浏览代码

maj && modif graphique

Tessier 4 年之前
父节点
当前提交
b1e14f5f7b
共有 100 个文件被更改,包括 3453 次插入570 次删除
  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. 45 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. 17 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. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/where-the-ocean-meets-the-beach/Sola Olulode at V.O Curations.jpg
  64. 18 3
      user/pages/02.whats-on/01.exhibitions/current-upcoming/where-the-ocean-meets-the-beach/events.md
  65. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Dig #7 2019.jpeg
  66. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Through (detail) 2020.jpeg
  67. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Through 2020.jpeg
  68. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/André Figueiredo da Silva Pinning Down (detail) 2020.jpeg
  69. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/André Figueiredo da Silva Pinning Down 2020.jpeg
  70. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Frances Gibson 40 Elephants 2020.jpeg
  71. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Frances Gibson A Pound of Flower 2020.jpeg
  72. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Installation Frances Gibson Alexandre Canonico André Figueiredo da Silva.jpeg
  73. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Abrasion Coast (detail) 2020.jpeg
  74. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Abrasion Coast 2020.jpeg
  75. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Waterfall (detail) 2020.jpeg
  76. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Waterfall 2020.jpeg
  77. 二进制
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker and Alexandré Canonico.jpeg
  78. 12 4
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/events.md
  79. 2 2
      user/pages/03.studio-programme/01.apply/form.md
  80. 二进制
      user/pages/03.studio-programme/02.about/VO Curations - 12th Floor.jpg
  81. 3 2
      user/pages/03.studio-programme/02.about/default.md
  82. 1 1
      user/pages/04.residence/02.current-upcoming/blog.md
  83. 二进制
      user/pages/04.residence/02.current-upcoming/emma-prempeh/20190611_090033694_iOS.jpg
  84. 2 2
      user/pages/04.residence/02.current-upcoming/emma-prempeh/artistes.md
  85. 二进制
      user/pages/04.residence/02.current-upcoming/sola-olulode/Sola Olulode at V.O Curations.JPG
  86. 5 6
      user/pages/04.residence/02.current-upcoming/sola-olulode/artistes.md
  87. 二进制
      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. 二进制
      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. 二进制
      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": {

文件差异内容过多而无法显示
+ 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');

文件差异内容过多而无法显示
+ 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;
     }

+ 45 - 0
user/config/plugins/aboutme.yaml

@@ -0,0 +1,45 @@
+enabled: false
+built_in_css: true
+name: 'Santa Claus'
+title: 'Present Giver'
+show_title: true
+description: 'Santa Claus, Saint Nicholas, Saint Nick, Father Christmas, Kris Kringle, Santy, or simply Santa is a figure with legendary, historical and folkloric origins who, in many Western cultures, is said to bring gifts to the homes of good children on 24 December, the night before Christmas Day. The modern figure of Santa Claus is derived from the British figure of Father Christmas, the Dutch figure of Sinterklaas, and Saint Nicholas, the historical Greek bishop and gift-giver of Myra. During the Christianization of Germanic Europe, this figure may also have absorbed elements of the god Odin, who was associated with the Germanic pagan midwinter event of Yule and led the Wild Hunt, a ghostly procession through the sky'
+picture_src:
+  user/plugins/aboutme/assets/avatars/santa.jpg:
+    name: santa.jpg
+    type: image/jpeg
+    size: 43391
+    path: user/plugins/aboutme/assets/avatars/santa.jpg
+gravatar:
+  enabled: false
+  email: example@test.com
+  size: 100
+social_pages:
+  enabled: true
+  use_font_awesome: false
+  pages:
+    facebook:
+      icon_type: b
+      icon: facebook
+      title: Facebook
+      position: 1
+    twitter:
+      icon_type: b
+      icon: twitter
+      title: Twitter
+      position: 2
+    github:
+      icon_type: b
+      icon: github
+      title: GitHub
+      position: 3
+    linkedin:
+      icon_type: b
+      icon: linkedin
+      title: LinkedIn
+      position: 4
+    instagram:
+      icon_type: b
+      icon: instagram
+      title: Instagram
+      position: 5

+ 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:

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

@@ -2,8 +2,8 @@
 date: '22-03-2019 16:06'
 date_end: '03-05-2019 12:38'
 lieux: '12th Floor'
-title: 'Cicatrices, '
 artistes: ' Muriel Abadie, Adriano Amaral, Jean-marie Appriou, Srijon Chowdhury, Isaac Lythgoe, John Miserendino, Maïa Régis, Cajsa von Zeipel'
+title: '‘Cicatrices'', '
 media_order: 'Installation Isaac Lythgoe and Muriel Abadie.jpg,Jean-Marie Appriou, Shrimp, 2018.jpg,Muriel Abadie, Masked Monkey, 2018.jpg,John Miserendino, Mamie and Marco, 2018.jpg,Adriano Amaral, Untitled, 2018.jpg,John Miserendino, L’origine du monde, 2018.jpg,Isaac Lythgoe, grip the wheel, look straight ahead, 2016.jpg,Details of Adriano Amaral, Untitled, 2018.jpg,Adriano Amaral, Untitled, 2018(2).jpg,Casja von Zeipel ''When?'', 2019.jpg,Adriano Amaral, untitled, 2018, Maïa Regis, Ramon Reyna, 2018.jpg,Details of Cajsa von Zeipel, When? and Why?, 2019.jpg,Cajsa von Zeipel, When? and Why?, 2019.jpg,Adriano Amaral, Untitled, 2018.jpg'
 show_sidebar: false
 aura:

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

@@ -4,7 +4,7 @@ date: '24-10-2019 17:21'
 date_end: '22-11-2019 17:21'
 lieux: '12th floor'
 artistes: ' Dillwyn Smith'
-title: 'Light Cages, '
+title: 'Light Cages'', '
 media_order: 'Installation 1 ''Light Cages'', Dillwyn Smith.jpg,Dillwyn Smith, ''Turning Poison into Medicine V'', 2018.jpg,Dillwyn Smith, ''Full Transparency II'', 2019, and ''Light Cage'', 2018.jpg,Dillwyn Smith, ''Sensitised Ground'', 2018.jpg,Installation 2 ''Light Cages'', Dillwyn Smith.jpg,Installation 3 ''Light Cages'', Dillwyn Smith.jpg,Dillwyn Smith, ''Full Transparency'', 2018.jpg,Dillwyn Smith, ''Hermit Song'', 2018.jpg,Dillwyn Smith, ''Working Class Hero'', 2018, and ''Praise'', 2018.jpg,Dillwyn Smith, ''Working Class Hero'', 2018.jpg'
 show_sidebar: false
 aura:

+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/mr-majidi-and-the-electricity-box/events.md

@@ -5,7 +5,7 @@ date_end: '20-12-2019 00:00'
 display_time: false
 lieux: '12th floor'
 artistes: 'Mania Akbari, Douglas White '
-title: '‘Mr. Majidi and the Electricity Box'' '
+title: '‘Mr. Majidi and the Electricity Box'','
 media_order: 'Installation Mania Akbari and Douglas White I.jpg,Geometric Resistance, Mania Akbari and Douglas White, 2019.jpg,Detail Mr. Majidi series, Mania Akbari and Douglas White, 2019 I.jpg,Detail Mr. Majidi series, Mania Akbari and Douglas White, 2019 II.jpg,Mr. Majidi series and House of Sin I, Mania Akbari and Douglas White, 2019.jpg,House of Sin I, Mania Akbari and Douglas White, 2019.jpg,House of Sin II, Mania Akbari and Douglas White, 2019.jpg,Installation Mania Akbari and Douglas White II.jpg,House of Sin III, Mania Akbari and Douglas White, 2019.jpg,Installation Mania Akbari and Douglas White III.jpg,Mr. Majidi series, Mania Akbari and Douglas White, 2019.jpg,Lubion, Mania Akbari and Douglas White, 2019 I.jpg,Lubion, Mania Akbari and Douglas White, 2019 II.jpg,Lubion box, Mania Akbari and Douglas White, 2019.jpg,Detail Lubion box, Mania Akbari and Douglas White, 2019.jpg'
 show_sidebar: false
 aura:

+ 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:

+ 17 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/parfum-depines-phillips-x-vo-curations/events.md

@@ -2,8 +2,8 @@
 date: '23-05-2019 16:33'
 date_end: '26-07-2019 16:33'
 lieux: Phillips
-title: 'Parfum d''épines,'
 artistes: ' PHILLIPS X VO CURATIONS'
+title: '‘Parfum d''épines'','
 media_order: 'Installation Daiga Grantina and Robert Brambora.jpg,Installation Jean-Marie Appriou and Claude Bellegarde.jpg,Daiga Grantina, Li Li, 2018 and Victor Man, Flowers with Skeleton and Bear Wrestling, 2012.jpg,Installation Jean-Marie Appriou, Muriel Abadie and Claude Bellegarde.jpg,Installation Oda Jaune, Pedro Wirz and Sonya Derviz.jpg,Robert Brambora, Untitled, 2019, Untitled, 2019, and 9 to 5 (2), 2018.jpg,Oda Jaune, Fleurs, 2015.jpg,Victor Man, Flowers with Skeleton and Bear Wrestling, 2012.jpg,Daiga Grantina, Yolk Bott, 2017.jpg'
 show_sidebar: false
 aura:
@@ -11,3 +11,19 @@ aura:
 ---
 
 Phillips Auction House and V.O Curations are pleased to present a novel reading of Charles Baudelaire's collection of poems _Les Fleurs du Mal_ through the spectrum of contemporary art, exploring the seminal concept of the double postulation that has protruded beyond the literary universe of the French poet. The exhibition features the works of 9 contemporary artists that play upon the duality of the Baudelairian ethos and its fascination for antithetical pairings, the atemporal resonance of what the poet refers to as the "two inseparable abysses of the human soul". Parfum d'épines was supported by Parcours 2019 and Outset Contemporary Art Fund.
+
+--
+
+Charles Baudelaire a bâti son univers poétique dans l'atmosphère parisienne des années 1850, une période pendant laquelle la misère coexistait avec la plus grande des sophistications, où la ville de Paris a connu de drastiques transformations urbaines. Flânant le long des avenues Haussmanniennes éclairées aux becs de gaz, le poète regrette le passé. Pourtant c'est cette modernité émergente, son dynamisme et sa vulgarité qui l'ont conduit à développer une nouvelle forme de poésie. Fervent catholique, auteur de scandaleux blasphèmes qui lui ont valu d'être condamné et censuré, Charles Baudelaire a fait de la dualité son axiome fondamental, étant lui-même divisé entre la quête du Beau et l'inévitable abysse du péché. Puisant dans la tendresse et la haine, le parfum et les épines, Les Fleurs du Mal - recueil publié en 1857 - sont telle une prouesse de la Beauté inextricablement liée au Mal. La cohésion de ces polarités permet de faire sens dans des directions opposées sans pour autant les disjoindre. En considérant la paire antithétique comme un tout, Baudelaire forge la notion de double postulation, cœur de son univers littéraire. L'exposition Parfum d’épines cherche à en révéler l'écho dans le domaine de l'art contemporain.
+
+  Les sculptures abstraites et composites de Daiga Grantina ressemblent à des organes palpitants, des pans de chairs dont le plasma et les fluides vitaux auraient été drainés. Elles s’apparentent à une version plus asséchée des viscères du "ventre putride" de la Charogne. Tulle, coton, mousse et silicone : les tissus ondulés se déversent dans l'espace et déploient un univers de signification riche suggérant à la fois le végétal, le médical et le délicat. Ces plis voluptueux sont semblables à des amphores elles-mêmes ressemblant à un intestin. On entend résonner les vers de Baudelaire dans lequel le souvenir d'un cadavre devient une source de création esthétique: " et le ciel regardait la carcasse superbe comme une fleur s'épanouir ". De la même façon, le bouquet d'entrailles d'Oda Jaune rapproche le floral de l'intestinal. Les organes sanglants qu'elle représente sont plus réalistes que ceux de Daiga Grantina en ce qu'ils possèdent encore leurs fluides vitaux qui permettent au cadavre de fleurir chez Baudelaire, à une photosynthèse corporelle d'advenir. Aussi, après l'extraction de ces fleurs de sang, le bouquet de viscères, est maintenu en vie extra-corporellement. Sous le prisme baudelairien se laisse entrevoir tout un imaginaire de l'amour dévorant et des "amours décomposés".
+
+  Dans le bouquet de Victor Man, réminiscence des fleurs maladives du poète, la vie semble se cacher sous un masque mortuaire: les fleurs de chardon apparaissent fanées alors qu'elles sont en pleine floraison. Au lieu d'être parées de leur naturelle teinte améthyste, elles portent le gris. La fleur de chardon, utilisée comme un aphrodisiaque à l'époque Élisabéthaine, couplée avec le squelette, évoque les memento mori, ces peintures illustrant la locution latine " souviens toi que tu vas mourir ". Elles réunissent les symboles de deux pulsions majeures, à savoir l'Eros et le Thanatos, que Baudelaire convoque quand, dans Femmes Damnées (Delphine et Hippolyte), il trouve sur un sein "la fraîcheur des tombeaux !". 
+  Les profils aplatis de Robert Brambora ne privilégient pas le rendu sculptural du modèle mais plutôt leur être émotionnel. Ainsi, ce qui est normalement intime se trouve dévoilé, l'extérieur et l'intérieur se confondent en des paysages de soleils et de nuages. Si l'on établit une référence avec les Correspondances de Baudelaire, l'œuvre de Brambora nous fait voir le monde naturel comme "un temple où de vivants piliers laissent parfois sortir de confuses paroles". Dans ce monde, véritable " forêts de symboles ", l'humain est étroitement lié à la flore et à la faune. 
+
+  La fascination de Pedro Wirz pour la métamorphose des amphibiens - de leur apparence à leur habitat, passant de l'œuf à la grenouille, de l'eau à la terre – imprègne sa production artistique. Marqué par les mythes et les rituels brésiliens auxquels il a été exposé dans l'enfance, l'artiste traite l'animal avec une dimension spirituelle et l'élève, peut-être, au rang d'alter ego. En plus de cette notion de dédoublement, on trouve également dans ses œuvres, le rapport d'inversion et le devenir autre explicité par la grenouille moulée en cire d'abeille.
+
+  En employant l'aluminium, Jean-Marie Appriou transforme un matériau industriel en matière organique, créant son propre microenvironnement ; cyprès, chauve-souris et serpent peuplent ce monde métallique. Un certain mysticisme se trouve entremêlé à une ténébreuse perception. Le cyprès symbolise à la fois la mort et l'aspiration ascétique et le serpent expulsé du Paradis nous rappelle l'indolente tentatrice du Serpent qui Danse. Après avoir été confiné aux enfers, une chauve-souris baudelairienne s'élance et surgit dans la lumière cherchant éperdument l'Espoir et sera finalement incapable de quitter sa grotte. Si la chauve-souris de Jean-Marie Appriou se bat pour s'élever, l'oiseau de Muriel Abadie choit vertigineusement comme l'Albatros de Baudelaire. À travers l'énergie conférée par la forme esquissée, Muriel Abadie a transposé le dynamisme de l'oiseau, sa liberté et sa mobilité à l'âme du poète qui mène à la fois une existence terrestre et céleste. Chutant vers le sol, peut-être même déjà retenu par lui, le vol est interrompu et la maison vide ressemble à une cage. Entre l'ascension et la chute, cygne ou albatros, le poète n'est jamais aussi éloigné des Hommes que quand il est parmi eux. La figure fantomatique de Sonya Derviz quant à elle, reflète la dualité de la condition humaine: multitude de facettes en une seule entité. 
+
+  Les achromes de Claude Bellegarde encerclent cet inhérent jeu de doubles à travers l'emploi du blanc, une non-couleur ayant le potentiel d'être néant et origine, une blancheur féconde, un point zéro de l'espace qui n'est plus soumis au despotisme de la perspective. Dans le travail de l'artiste, les plis, les larmes et les rides affirment leur corporéité et donnent l'impression d'une matière travaillée vivante tel un papier froissé que Baudelaire assimile à une compression du cœur. 
+

+ 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:

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


+ 18 - 3
user/pages/02.whats-on/01.exhibitions/current-upcoming/where-the-ocean-meets-the-beach/events.md

@@ -1,13 +1,28 @@
 ---
-topH: true
+topH: false
 date: '13-02-2020 10:31'
 date_end: '28-03-2020 10:31'
 lieux: '12th floor'
 artistes: 'Sola Olulode'
-title: '‘Where The Ocean Meets The Beach'' '
+title: '‘Where The Ocean Meets The Beach'','
+media_order: 'Installation. Sola Olulode. In the Middle. The Feels and A Perfect Summer''s Day.jpeg,Sola Olulode. The Feels and A Perfect Summer''s Day. 2019.jpeg,Sola Olulode. The Feels. 2019.jpeg,Sola Olulode. A Perfect Summer''s Day. 2019.jpeg,Sola Olulode. Gals Pals. 2019.jpg,Sola Olulode. Close To You. 2019.jpeg,Installation. Sola Olulode. I Could Just Lay All Day With You and Laying in the Grass.jpeg,Sola Olulode. I Could Just Lay All Day With You. 2019.jpg,Sola Olulode. Laying in The Grass. 2019.jpg,Installation. Sola Olulode. Laying in the Grass. Entwined and Eternal Light.jpeg,Installation. Sola Olulode. Safety and Entwined.jpeg,Sola Olulode. Safety. 2019.jpeg,Sola Olulode. Entwined. 2020.jpeg,Sola Olulode. Eternal Light. 2020.jpeg,Sola Olulode. Waves. 2020.jpeg'
 show_sidebar: false
 aura:
     pagetype: website
 ---
 
-Sola Olulode's end of residency solo show ‘_Where the Ocean Meets the Beach_' will take place at our 12th floor gallery from 13th February until 28th March. Olulode has been working on a new series of paintings in which narratives of queer and Black identity culminate in the representation of romantic relationships an their complexities. Taken from artist Travis Alabanza's poem _The Sea_, the exhibition title refers to Alabanza’s feeling on gender fluidity as a boundless place similar to 'where the ocean meets the beach'. Analogously to Alabanza’s sentiment, Olulode renders the fluidity of emotions that chronicle the falling in love. 
+_Where the Ocean Meets the Beach_ is Sola Olulode’s end of residency show at V.O Curations, a three-month period dedicated to Olulode’s research into representations of QTIBPOC (Queer Trans Intersex Black People & People of Colour) and romance. The artist presents a new series of paintings where narratives of Black queer womxn culminate in the representation of romantic relationships and their complexities, directly drawing upon Olulode’s experiences. Focusing on love, Olulode delves into the intimacy, complicity of dating and the impassioned ‘honeymoon’ phase.
+
+Taken from artist Travis Alabanza's poem _The Sea_, the exhibition title refers to Alabanza’s feeling on gender fluidity as a boundless place similar to ‘where the ocean meets the beach’. Analogously to the poet’s sentiment, Olulode renders the fluidity of emotions that chronicles the process of falling in love. The constant malleable shift and undefinable identity, a central theme of Alabanza’s poem, enhances and sets the stage for Olulode’s characters to tell the untold, but timeless, stories that remain crystallised in calming moments of reflection and joy.
+
+By creating a space that encapsulates the lovers, Olulode transports them into a placid and tranquil bubble that transcends reality. Exploring love as a protective barrier, a refuge from the misrepresentation and the confinement within a heteronormative ideal, that both denies and depreciates queer relationships. Reflecting the intimate and complex relationships of Black queer womxn, be they romantic or deeply platonic, Olulode questions the dynamics of the same existence of these relationships. The lovers’ genders are undefined and devoid of eroticity, straying away from a grazing patriarchal gaze of male desire and traditional heterosexual relational aesthetics. The figures are intrinsically naive and exude an aura of innocence, parting from references to eroticisim and crude sexuality, which queer relationships often become victim to. The perspective offered is delicate, creeping softly into a space, where only the deeply profound bond remains.
+
+_Safety_ (2019) represents Olulode’s depiction of a safe and intimate space for the lovers resting under the duvet covers; inundated by a veil of indigo, the painting recalls Olulode’s experimentation with the Adire technique, a dyed textile made by the Yoruba people in Nigeria. Dyeing textiles and the use of melted wax directly references the artist’s Nigerian roots, echoing and informing her oeuvre through the lens of her background and history.
+
+From the deeper tones that evoke the atmosphere and intimacy of nighttime, to the warmer hues that veil daily interactions between the lovers, indigo and turmeric envelop the characters and almost blend with them, becoming both part and background for them to exist. _A Perfect Summer’s Day_ (2019) amalgamates the lovers in a honey- glazed lens, where they lay imperturbable, suspended in a dimension without time nor space.
+
+Olulode emphasises the undefinability of the characters and their gender, hair and clothing remaining the only signifier of personal identity. Curly afros, twists, locs with thick deep black lines and cornrows: the appearance of detailed hairstyles allows for more agency and reality to each figure. The intricate details are delicately interwoven and applied, either sewn or drawn upon, generating unique patterns that become an identifiable detail within her works. The lovers in _Good Vibrations _(2020) are distinct from other figures as their clothing becomes a prominent part of their depiction, with polka dots and pinstripes gently laying down on their bodies and raising them from the background where they emerge from.
+
+_British-Nigerian artist Sola Olulode (b. 1996, London) graduated from the University of Brighton in 2018. After successfully completing a residency at Lewisham Art House, she was recently shortlisted for the Evening Standard Art Prize, and featured in V&A’s 2020 exhibition ‘In the Palm of Your Hands’ . Olulode has been developing a narrative investigating queerness and black identity through the lens of social interactions. _
+
+Accessibility entrance from 39 York Road. For further assistance please call +447789813313.

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


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


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


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


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


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


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


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


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


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


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


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


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


+ 12 - 4
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/events.md

@@ -1,14 +1,22 @@
 ---
-topH: true
+topH: false
 date: '09-01-2020 17:50'
 date_end: '01-02-2020 17:50'
 lieux: '12th floor'
 artistes: 'Frances Gibson, Alexandre Canonico, André Figueiredo da Silva, Josephine Baker'
-title: '''Wreck Or Ruin'''
-media_order: 'Josephine Baker and Alexandré Canonico.jpeg,Alexandre Canonico Through 2020.jpeg,Alexandre Canonico Through (detail) 2020.jpeg,Josephine Baker Abrasion Coast 2020.jpeg,Josephine Baker Abrasion Coast (detail) 2020.jpeg,Josephine Baker Waterfall 2020.jpeg,Josephine Baker Waterfall (detail) 2020.jpeg,Installation Frances Gibson Alexandre Canonico André Figueiredo da Silva.jpeg,Frances Gibson A Pound of Flower 2020.jpeg,Frances Gibson 40 Elephants 2020.jpeg,Alexandre Canonico Dig #7 2019.jpeg,André Figueiredo da Silva Pinning Down 2020.jpeg,André Figueiredo da Silva Pinning Down (detail) 2020.jpeg'
+title: '‘Wreck Or Ruin’,'
+media_order: 'Installation. Alexandre Canonico and Jospehine Baker.jpeg,Josephine Baker. Abrasion Coast. 2020.jpeg,Josephine Baker. Abrasion Coast (detail). 2020.jpeg,Installation. Alexandre Canonico and Josephine Baker.jpeg,Josephine Baker. Waterfall. 2020.jpeg,Josephine Baker. Waterfall (detail). 2020.jpeg,Alexandre Canonico. Through. 2020.jpeg,Alexandre Canonico. Through (detail). 2020.jpeg,Installation. Frances Gibson. Alexandre Canonico. André Figueiredo da Silva.jpeg,Frances Gibson. A Pound of Flower. 2020.jpeg,Frances Gibson. 40 Elephants. 2020.jpeg,Frances Gibson. 40 Elephants (detail). 2020.jpeg,Alexandre Canonico. Dig #7. 2019.jpeg,André Figueiredo da Silva. Pinning Down. 2020.jpeg'
 show_sidebar: false
 aura:
     pagetype: website
 ---
 
-We are thrilled to host ‘Wreck or Ruin’, a collaborative project of sculptural works that has organically developed between André Figueiredo da Silva, Alexandre Canonico, Josephine Baker and Frances Gibson at 12th floor, reflecting upon the impending demolition of Tower Building.
+‘Wreck or Ruin’ is a collaborative project that has organically developed between André Figueiredo da Silva,  Alexandre Canonico,  Josephine  Baker  and  Frances  Gibson  at  12th  floor,  reflecting  upon  the impending demolition of Tower Building.  
+
+As  a  studio  resident  in  the  Tower  Building,  Figueiredo  da  Silva  incorporates  architectural  elements from the site: corners, walls, columns. Pink sculptural foamed columns act as traces of a building that will  cease  to  exist.  As  the  foam  cannot  sustain  anything  in  its  usual  form,  Figueiredo  da  Silva’s structure  relies  on  carefully  depressurised  footballs  that  require  a  specific  maintenance,  similar  to that of the building. By misusing the materials and turning them into something they are not meant to be, the artist makes an allusion to the building's present state of instability. 
+
+Using materials as a starting point, Canonico has taken a playful approach to create sculptures from commonly found objects. Metal rods and hoses sit together, sustained by the interplay between their different material qualities to bridge the space between floor and ceiling.  While Canonico’s works are not site-specific, they rely on the ‘negative space’ surrounding them. The sculptures embrace the air they encapsulate.  
+
+Artist  Josephine  Baker  has  created  relief  works  that  mimic  the  historical  production  of  nature  and landscape  as  images  or  ‘grand  designs’  of  humancentric  relations.  Their  diagrammatic  and  almost pixelated  motifs,  made  out  of  building  and  industrial  materials,  appear  to  illustrate  elemental processes such as water cycles and the movements of the earth. Her works further comment on the lack of distinction between human-made and natural disasters in today's world.
+
+Finally, Gibson’s work takes the form of a memorial of forty small clay elephants and a sculpture of a pound  symbol  made  of  carnations.  The  small  sculptures  reference  the  infamous  all-women  crime syndicate   ‘The   Forty   Elephants’   (based   in   Elephant   and   Castle),   who   conducted   the   largest shoplifting  operation  in  Britain  during  their  activity  from  the  1870s  to  1950s.  Gibson’s  work  for  the project is informed by a tenuous chain of events and research based on the history of the building.

+ 2 - 2
user/pages/03.studio-programme/01.apply/form.md

@@ -83,6 +83,6 @@ form:
             reset: true
 ---
 
-We accept applications of artists from all practices. Our studios can be used individually or shared with others; applications are for one person at a time, so even if you are applying as a group, please fill out individual applications and specify in _notes_ who you wish to share with. Our studio prices start from £100pm and are offered inline with _Greater London Authority's affordability guide (2014, 2019)_. All prices are inclusive of utilities (inc. wifi). Facilities are accessible 24/7 with communal areas and shared kitchens available.
+We accept applications of artists from all practices. Our studios can be used individually or shared with others; applications are for one person at a time, even if you apply as a group, please fill out individual applications and specify in _notes_ who you wish to share with. Our studio prices start from £100pm and are offered inline with _Greater London Authority's affordability guide (2014, 2019)_. All prices are inclusive of utilities. Facilities are accessible 24/7 with communal areas and shared kitchens available. 
 
-Please allow us a week to get back to you, you will receive a response via email. Feel free to contact [studio@vocurations.com](mailto:studio@vocurations.com) for any further questions, or to organise a site visit!
+Feel free to contact [studio@vocurations.com](mailto:studio@vocurations.com) for any further questions, or to organise a site visit.

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


+ 3 - 2
user/pages/03.studio-programme/02.about/default.md

@@ -1,11 +1,12 @@
 ---
 title: About
-media_order: 'VO Curations - 12th Floor.jpg'
+media_order: 'Anousha Payne.jpg,SJM_VO_SonnyShanti-9451.jpg,SJM_Vo-9154.jpg,Portrait Azadeh and Salman.jpg,_MG_2961 (2).jpg'
 visible: false
 aura:
     pagetype: website
 ---
 
-V.O Studios offers affordable workspaces to artists based in London. The group reflects the diversity of artists and practices in the city, and form an integral part of V.O’s community-at-large. All studios are located in Zone 1 Central London, with the view to ease access and visibility for each artist. ‘Open Days’ are scheduled throughout the year to offer artists ongoing opportunities to explore new dialogues with interested members of the local community, as well as the wider public. V.O ensures their artist community their ongoing support through advising on professional development, mentoring, presentation, and access to workshop and exhibition opportunities. V.O Studios are currently located in Waterloo, SE1 and 242 Marylebone Road, NW1, with more upcoming spaces from spring 2020! 
+V.O Studios offers affordable workspaces to artists based in London. The group reflects the diversity of artists and practices in the city, and form an integral part of V.O’s community-at-large. All studios are located in Zone 1 Central London, with the view to ease access and visibility for each artist. ‘Open Days’ are scheduled throughout the year to offer artists ongoing opportunities to explore new dialogues with interested members of the local community, as well as the wider public. V.O ensures their artist community their ongoing support through advising on professional development, mentoring, presentation, and access to workshop and exhibition opportunities. V.O Studios are currently located in 242 Marylebone Road, NW1, with more upcoming spaces from Autumn 2020.
 _V.O Studios is a not-for-profit initiative launched October 2019._
 
+

+ 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

二进制
user/pages/04.residence/02.current-upcoming/emma-prempeh/20190611_090033694_iOS.jpg


+ 2 - 2
user/pages/04.residence/02.current-upcoming/emma-prempeh/artistes.md

@@ -3,11 +3,11 @@ residence: true
 date: '12-12-2019 13:22'
 date_end: '27-02-2020 13:22'
 title: 'Emma Prempeh '
-media_order: 20190611_090033694_iOS.jpg
+media_order: 'Example Vo.jpg'
 published: true
 show_sidebar: false
 aura:
     pagetype: website
 ---
 
-Recent graduate from Goldsmiths University, Emma Prempeh is currently an artist-in-residence at V.O Curations, completing a new series of work which will be exhibited with V.O Curations in Spring 2020.
+Artist Emma Prempeh (b.1996, London) is a fine art graduate from Goldsmiths University and is currently attending an MA in Painting at the Royal College of Art. Family and generational continuity is often the subject of Prempeh’s paintings, as relational ties are explored and questioned, through the depiction of her mother and grandmother and their experiences. The search of spirituality enables Prempeh to analyse existential questions that are projected upon her reality; the fear of death, memory, ancestral ties. After completing a 2-month residency at V.O Curations, Prempeh has debuted with solo show ‘The Faces of Love’ (13.10-14.11.2020) held at V.O Studios, 242 Marylebone Road.

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


+ 5 - 6
user/pages/04.residence/02.current-upcoming/sola-olulode/artistes.md

@@ -1,12 +1,11 @@
 ---
-date: '14-11-2019 14:54'
-date_end: '14-02-2020 14:54'
-title: 'Sola Olulode '
-media_order: 'Sola Olulode at V.O Curations.JPG'
+date: '14-11-2019 10:34'
+date_end: '14-02-2020 10:34'
+title: 'Sola Olulode'
+media_order: 'Sola Olulode at VO Curations.jpg'
 show_sidebar: false
 aura:
     pagetype: website
-date_start: '14-11-2019 10:46'
 ---
 
-British-Nigerian artist Sola Olulode is currently completing a three-month residency at V.O Curations working on a new series of paintings where narratives of queer and Black identity culminate in the representation of romantic relationships and their complexities. The artist's end of residency solo show 'Where the ocean meets the beach' will take place at V.O's 12th floor gallery from 13 February to 28 March.
+British-Nigerian artist Sola Olulode (b. 1996, London) is a fine art graduate from the University of Brighton. Olulode explores the relations of QTIBPOC (Queer Trans Intersex Black People & People of Colour) and black identity through the lens of social interactions, working with wax and textile dyes taken from Yoruba tradition. Sola Olulode has been in a 3-month residency at V.O Curations, culminating in the solo show ‘Where the Ocean Meets the Beach’ (13.02-28.03-2020) at V.O Curations, 12th Floor. 

二进制
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/)

二进制
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
----
-

二进制
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:

部分文件因为文件数量过多而无法显示