[
  {
    "path": ".dependencies",
    "content": "git:\n    problems:\n        url: https://github.com/getgrav/grav-plugin-problems\n        path: user/plugins/problems\n        branch: master\n    error:\n        url: https://github.com/getgrav/grav-plugin-error\n        path: user/plugins/error\n        branch: master\n    markdown-notices:\n        url: https://github.com/getgrav/grav-plugin-markdown-notices\n        path: user/plugins/markdown-notices\n        branch: master\n    quark:\n        url: https://github.com/getgrav/grav-theme-quark\n        path: user/themes/quark\n        branch: master\nlinks:\n    problems:\n        src: grav-plugin-problems\n        path: user/plugins/problems\n        scm: github\n    error:\n        src: grav-plugin-error\n        path: user/plugins/error\n        scm: github\n    markdown-notices:\n        src: grav-plugin-markdown-notices\n        path: user/plugins/markdown-notices\n        scm: github\n    quark:\n        src: grav-theme-quark\n        path: user/themes/quark\n        scm: github\n"
  },
  {
    "path": ".editorconfig",
    "content": "# EditorConfig is awesome: http://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with a newline ending every file\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 4\ntrim_trailing_whitespace = true\n\n# 2 space indentation\n[*.{yaml,yml,vue,js,css}]\nindent_size = 2\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\npatreon: # Replace with a single Patreon username\nopen_collective: grav\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncustom: # Replace with a single custom sponsorship URL\n"
  },
  {
    "path": ".github/workflows/build.yaml",
    "content": "name: Release Builds\n\non:\n  release:\n    types: [published]\n\npermissions: {}\n\njobs:\n  build:\n    permissions:\n      contents: write # for release creation (svenstaro/upload-release-action)\n\n    if: \"!github.event.release.prerelease\"\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v2\n        with:\n          ref: ${{ github.ref }}\n\n      - name: Extract Tag\n        run: echo \"PACKAGE_VERSION=${{ github.ref }}\" >> $GITHUB_ENV\n\n      - name: Setup PHP\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: 7.3\n          extensions: opcache, gd\n          tools: composer:v2\n          coverage: none\n        env:\n          COMPOSER_TOKEN: ${{ secrets.GLOBAL_TOKEN }}\n\n      - name: Install Dependencies\n        run: |\n          sudo apt-get -y update -qq  < /dev/null > /dev/null\n          sudo apt-get -y install -qq git zip < /dev/null > /dev/null\n\n      - name: Retrieval of Builder Scripts\n        run: |\n          # Real Grav URL\n          curl --silent -H \"Authorization: token ${{ secrets.GLOBAL_TOKEN }}\" -H \"Accept: application/vnd.github.v3.raw\" ${{ secrets.BUILD_SCRIPT_URL }} --output build-grav.sh\n\n          # Development Local URL\n          # curl ${{ secrets.BUILD_SCRIPT_URL }} --output build-grav.sh\n\n      - name: Grav Builder\n        run: |\n          bash ./build-grav.sh\n\n      - name: Upload packages to release\n        uses: svenstaro/upload-release-action@v2\n        with:\n          repo_token: ${{ secrets.GITHUB_TOKEN }}\n          tag: ${{ env.PACKAGE_VERSION }}\n          file: ./grav-dist/*.zip\n          overwrite: true\n          file_glob: true\n\n  slack:\n    permissions:\n      actions: read # to list jobs for workflow run (technote-space/workflow-conclusion-action)\n\n    name: Slack\n    needs: build\n    runs-on: ubuntu-latest\n    if: always()\n    steps:\n      - uses: technote-space/workflow-conclusion-action@v2\n      - uses: 8398a7/action-slack@v3\n        with:\n          status: failure\n          fields: repo,message,author,action\n          icon_emoji: ':octocat:'\n          author_name: 'Github Action Build'\n          text: '🚚 Automated Build Failure'\n        env:\n          GITHUB_TOKEN: ${{ secrets.GLOBAL_TOKEN }}\n          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\n        if: env.WORKFLOW_CONCLUSION == 'failure'\n"
  },
  {
    "path": ".github/workflows/tests.yaml",
    "content": "name: PHP Tests\n\non:\n  push:\n    branches: [ develop ]\n  pull_request:\n    branches: [ develop ]\n\npermissions:\n  contents: read # to fetch code (actions/checkout)\n\njobs:\n  unit-tests:\n    strategy:\n      matrix:\n        php: ['8.3', '8.2', '8.1', '8.0', '7.4', '7.3']\n        os: [ubuntu-latest]\n\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Setup PHP ${{ matrix.php }}\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: ${{ matrix.php }}\n          extensions: opcache, gd\n          tools: composer:v2\n          coverage: none\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Get composer cache directory\n        id: composer-cache\n        run: echo \"dir=$(composer config cache-files-dir)\" >> $GITHUB_OUTPUT\n\n      - name: Cache dependencies\n        uses: actions/cache@v4\n        with:\n          path: ${{ steps.composer-cache.outputs.dir }}\n          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}\n          restore-keys: ${{ runner.os }}-composer-\n\n      - name: Install dependencies\n        run: composer install --prefer-dist --no-progress\n\n      - name: Run test suite\n        run: vendor/bin/codecept run\n\n#  slack:\n#      name: Slack\n#      needs: unit-tests\n#      runs-on: ubuntu-latest\n#      if: always()\n#      steps:\n#        - uses: technote-space/workflow-conclusion-action@v2\n#        - uses: 8398a7/action-slack@v3\n#          with:\n#             status: failure\n#             fields: repo,message,author,action\n#             icon_emoji: ':octocat:'\n#             author_name: 'Github Action Tests'\n#             text: '💥 Automated Test Failure'\n#          env:\n#            GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n#            SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\n#          if: env.WORKFLOW_CONCLUSION == 'failure'\n"
  },
  {
    "path": ".github/workflows/trigger-skeletons.yml",
    "content": "name: Trigger Skeletons Build\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Which Grav release to use'\n        required: true\n        default: 'latest'\n      admin:\n        description: 'Create also a package with Admin'\n        required: true\n        default: true\n\npermissions:\n  contents: read # to fetch code (actions/checkout)\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    env:\n      WORKFLOW: \"build-skeleton.yml\"\n      AUTH: \":${{secrets.GLOBAL_TOKEN}}\"\n    steps:\n      - uses: actions/checkout@v2\n      - name: Make it rain ☔️\n        run: |\n          SKELETONS=`curl -s \"${{secrets.SKELETONS_JSON_LIST}}\"`\n          echo \"$SKELETONS\" | jq -cr '.[]' | while read SKELETON; do\n            KEY=$(echo \"$SKELETON\" | jq -cr 'keys[0]')\n            VERSION=$(echo \"$SKELETON\" | jq -cr '.[]')\n            URL=\"https://api.github.com/repos/${KEY}/actions/workflows/${WORKFLOW}/dispatches\"\n\n            curl -X POST \\\n            -u \"${AUTH}\" \\\n            -H \"Accept: application/vnd.github.everest-preview+json\" \\\n            -H \"Content-Type: application/json\" \\\n            -sS \\\n            ${URL} \\\n            --data '{ \"ref\": \"develop\", \n                      \"inputs\": { \n                        \"tag\": \"'\"$VERSION\"'\", \n                        \"version\": \"'\"$INPUT_VERSION\"'\", \n                        \"admin\": \"'\"$INPUT_ADMIN\"'\" \n                      } \n                    }' > /dev/null\n            echo \"Dispatched Worfklow for ${KEY}@$VERSION\"\n          done\n"
  },
  {
    "path": ".gitignore",
    "content": "# Composer\n.composer\nvendor/*\n!*/vendor/*\n\n# Sass\n.sass-cache\n\n# Grav Specific\nbackup/*\n!backup/.*\ncache/*\n!cache/.*\nassets/*\n!assets/.*\nlogs/*\n!logs/.*\nimages/*\n!images/.*\nuser/accounts/*\n!user/accounts/.*\nuser/data/*\n!user/data/.*\nuser/plugins/*\n!user/plugins/.*\nuser/themes/*\n!user/themes/.*\nuser/**/config/security.yaml\n\n# Environments\n.env\n.gravenv\n\n# OS Generated\n.DS_Store*\nehthumbs.db\nIcon?\nThumbs.db\n*.swp\n\n# phpstorm\n.idea/*\n\n# testing stuff\ntests/_output/*\ntests/_support/_generated/*\ntests/cache/*\ntests/error.log\nsystem/templates/testing/*\n/user/config/versions.yaml\n/tmp\n"
  },
  {
    "path": ".htaccess",
    "content": "<IfModule mod_rewrite.c>\n\nRewriteEngine On\n\n## Begin RewriteBase\n# If you are getting 500 or 404 errors on subpages, you may have to uncomment the RewriteBase entry\n# You should change the '/' to your appropriate subfolder. For example if you have\n# your Grav install at the root of your site '/' should work, else it might be something\n# along the lines of: RewriteBase /<your_sub_folder>\n##\n\n# RewriteBase /\n\n## End - RewriteBase\n\n## Begin - X-Forwarded-Proto\n# In some hosted or load balanced environments, SSL negotiation happens upstream.\n# In order for Grav to recognize the connection as secure, you need to uncomment\n# the following lines.\n#\n# RewriteCond %{HTTP:X-Forwarded-Proto} https\n# RewriteRule .* - [E=HTTPS:on]\n#\n## End - X-Forwarded-Proto\n\n## Begin - Exploits\n# If you experience problems on your site block out the operations listed below\n# This attempts to block the most common type of exploit `attempts` to Grav\n#\n# Block out any script trying to use twig tags in URL.\nRewriteCond %{REQUEST_URI} ({{|}}|{%|%}) [OR]\nRewriteCond %{QUERY_STRING} ({{|}}|{%25|%25}) [OR]\n# Block out any script trying to base64_encode data within the URL.\nRewriteCond %{QUERY_STRING} base64_encode[^(]*\\([^)]*\\) [OR]\n# Block out any script that includes a <script> tag in URL.\nRewriteCond %{QUERY_STRING} (<|%3C)([^s]*s)+cript.*(>|%3E) [NC,OR]\n# Block out any script trying to set a PHP GLOBALS variable via URL.\nRewriteCond %{QUERY_STRING} GLOBALS(=|\\[|\\%[0-9A-Z]{0,2}) [OR]\n# Block out any script trying to modify a _REQUEST variable via URL.\nRewriteCond %{QUERY_STRING} _REQUEST(=|\\[|\\%[0-9A-Z]{0,2})\n# Return 403 Forbidden header and show the content of the root homepage\nRewriteRule .* index.php [F]\n#\n## End - Exploits\n\n## Begin - Index\n# If the requested path and file is not /index.php and the request\n# has not already been internally rewritten to the index.php script\nRewriteCond %{REQUEST_URI} !^/index\\.php\n# and the requested path and file doesn't directly match a physical file\nRewriteCond %{REQUEST_FILENAME} !-f\n# and the requested path and file doesn't directly match a physical folder\nRewriteCond %{REQUEST_FILENAME} !-d\n# internally rewrite the request to the index.php script\nRewriteRule .* index.php [L]\n## End - Index\n\n## Begin - Security\n# Block all direct access for these folders\nRewriteRule ^(\\.git|cache|bin|logs|backup|webserver-configs|tests)/(.*) error [F]\n# Block access to specific file types for these system folders\nRewriteRule ^(system|vendor)/(.*)\\.(txt|xml|md|html|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ error [F]\n# Block access to specific file types for these user folders\nRewriteRule ^(user)/(.*)\\.(txt|md|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ error [F]\n# Block all direct access to .md files:\nRewriteRule \\.md$ error [F]\n# Block all direct access to files and folders beginning with a dot\nRewriteRule (^|/)\\.(?!well-known) - [F]\n# Block access to specific files in the root folder\nRewriteRule ^(LICENSE\\.txt|composer\\.lock|composer\\.json|\\.htaccess)$ error [F]\n## End - Security\n\n</IfModule>\n\n# Begin - Prevent Browsing and Set Default Resources\nOptions -Indexes\nDirectoryIndex index.php index.html index.htm\n# End - Prevent Browsing and Set Default Resources\n"
  },
  {
    "path": ".phan/config.php",
    "content": "<?php\nreturn [\n    \"target_php_version\" => null,\n    'pretend_newer_core_functions_exist' => true,\n    'allow_missing_properties' => false,\n    'null_casts_as_any_type' => false,\n    'null_casts_as_array' => false,\n    'array_casts_as_null' => false,\n    'strict_method_checking' => true,\n    'quick_mode' => false,\n    'simplify_ast' => false,\n    'directory_list' => [\n        '.',\n    ],\n    \"exclude_analysis_directory_list\" => [\n        'vendor/'\n    ],\n    'exclude_file_list' => [\n        'system/src/Grav/Common/Errors/Resources/layout.html.php',\n        'tests/_support/AcceptanceTester.php',\n        'tests/_support/FunctionalTester.php',\n        'tests/_support/UnitTester.php',\n    ],\n    'autoload_internal_extension_signatures' => [\n        'memcached' => '.phan/internal_stubs/memcached.phan_php',\n        'memcache' => '.phan/internal_stubs/memcache.phan_php',\n        'redis' => '.phan/internal_stubs/Redis.phan_php',\n    ],\n    'plugins' => [\n        'AlwaysReturnPlugin',\n        'UnreachableCodePlugin',\n        'DuplicateArrayKeyPlugin',\n        'PregRegexCheckerPlugin',\n        'PrintfCheckerPlugin',\n    ],\n    'suppress_issue_types' => [\n        'PhanUnreferencedUseNormal',\n        'PhanTypeObjectUnsetDeclaredProperty',\n        'PhanTraitParentReference',\n        'PhanTypeInvalidThrowsIsInterface',\n        'PhanRequiredTraitNotAdded',\n        'PhanDeprecatedFunction',  // Uncomment this to see all the deprecated calls\n    ]\n];\n"
  },
  {
    "path": ".phan/internal_stubs/Redis.phan_php",
    "content": "<?php\n\n/**\n * Helper autocomplete for php redis extension\n *\n * @author Max Kamashev <max.kamashev@gmail.com>\n * @link   https://github.com/ukko/phpredis-phpdoc\n */\nclass Redis\n{\n    const AFTER                 = 'after';\n    const BEFORE                = 'before';\n\n    /**\n     * Options\n     */\n    const OPT_SERIALIZER        = 1;\n    const OPT_PREFIX            = 2;\n    const OPT_READ_TIMEOUT      = 3;\n    const OPT_SCAN              = 4;\n    const OPT_SLAVE_FAILOVER    = 5;\n\n    /**\n     * Cluster options\n     */\n    const FAILOVER_NONE         = 0;\n    const FAILOVER_ERROR        = 1;\n    const FAILOVER_DISTRIBUTE   = 2;\n\n    /**\n     * SCAN options\n     */\n    const SCAN_NORETRY          = 0;\n    const SCAN_RETRY            = 1;\n\n    /**\n     * Serializers\n     */\n    const SERIALIZER_NONE       = 0;\n    const SERIALIZER_PHP        = 1;\n    const SERIALIZER_IGBINARY   = 2;\n    const SERIALIZER_MSGPACK    = 3;\n    const SERIALIZER_JSON       = 4;\n\n    /**\n     * Multi\n     */\n    const ATOMIC                = 0;\n    const MULTI                 = 1;\n    const PIPELINE              = 2;\n\n    /**\n     * Type\n     */\n    const REDIS_NOT_FOUND       = 0;\n    const REDIS_STRING          = 1;\n    const REDIS_SET             = 2;\n    const REDIS_LIST            = 3;\n    const REDIS_ZSET            = 4;\n    const REDIS_HASH            = 5;\n\n    /**\n     * Creates a Redis client\n     *\n     * @example $redis = new Redis();\n     */\n    public function __construct()\n    {\n    }\n\n    /**\n     * Connects to a Redis instance.\n     *\n     * @param string $host          can be a host, or the path to a unix domain socket\n     * @param int    $port          optional\n     * @param float  $timeout       value in seconds (optional, default is 0.0 meaning unlimited)\n     * @param null   $reserved      should be null if $retryInterval is specified\n     * @param int    $retryInterval retry interval in milliseconds.\n     * @param float  $readTimeout   value in seconds (optional, default is 0 meaning unlimited)\n     *\n     * @return bool TRUE on success, FALSE on error\n     *\n     * @example\n     * <pre>\n     * $redis->connect('127.0.0.1', 6379);\n     * $redis->connect('127.0.0.1');            // port 6379 by default\n     * $redis->connect('127.0.0.1', 6379, 2.5); // 2.5 sec timeout.\n     * $redis->connect('/tmp/redis.sock');      // unix domain socket.\n     * </pre>\n     */\n    public function connect(\n        $host,\n        $port = 6379,\n        $timeout = 0.0,\n        $reserved = null,\n        $retryInterval = 0,\n        $readTimeout = 0.0\n    ) {\n    }\n\n    /**\n     * Connects to a Redis instance.\n     *\n     * @param string $host          can be a host, or the path to a unix domain socket\n     * @param int    $port          optional\n     * @param float  $timeout       value in seconds (optional, default is 0.0 meaning unlimited)\n     * @param null   $reserved      should be null if $retry_interval is specified\n     * @param int    $retryInterval retry interval in milliseconds.\n     * @param float  $readTimeout   value in seconds (optional, default is 0 meaning unlimited)\n     *\n     * @return bool TRUE on success, FALSE on error\n     *\n     * @see        connect()\n     * @deprecated use Redis::connect()\n     */\n    public function open(\n        $host,\n        $port = 6379,\n        $timeout = 0.0,\n        $reserved = null,\n        $retryInterval = 0,\n        $readTimeout = 0.0\n    ) {\n    }\n\n    /**\n     * A method to determine if a phpredis object thinks it's connected to a server\n     *\n     * @return bool Returns TRUE if phpredis thinks it's connected and FALSE if not\n     */\n    public function isConnected()\n    {\n    }\n\n    /**\n     * Retrieve our host or unix socket that we're connected to\n     *\n     * @return string|bool The host or unix socket we're connected to or FALSE if we're not connected\n     */\n    public function getHost()\n    {\n    }\n\n    /**\n     * Get the port we're connected to\n     *\n     * @return int|bool Returns the port we're connected to or FALSE if we're not connected\n     */\n    public function getPort()\n    {\n    }\n\n    /**\n     * Get the database number phpredis is pointed to\n     *\n     * @return int|bool Returns the database number (int) phpredis thinks it's pointing to\n     * or FALSE if we're not connected\n     */\n    public function getDbNum()\n    {\n    }\n\n    /**\n     * Get the (write) timeout in use for phpredis\n     *\n     * @return float|bool The timeout (DOUBLE) specified in our connect call or FALSE if we're not connected\n     */\n    public function getTimeout()\n    {\n    }\n\n    /**\n     * Get the read timeout specified to phpredis or FALSE if we're not connected\n     *\n     * @return float|bool Returns the read timeout (which can be set using setOption and Redis::OPT_READ_TIMEOUT)\n     * or FALSE if we're not connected\n     */\n    public function getReadTimeout()\n    {\n    }\n\n    /**\n     * Gets the persistent ID that phpredis is using\n     *\n     * @return string|null|bool Returns the persistent id phpredis is using\n     * (which will only be set if connected with pconnect),\n     * NULL if we're not using a persistent ID,\n     * and FALSE if we're not connected\n     */\n    public function getPersistentID()\n    {\n    }\n\n    /**\n     * Get the password used to authenticate the phpredis connection\n     *\n     * @return string|null|bool Returns the password used to authenticate a phpredis session or NULL if none was used,\n     * and FALSE if we're not connected\n     */\n    public function getAuth()\n    {\n    }\n\n    /**\n     * Connects to a Redis instance or reuse a connection already established with pconnect/popen.\n     *\n     * The connection will not be closed on close or end of request until the php process ends.\n     * So be patient on to many open FD's (specially on redis server side) when using persistent connections on\n     * many servers connecting to one redis server.\n     *\n     * Also more than one persistent connection can be made identified by either host + port + timeout\n     * or host + persistentId or unix socket + timeout.\n     *\n     * This feature is not available in threaded versions. pconnect and popen then working like their non persistent\n     * equivalents.\n     *\n     * @param string $host          can be a host, or the path to a unix domain socket\n     * @param int    $port          optional\n     * @param float  $timeout       value in seconds (optional, default is 0 meaning unlimited)\n     * @param string $persistentId  identity for the requested persistent connection\n     * @param int    $retryInterval retry interval in milliseconds.\n     * @param float  $readTimeout   value in seconds (optional, default is 0 meaning unlimited)\n     *\n     * @return bool TRUE on success, FALSE on ertcnror.\n     *\n     * @example\n     * <pre>\n     * $redis->pconnect('127.0.0.1', 6379);\n     *\n     * // port 6379 by default - same connection like before\n     * $redis->pconnect('127.0.0.1');\n     *\n     * // 2.5 sec timeout and would be another connection than the two before.\n     * $redis->pconnect('127.0.0.1', 6379, 2.5);\n     *\n     * // x is sent as persistent_id and would be another connection than the three before.\n     * $redis->pconnect('127.0.0.1', 6379, 2.5, 'x');\n     *\n     * // unix domain socket - would be another connection than the four before.\n     * $redis->pconnect('/tmp/redis.sock');\n     * </pre>\n     */\n    public function pconnect(\n        $host,\n        $port = 6379,\n        $timeout = 0.0,\n        $persistentId = null,\n        $retryInterval = 0,\n        $readTimeout = 0.0\n    ) {\n    }\n\n    /**\n     * @param string $host\n     * @param int    $port\n     * @param float  $timeout\n     * @param string $persistentId\n     * @param int    $retryInterval\n     * @param float  $readTimeout\n     *\n     * @return bool\n     *\n     * @deprecated use Redis::pconnect()\n     * @see pconnect()\n     */\n    public function popen(\n        $host,\n        $port = 6379,\n        $timeout = 0.0,\n        $persistentId = '',\n        $retryInterval = 0,\n        $readTimeout = 0.0\n    ) {\n    }\n\n    /**\n     * Disconnects from the Redis instance.\n     *\n     * Note: Closing a persistent connection requires PhpRedis >= 4.2.0\n     *\n     * @since >= 4.2 Closing a persistent connection requires PhpRedis\n     *\n     * @return bool TRUE on success, FALSE on error\n     */\n    public function close()\n    {\n    }\n\n    /**\n     * Swap one Redis database with another atomically\n     *\n     * Note: Requires Redis >= 4.0.0\n     *\n     * @param int $db1\n     * @param int $db2\n     *\n     * @return bool TRUE on success and FALSE on failure\n     *\n     * @link https://redis.io/commands/swapdb\n     * @since >= 4.0\n     * @example\n     * <pre>\n     * // Swaps DB 0 with DB 1 atomically\n     * $redis->swapdb(0, 1);\n     * </pre>\n     */\n    public function swapdb(int $db1, int $db2)\n    {\n    }\n\n    /**\n     * Set client option\n     *\n     * @param int   $option option name\n     * @param mixed $value  option value\n     *\n     * @return bool TRUE on success, FALSE on error\n     *\n     * @example\n     * <pre>\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE);        // don't serialize data\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);         // use built-in serialize/unserialize\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_IGBINARY);    // use igBinary serialize/unserialize\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_MSGPACK);     // Use msgpack serialize/unserialize\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);        // Use json serialize/unserialize\n     *\n     * $redis->setOption(Redis::OPT_PREFIX, 'myAppName:');                      // use custom prefix on all keys\n     *\n     * // Options for the SCAN family of commands, indicating whether to abstract\n     * // empty results from the user.  If set to SCAN_NORETRY (the default), phpredis\n     * // will just issue one SCAN command at a time, sometimes returning an empty\n     * // array of results.  If set to SCAN_RETRY, phpredis will retry the scan command\n     * // until keys come back OR Redis returns an iterator of zero\n     * $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_NORETRY);\n     * $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);\n     * </pre>\n     */\n    public function setOption($option, $value)\n    {\n    }\n\n    /**\n     * Get client option\n     *\n     * @param int $option parameter name\n     *\n     * @return mixed|null Parameter value\n     *\n     * @see setOption()\n     * @example\n     * // return option value\n     * $redis->getOption(Redis::OPT_SERIALIZER);\n     */\n    public function getOption($option)\n    {\n    }\n\n    /**\n     * Check the current connection status\n     *\n     * @return  string STRING: +PONG on success.\n     * Throws a RedisException object on connectivity error, as described above.\n     * @throws RedisException\n     * @link    https://redis.io/commands/ping\n     */\n    public function ping()\n    {\n    }\n\n    /**\n     * Echo the given string\n     *\n     * @param string $message\n     *\n     * @return string Returns message\n     *\n     * @link    https://redis.io/commands/echo\n     */\n    public function echo($message)\n    {\n    }\n\n    /**\n     * Get the value related to the specified key\n     *\n     * @param string $key\n     *\n     * @return string|mixed|bool If key didn't exist, FALSE is returned.\n     * Otherwise, the value related to this key is returned\n     *\n     * @link    https://redis.io/commands/get\n     * @example\n     * <pre>\n     * $redis->set('key', 'hello');\n     * $redis->get('key');\n     *\n     * // set and get with serializer\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);\n     *\n     * $redis->set('key', ['asd' => 'as', 'dd' => 123, 'b' => true]);\n     * var_dump($redis->get('key'));\n     * // Output:\n     * array(3) {\n     *  'asd' => string(2) \"as\"\n     *  'dd' => int(123)\n     *  'b' => bool(true)\n     * }\n     * </pre>\n     */\n    public function get($key)\n    {\n    }\n\n    /**\n     * Set the string value in argument as value of the key.\n     *\n     * @since If you're using Redis >= 2.6.12, you can pass extended options as explained in example\n     *\n     * @param string       $key\n     * @param string|mixed $value string if not used serializer\n     * @param int|array    $timeout [optional] Calling setex() is preferred if you want a timeout.<br>\n     * Since 2.6.12 it also supports different flags inside an array. Example ['NX', 'EX' => 60]<br>\n     *  - EX seconds -- Set the specified expire time, in seconds.<br>\n     *  - PX milliseconds -- Set the specified expire time, in milliseconds.<br>\n     *  - PX milliseconds -- Set the specified expire time, in milliseconds.<br>\n     *  - NX -- Only set the key if it does not already exist.<br>\n     *  - XX -- Only set the key if it already exist.<br>\n     * <pre>\n     * // Simple key -> value set\n     * $redis->set('key', 'value');\n     *\n     * // Will redirect, and actually make an SETEX call\n     * $redis->set('key','value', 10);\n     *\n     * // Will set the key, if it doesn't exist, with a ttl of 10 seconds\n     * $redis->set('key', 'value', ['nx', 'ex' => 10]);\n     *\n     * // Will set a key, if it does exist, with a ttl of 1000 miliseconds\n     * $redis->set('key', 'value', ['xx', 'px' => 1000]);\n     * </pre>\n     *\n     * @return bool TRUE if the command is successful\n     *\n     * @link     https://redis.io/commands/set\n     */\n    public function set($key, $value, $timeout = null)\n    {\n    }\n\n    /**\n     * Set the string value in argument as value of the key, with a time to live.\n     *\n     * @param string       $key\n     * @param int          $ttl\n     * @param string|mixed $value\n     *\n     * @return bool TRUE if the command is successful\n     *\n     * @link    https://redis.io/commands/setex\n     * @example $redis->setex('key', 3600, 'value'); // sets key → value, with 1h TTL.\n     */\n    public function setex($key, $ttl, $value)\n    {\n    }\n\n    /**\n     * Set the value and expiration in milliseconds of a key.\n     *\n     * @see     setex()\n     * @param   string       $key\n     * @param   int          $ttl, in milliseconds.\n     * @param   string|mixed $value\n     *\n     * @return bool TRUE if the command is successful\n     *\n     * @link    https://redis.io/commands/psetex\n     * @example $redis->psetex('key', 1000, 'value'); // sets key → value, with 1sec TTL.\n     */\n    public function psetex($key, $ttl, $value)\n    {\n    }\n\n    /**\n     * Set the string value in argument as value of the key if the key doesn't already exist in the database.\n     *\n     * @param string       $key\n     * @param string|mixed $value\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/setnx\n     * @example\n     * <pre>\n     * $redis->setnx('key', 'value');   // return TRUE\n     * $redis->setnx('key', 'value');   // return FALSE\n     * </pre>\n     */\n    public function setnx($key, $value)\n    {\n    }\n\n    /**\n     * Remove specified keys.\n     *\n     * @param   int|string|array $key1 An array of keys, or an undefined number of parameters, each a key: key1 key2 key3 ... keyN\n     * @param   int|string       ...$otherKeys\n     *\n     * @return int Number of keys deleted\n     *\n     * @link https://redis.io/commands/del\n     * @example\n     * <pre>\n     * $redis->set('key1', 'val1');\n     * $redis->set('key2', 'val2');\n     * $redis->set('key3', 'val3');\n     * $redis->set('key4', 'val4');\n     *\n     * $redis->del('key1', 'key2');     // return 2\n     * $redis->del(['key3', 'key4']);   // return 2\n     * </pre>\n     */\n    public function del($key1, ...$otherKeys)\n    {\n    }\n\n    /**\n     * @see del()\n     * @deprecated use Redis::del()\n     *\n     * @param   string|string[] $key1\n     * @param   string          $key2\n     * @param   string          $key3\n     *\n     * @return int Number of keys deleted\n     */\n    public function delete($key1, $key2 = null, $key3 = null)\n    {\n    }\n\n    /**\n     * Delete a key asynchronously in another thread. Otherwise it is just as DEL, but non blocking.\n     *\n     * @see del()\n     * @param string|string[] $key1\n     * @param string          $key2\n     * @param string          $key3\n     *\n     * @return int Number of keys unlinked.\n     *\n     * @link    https://redis.io/commands/unlink\n     * @example\n     * <pre>\n     * $redis->set('key1', 'val1');\n     * $redis->set('key2', 'val2');\n     * $redis->set('key3', 'val3');\n     * $redis->set('key4', 'val4');\n     * $redis->unlink('key1', 'key2');          // return 2\n     * $redis->unlink(array('key3', 'key4'));   // return 2\n     * </pre>\n     */\n    public function unlink($key1, $key2 = null, $key3 = null)\n    {\n    }\n\n    /**\n     * Enter and exit transactional mode.\n     *\n     * @param int $mode Redis::MULTI|Redis::PIPELINE\n     * Defaults to Redis::MULTI.\n     * A Redis::MULTI block of commands runs as a single transaction;\n     * a Redis::PIPELINE block is simply transmitted faster to the server, but without any guarantee of atomicity.\n     * discard cancels a transaction.\n     *\n     * @return Redis returns the Redis instance and enters multi-mode.\n     * Once in multi-mode, all subsequent method calls return the same object until exec() is called.\n     *\n     * @link    https://redis.io/commands/multi\n     * @example\n     * <pre>\n     * $ret = $redis->multi()\n     *      ->set('key1', 'val1')\n     *      ->get('key1')\n     *      ->set('key2', 'val2')\n     *      ->get('key2')\n     *      ->exec();\n     *\n     * //$ret == array (\n     * //    0 => TRUE,\n     * //    1 => 'val1',\n     * //    2 => TRUE,\n     * //    3 => 'val2');\n     * </pre>\n     */\n    public function multi($mode = Redis::MULTI)\n    {\n    }\n\n    /**\n     * @return void|array\n     *\n     * @see multi()\n     * @link https://redis.io/commands/exec\n     */\n    public function exec()\n    {\n    }\n\n    /**\n     * @see multi()\n     * @link https://redis.io/commands/discard\n     */\n    public function discard()\n    {\n    }\n\n    /**\n     * Watches a key for modifications by another client. If the key is modified between WATCH and EXEC,\n     * the MULTI/EXEC transaction will fail (return FALSE). unwatch cancels all the watching of all keys by this client.\n     * @param string|string[] $key a list of keys\n     *\n     * @return void\n     *\n     * @link    https://redis.io/commands/watch\n     * @example\n     * <pre>\n     * $redis->watch('x');\n     * // long code here during the execution of which other clients could well modify `x`\n     * $ret = $redis->multi()\n     *          ->incr('x')\n     *          ->exec();\n     * // $ret = FALSE if x has been modified between the call to WATCH and the call to EXEC.\n     * </pre>\n     */\n    public function watch($key)\n    {\n    }\n\n    /**\n     * @see watch()\n     * @link    https://redis.io/commands/unwatch\n     */\n    public function unwatch()\n    {\n    }\n\n    /**\n     * Subscribe to channels.\n     *\n     * Warning: this function will probably change in the future.\n     *\n     * @param string[]     $channels an array of channels to subscribe\n     * @param string|array $callback either a string or an array($instance, 'method_name').\n     * The callback function receives 3 parameters: the redis instance, the channel name, and the message.\n     *\n     * @return mixed|null Any non-null return value in the callback will be returned to the caller.\n     *\n     * @link    https://redis.io/commands/subscribe\n     * @example\n     * <pre>\n     * function f($redis, $chan, $msg) {\n     *  switch($chan) {\n     *      case 'chan-1':\n     *          ...\n     *          break;\n     *\n     *      case 'chan-2':\n     *                     ...\n     *          break;\n     *\n     *      case 'chan-2':\n     *          ...\n     *          break;\n     *      }\n     * }\n     *\n     * $redis->subscribe(array('chan-1', 'chan-2', 'chan-3'), 'f'); // subscribe to 3 chans\n     * </pre>\n     */\n    public function subscribe($channels, $callback)\n    {\n    }\n\n    /**\n     * Subscribe to channels by pattern\n     *\n     * @param array        $patterns   an array of glob-style patterns to subscribe\n     * @param string|array $callback   Either a string or an array with an object and method.\n     *                     The callback will get four arguments ($redis, $pattern, $channel, $message)\n     * @param mixed        $chan       Any non-null return value in the callback will be returned to the caller\n     * @param string       $msg\n     *\n     * @link    https://redis.io/commands/psubscribe\n     * @example\n     * <pre>\n     * function psubscribe($redis, $pattern, $chan, $msg) {\n     *  echo \"Pattern: $pattern\\n\";\n     *  echo \"Channel: $chan\\n\";\n     *  echo \"Payload: $msg\\n\";\n     * }\n     * </pre>\n     */\n    public function psubscribe($patterns, $callback, $chan, $msg)\n    {\n    }\n\n    /**\n     * Publish messages to channels.\n     *\n     * Warning: this function will probably change in the future.\n     *\n     * @param string $channel a channel to publish to\n     * @param string $message string\n     *\n     * @return int Number of clients that received the message\n     *\n     * @link    https://redis.io/commands/publish\n     * @example $redis->publish('chan-1', 'hello, world!'); // send message.\n     */\n    public function publish($channel, $message)\n    {\n    }\n\n    /**\n     * A command allowing you to get information on the Redis pub/sub system\n     *\n     * @param string       $keyword    String, which can be: \"channels\", \"numsub\", or \"numpat\"\n     * @param string|array $argument   Optional, variant.\n     *                                 For the \"channels\" subcommand, you can pass a string pattern.\n     *                                 For \"numsub\" an array of channel names\n     *\n     * @return array|int Either an integer or an array.\n     *   - channels  Returns an array where the members are the matching channels.\n     *   - numsub    Returns a key/value array where the keys are channel names and\n     *               values are their counts.\n     *   - numpat    Integer return containing the number active pattern subscriptions\n     *\n     * @link    https://redis.io/commands/pubsub\n     * @example\n     * <pre>\n     * $redis->pubsub('channels'); // All channels\n     * $redis->pubsub('channels', '*pattern*'); // Just channels matching your pattern\n     * $redis->pubsub('numsub', array('chan1', 'chan2')); // Get subscriber counts for 'chan1' and 'chan2'\n     * $redis->pubsub('numpat'); // Get the number of pattern subscribers\n     * </pre>\n     */\n    public function pubsub($keyword, $argument)\n    {\n    }\n\n    /**\n     * Stop listening for messages posted to the given channels.\n     *\n     * @param array $channels an array of channels to usubscribe\n     *\n     * @link    https://redis.io/commands/unsubscribe\n     */\n    public function unsubscribe($channels = null)\n    {\n    }\n\n    /**\n     * Stop listening for messages posted to the given channels.\n     *\n     * @param array $patterns   an array of glob-style patterns to unsubscribe\n     *\n     * @link https://redis.io/commands/punsubscribe\n     */\n    public function punsubscribe($patterns = null)\n    {\n    }\n\n    /**\n     * Verify if the specified key/keys exists\n     *\n     * This function took a single argument and returned TRUE or FALSE in phpredis versions < 4.0.0.\n     *\n     * @since >= 4.0 Returned int, if < 4.0 returned bool\n     *\n     * @param string|string[] $key\n     *\n     * @return int|bool The number of keys tested that do exist\n     *\n     * @link https://redis.io/commands/exists\n     * @link https://github.com/phpredis/phpredis#exists\n     * @example\n     * <pre>\n     * $redis->exists('key'); // 1\n     * $redis->exists('NonExistingKey'); // 0\n     *\n     * $redis->mset(['foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz']);\n     * $redis->exists(['foo', 'bar', 'baz]); // 3\n     * $redis->exists('foo', 'bar', 'baz'); // 3\n     * </pre>\n     */\n    public function exists($key)\n    {\n    }\n\n    /**\n     * Increment the number stored at key by one.\n     *\n     * @param   string $key\n     *\n     * @return int the new value\n     *\n     * @link    https://redis.io/commands/incr\n     * @example\n     * <pre>\n     * $redis->incr('key1'); // key1 didn't exists, set to 0 before the increment and now has the value 1\n     * $redis->incr('key1'); // 2\n     * $redis->incr('key1'); // 3\n     * $redis->incr('key1'); // 4\n     * </pre>\n     */\n    public function incr($key)\n    {\n    }\n\n    /**\n     * Increment the float value of a key by the given amount\n     *\n     * @param string $key\n     * @param float  $increment\n     *\n     * @return float\n     *\n     * @link    https://redis.io/commands/incrbyfloat\n     * @example\n     * <pre>\n     * $redis->set('x', 3);\n     * $redis->incrByFloat('x', 1.5);   // float(4.5)\n     * $redis->get('x');                // float(4.5)\n     * </pre>\n     */\n    public function incrByFloat($key, $increment)\n    {\n    }\n\n    /**\n     * Increment the number stored at key by one.\n     * If the second argument is filled, it will be used as the integer value of the increment.\n     *\n     * @param string $key   key\n     * @param int    $value value that will be added to key (only for incrBy)\n     *\n     * @return int the new value\n     *\n     * @link    https://redis.io/commands/incrby\n     * @example\n     * <pre>\n     * $redis->incr('key1');        // key1 didn't exists, set to 0 before the increment and now has the value 1\n     * $redis->incr('key1');        // 2\n     * $redis->incr('key1');        // 3\n     * $redis->incr('key1');        // 4\n     * $redis->incrBy('key1', 10);  // 14\n     * </pre>\n     */\n    public function incrBy($key, $value)\n    {\n    }\n\n    /**\n     * Decrement the number stored at key by one.\n     *\n     * @param string $key\n     *\n     * @return int the new value\n     *\n     * @link    https://redis.io/commands/decr\n     * @example\n     * <pre>\n     * $redis->decr('key1'); // key1 didn't exists, set to 0 before the increment and now has the value -1\n     * $redis->decr('key1'); // -2\n     * $redis->decr('key1'); // -3\n     * </pre>\n     */\n    public function decr($key)\n    {\n    }\n\n    /**\n     * Decrement the number stored at key by one.\n     * If the second argument is filled, it will be used as the integer value of the decrement.\n     *\n     * @param string $key\n     * @param int    $value  that will be substracted to key (only for decrBy)\n     *\n     * @return int the new value\n     *\n     * @link    https://redis.io/commands/decrby\n     * @example\n     * <pre>\n     * $redis->decr('key1');        // key1 didn't exists, set to 0 before the increment and now has the value -1\n     * $redis->decr('key1');        // -2\n     * $redis->decr('key1');        // -3\n     * $redis->decrBy('key1', 10);  // -13\n     * </pre>\n     */\n    public function decrBy($key, $value)\n    {\n    }\n\n    /**\n     * Adds the string values to the head (left) of the list.\n     * Creates the list if the key didn't exist.\n     * If the key exists and is not a list, FALSE is returned.\n     *\n     * @param string $key\n     * @param string|mixed $value1... Variadic list of values to push in key, if dont used serialized, used string\n     *\n     * @return int|bool The new length of the list in case of success, FALSE in case of Failure\n     *\n     * @link https://redis.io/commands/lpush\n     * @example\n     * <pre>\n     * $redis->lPush('l', 'v1', 'v2', 'v3', 'v4')   // int(4)\n     * var_dump( $redis->lRange('l', 0, -1) );\n     * // Output:\n     * // array(4) {\n     * //   [0]=> string(2) \"v4\"\n     * //   [1]=> string(2) \"v3\"\n     * //   [2]=> string(2) \"v2\"\n     * //   [3]=> string(2) \"v1\"\n     * // }\n     * </pre>\n     */\n    public function lPush($key, ...$value1)\n    {\n    }\n\n    /**\n     * Adds the string values to the tail (right) of the list.\n     * Creates the list if the key didn't exist.\n     * If the key exists and is not a list, FALSE is returned.\n     *\n     * @param string $key\n     * @param string|mixed $value1... Variadic list of values to push in key, if dont used serialized, used string\n     *\n     * @return int|bool The new length of the list in case of success, FALSE in case of Failure\n     *\n     * @link    https://redis.io/commands/rpush\n     * @example\n     * <pre>\n     * $redis->rPush('l', 'v1', 'v2', 'v3', 'v4');    // int(4)\n     * var_dump( $redis->lRange('l', 0, -1) );\n     * // Output:\n     * // array(4) {\n     * //   [0]=> string(2) \"v1\"\n     * //   [1]=> string(2) \"v2\"\n     * //   [2]=> string(2) \"v3\"\n     * //   [3]=> string(2) \"v4\"\n     * // }\n     * </pre>\n     */\n    public function rPush($key, ...$value1)\n    {\n    }\n\n    /**\n     * Adds the string value to the head (left) of the list if the list exists.\n     *\n     * @param string $key\n     * @param string|mixed $value String, value to push in key\n     *\n     * @return int|bool The new length of the list in case of success, FALSE in case of Failure.\n     *\n     * @link    https://redis.io/commands/lpushx\n     * @example\n     * <pre>\n     * $redis->del('key1');\n     * $redis->lPushx('key1', 'A');     // returns 0\n     * $redis->lPush('key1', 'A');      // returns 1\n     * $redis->lPushx('key1', 'B');     // returns 2\n     * $redis->lPushx('key1', 'C');     // returns 3\n     * // key1 now points to the following list: [ 'A', 'B', 'C' ]\n     * </pre>\n     */\n    public function lPushx($key, $value)\n    {\n    }\n\n    /**\n     * Adds the string value to the tail (right) of the list if the ist exists. FALSE in case of Failure.\n     *\n     * @param string $key\n     * @param string|mixed $value String, value to push in key\n     *\n     * @return int|bool The new length of the list in case of success, FALSE in case of Failure.\n     *\n     * @link    https://redis.io/commands/rpushx\n     * @example\n     * <pre>\n     * $redis->del('key1');\n     * $redis->rPushx('key1', 'A'); // returns 0\n     * $redis->rPush('key1', 'A'); // returns 1\n     * $redis->rPushx('key1', 'B'); // returns 2\n     * $redis->rPushx('key1', 'C'); // returns 3\n     * // key1 now points to the following list: [ 'A', 'B', 'C' ]\n     * </pre>\n     */\n    public function rPushx($key, $value)\n    {\n    }\n\n    /**\n     * Returns and removes the first element of the list.\n     *\n     * @param   string $key\n     *\n     * @return  mixed|bool if command executed successfully BOOL FALSE in case of failure (empty list)\n     *\n     * @link    https://redis.io/commands/lpop\n     * @example\n     * <pre>\n     * $redis->rPush('key1', 'A');\n     * $redis->rPush('key1', 'B');\n     * $redis->rPush('key1', 'C');  // key1 => [ 'A', 'B', 'C' ]\n     * $redis->lPop('key1');        // key1 => [ 'B', 'C' ]\n     * </pre>\n     */\n    public function lPop($key)\n    {\n    }\n\n    /**\n     * Returns and removes the last element of the list.\n     *\n     * @param   string $key\n     *\n     * @return  mixed|bool if command executed successfully BOOL FALSE in case of failure (empty list)\n     *\n     * @link    https://redis.io/commands/rpop\n     * @example\n     * <pre>\n     * $redis->rPush('key1', 'A');\n     * $redis->rPush('key1', 'B');\n     * $redis->rPush('key1', 'C');  // key1 => [ 'A', 'B', 'C' ]\n     * $redis->rPop('key1');        // key1 => [ 'A', 'B' ]\n     * </pre>\n     */\n    public function rPop($key)\n    {\n    }\n\n    /**\n     * Is a blocking lPop primitive. If at least one of the lists contains at least one element,\n     * the element will be popped from the head of the list and returned to the caller.\n     * Il all the list identified by the keys passed in arguments are empty, blPop will block\n     * during the specified timeout until an element is pushed to one of those lists. This element will be popped.\n     *\n     * @param string|string[] $keys    String array containing the keys of the lists OR variadic list of strings\n     * @param int             $timeout Timeout is always the required final parameter\n     *\n     * @return array ['listName', 'element']\n     *\n     * @link    https://redis.io/commands/blpop\n     * @example\n     * <pre>\n     * // Non blocking feature\n     * $redis->lPush('key1', 'A');\n     * $redis->del('key2');\n     *\n     * $redis->blPop('key1', 'key2', 10);        // array('key1', 'A')\n     * // OR\n     * $redis->blPop(['key1', 'key2'], 10);      // array('key1', 'A')\n     *\n     * $redis->brPop('key1', 'key2', 10);        // array('key1', 'A')\n     * // OR\n     * $redis->brPop(['key1', 'key2'], 10); // array('key1', 'A')\n     *\n     * // Blocking feature\n     *\n     * // process 1\n     * $redis->del('key1');\n     * $redis->blPop('key1', 10);\n     * // blocking for 10 seconds\n     *\n     * // process 2\n     * $redis->lPush('key1', 'A');\n     *\n     * // process 1\n     * // array('key1', 'A') is returned\n     * </pre>\n     */\n    public function blPop($keys, $timeout)\n    {\n    }\n\n    /**\n     * Is a blocking rPop primitive. If at least one of the lists contains at least one element,\n     * the element will be popped from the head of the list and returned to the caller.\n     * Il all the list identified by the keys passed in arguments are empty, brPop will\n     * block during the specified timeout until an element is pushed to one of those lists. T\n     * his element will be popped.\n     *\n     * @param string|string[] $keys    String array containing the keys of the lists OR variadic list of strings\n     * @param int             $timeout Timeout is always the required final parameter\n     *\n     * @return array ['listName', 'element']\n     *\n     * @link    https://redis.io/commands/brpop\n     * @example\n     * <pre>\n     * // Non blocking feature\n     * $redis->lPush('key1', 'A');\n     * $redis->del('key2');\n     *\n     * $redis->blPop('key1', 'key2', 10); // array('key1', 'A')\n     * // OR\n     * $redis->blPop(array('key1', 'key2'), 10); // array('key1', 'A')\n     *\n     * $redis->brPop('key1', 'key2', 10); // array('key1', 'A')\n     * // OR\n     * $redis->brPop(array('key1', 'key2'), 10); // array('key1', 'A')\n     *\n     * // Blocking feature\n     *\n     * // process 1\n     * $redis->del('key1');\n     * $redis->blPop('key1', 10);\n     * // blocking for 10 seconds\n     *\n     * // process 2\n     * $redis->lPush('key1', 'A');\n     *\n     * // process 1\n     * // array('key1', 'A') is returned\n     * </pre>\n     */\n    public function brPop(array $keys, $timeout)\n    {\n    }\n\n    /**\n     * Returns the size of a list identified by Key. If the list didn't exist or is empty,\n     * the command returns 0. If the data type identified by Key is not a list, the command return FALSE.\n     *\n     * @param string $key\n     *\n     * @return int|bool The size of the list identified by Key exists.\n     * bool FALSE if the data type identified by Key is not list\n     *\n     * @link    https://redis.io/commands/llen\n     * @example\n     * <pre>\n     * $redis->rPush('key1', 'A');\n     * $redis->rPush('key1', 'B');\n     * $redis->rPush('key1', 'C'); // key1 => [ 'A', 'B', 'C' ]\n     * $redis->lLen('key1');       // 3\n     * $redis->rPop('key1');\n     * $redis->lLen('key1');       // 2\n     * </pre>\n     */\n    public function lLen($key)\n    {\n    }\n\n    /**\n     * @see lLen()\n     * @link https://redis.io/commands/llen\n     * @deprecated use Redis::lLen()\n     *\n     * @param string $key\n     *\n     * @return int The size of the list identified by Key exists\n     */\n    public function lSize($key)\n    {\n    }\n\n    /**\n     * Return the specified element of the list stored at the specified key.\n     * 0 the first element, 1 the second ... -1 the last element, -2 the penultimate ...\n     * Return FALSE in case of a bad index or a key that doesn't point to a list.\n     *\n     * @param string $key\n     * @param int    $index\n     *\n     * @return mixed|bool the element at this index\n     *\n     * Bool FALSE if the key identifies a non-string data type, or no value corresponds to this index in the list Key.\n     *\n     * @link    https://redis.io/commands/lindex\n     * @example\n     * <pre>\n     * $redis->rPush('key1', 'A');\n     * $redis->rPush('key1', 'B');\n     * $redis->rPush('key1', 'C');  // key1 => [ 'A', 'B', 'C' ]\n     * $redis->lIndex('key1', 0);     // 'A'\n     * $redis->lIndex('key1', -1);    // 'C'\n     * $redis->lIndex('key1', 10);    // `FALSE`\n     * </pre>\n     */\n    public function lIndex($key, $index)\n    {\n    }\n\n    /**\n     * @see lIndex()\n     * @link https://redis.io/commands/lindex\n     * @deprecated use Redis::lIndex()\n     *\n     * @param string $key\n     * @param int $index\n     * @return mixed|bool the element at this index\n     */\n    public function lGet($key, $index)\n    {\n    }\n\n    /**\n     * Set the list at index with the new value.\n     *\n     * @param string $key\n     * @param int    $index\n     * @param string $value\n     *\n     * @return bool TRUE if the new value is setted.\n     * FALSE if the index is out of range, or data type identified by key is not a list.\n     *\n     * @link    https://redis.io/commands/lset\n     * @example\n     * <pre>\n     * $redis->rPush('key1', 'A');\n     * $redis->rPush('key1', 'B');\n     * $redis->rPush('key1', 'C');    // key1 => [ 'A', 'B', 'C' ]\n     * $redis->lIndex('key1', 0);     // 'A'\n     * $redis->lSet('key1', 0, 'X');\n     * $redis->lIndex('key1', 0);     // 'X'\n     * </pre>\n     */\n    public function lSet($key, $index, $value)\n    {\n    }\n\n    /**\n     * Returns the specified elements of the list stored at the specified key in\n     * the range [start, end]. start and stop are interpretated as indices: 0 the first element,\n     * 1 the second ... -1 the last element, -2 the penultimate ...\n     *\n     * @param string $key\n     * @param int    $start\n     * @param int    $end\n     *\n     * @return array containing the values in specified range.\n     *\n     * @link    https://redis.io/commands/lrange\n     * @example\n     * <pre>\n     * $redis->rPush('key1', 'A');\n     * $redis->rPush('key1', 'B');\n     * $redis->rPush('key1', 'C');\n     * $redis->lRange('key1', 0, -1); // array('A', 'B', 'C')\n     * </pre>\n     */\n    public function lRange($key, $start, $end)\n    {\n    }\n\n    /**\n     * @see lRange()\n     * @link https://redis.io/commands/lrange\n     * @deprecated use Redis::lRange()\n     *\n     * @param string    $key\n     * @param int       $start\n     * @param int       $end\n     * @return array\n     */\n    public function lGetRange($key, $start, $end)\n    {\n    }\n\n    /**\n     * Trims an existing list so that it will contain only a specified range of elements.\n     *\n     * @param string $key\n     * @param int    $start\n     * @param int    $stop\n     *\n     * @return array|bool Bool return FALSE if the key identify a non-list value\n     *\n     * @link        https://redis.io/commands/ltrim\n     * @example\n     * <pre>\n     * $redis->rPush('key1', 'A');\n     * $redis->rPush('key1', 'B');\n     * $redis->rPush('key1', 'C');\n     * $redis->lRange('key1', 0, -1); // array('A', 'B', 'C')\n     * $redis->lTrim('key1', 0, 1);\n     * $redis->lRange('key1', 0, -1); // array('A', 'B')\n     * </pre>\n     */\n    public function lTrim($key, $start, $stop)\n    {\n    }\n\n    /**\n     * @see lTrim()\n     * @link  https://redis.io/commands/ltrim\n     * @deprecated use Redis::lTrim()\n     *\n     * @param string    $key\n     * @param int       $start\n     * @param int       $stop\n     */\n    public function listTrim($key, $start, $stop)\n    {\n    }\n\n    /**\n     * Removes the first count occurences of the value element from the list.\n     * If count is zero, all the matching elements are removed. If count is negative,\n     * elements are removed from tail to head.\n     *\n     * @param string $key\n     * @param string $value\n     * @param int    $count\n     *\n     * @return int|bool the number of elements to remove\n     * bool FALSE if the value identified by key is not a list.\n     *\n     * @link    https://redis.io/commands/lrem\n     * @example\n     * <pre>\n     * $redis->lPush('key1', 'A');\n     * $redis->lPush('key1', 'B');\n     * $redis->lPush('key1', 'C');\n     * $redis->lPush('key1', 'A');\n     * $redis->lPush('key1', 'A');\n     *\n     * $redis->lRange('key1', 0, -1);   // array('A', 'A', 'C', 'B', 'A')\n     * $redis->lRem('key1', 'A', 2);    // 2\n     * $redis->lRange('key1', 0, -1);   // array('C', 'B', 'A')\n     * </pre>\n     */\n    public function lRem($key, $value, $count)\n    {\n    }\n\n    /**\n     * @see lRem\n     * @link https://redis.io/commands/lremove\n     * @deprecated use Redis::lRem()\n     *\n     * @param string $key\n     * @param string $value\n     * @param int $count\n     */\n    public function lRemove($key, $value, $count)\n    {\n    }\n\n    /**\n     * Insert value in the list before or after the pivot value. the parameter options\n     * specify the position of the insert (before or after). If the list didn't exists,\n     * or the pivot didn't exists, the value is not inserted.\n     *\n     * @param string       $key\n     * @param int          $position Redis::BEFORE | Redis::AFTER\n     * @param string       $pivot\n     * @param string|mixed $value\n     *\n     * @return int The number of the elements in the list, -1 if the pivot didn't exists.\n     *\n     * @link    https://redis.io/commands/linsert\n     * @example\n     * <pre>\n     * $redis->del('key1');\n     * $redis->lInsert('key1', Redis::AFTER, 'A', 'X');     // 0\n     *\n     * $redis->lPush('key1', 'A');\n     * $redis->lPush('key1', 'B');\n     * $redis->lPush('key1', 'C');\n     *\n     * $redis->lInsert('key1', Redis::BEFORE, 'C', 'X');    // 4\n     * $redis->lRange('key1', 0, -1);                       // array('A', 'B', 'X', 'C')\n     *\n     * $redis->lInsert('key1', Redis::AFTER, 'C', 'Y');     // 5\n     * $redis->lRange('key1', 0, -1);                       // array('A', 'B', 'X', 'C', 'Y')\n     *\n     * $redis->lInsert('key1', Redis::AFTER, 'W', 'value'); // -1\n     * </pre>\n     */\n    public function lInsert($key, $position, $pivot, $value)\n    {\n    }\n\n    /**\n     * Adds a values to the set value stored at key.\n     *\n     * @param string       $key       Required key\n     * @param string|mixed ...$value1 Variadic list of values\n     *\n     * @return int|bool The number of elements added to the set.\n     * If this value is already in the set, FALSE is returned\n     *\n     * @link    https://redis.io/commands/sadd\n     * @example\n     * <pre>\n     * $redis->sAdd('k', 'v1');                // int(1)\n     * $redis->sAdd('k', 'v1', 'v2', 'v3');    // int(2)\n     * </pre>\n     */\n    public function sAdd($key, ...$value1)\n    {\n    }\n\n    /**\n     * Removes the specified members from the set value stored at key.\n     *\n     * @param   string       $key\n     * @param   string|mixed ...$member1 Variadic list of members\n     *\n     * @return int The number of elements removed from the set\n     *\n     * @link    https://redis.io/commands/srem\n     * @example\n     * <pre>\n     * var_dump( $redis->sAdd('k', 'v1', 'v2', 'v3') );    // int(3)\n     * var_dump( $redis->sRem('k', 'v2', 'v3') );          // int(2)\n     * var_dump( $redis->sMembers('k') );\n     * //// Output:\n     * // array(1) {\n     * //   [0]=> string(2) \"v1\"\n     * // }\n     * </pre>\n     */\n    public function sRem($key, ...$member1)\n    {\n    }\n\n    /**\n     * @see sRem()\n     * @link    https://redis.io/commands/srem\n     * @deprecated use Redis::sRem()\n     *\n     * @param   string  $key\n     * @param   string|mixed  ...$member1\n     */\n    public function sRemove($key, ...$member1)\n    {\n    }\n\n    /**\n     * Moves the specified member from the set at srcKey to the set at dstKey.\n     *\n     * @param string       $srcKey\n     * @param string       $dstKey\n     * @param string|mixed $member\n     *\n     * @return bool If the operation is successful, return TRUE.\n     * If the srcKey and/or dstKey didn't exist, and/or the member didn't exist in srcKey, FALSE is returned.\n     *\n     * @link    https://redis.io/commands/smove\n     * @example\n     * <pre>\n     * $redis->sAdd('key1' , 'set11');\n     * $redis->sAdd('key1' , 'set12');\n     * $redis->sAdd('key1' , 'set13');          // 'key1' => {'set11', 'set12', 'set13'}\n     * $redis->sAdd('key2' , 'set21');\n     * $redis->sAdd('key2' , 'set22');          // 'key2' => {'set21', 'set22'}\n     * $redis->sMove('key1', 'key2', 'set13');  // 'key1' =>  {'set11', 'set12'}\n     *                                          // 'key2' =>  {'set21', 'set22', 'set13'}\n     * </pre>\n     */\n    public function sMove($srcKey, $dstKey, $member)\n    {\n    }\n\n    /**\n     * Checks if value is a member of the set stored at the key key.\n     *\n     * @param string       $key\n     * @param string|mixed $value\n     *\n     * @return bool TRUE if value is a member of the set at key key, FALSE otherwise\n     *\n     * @link    https://redis.io/commands/sismember\n     * @example\n     * <pre>\n     * $redis->sAdd('key1' , 'set1');\n     * $redis->sAdd('key1' , 'set2');\n     * $redis->sAdd('key1' , 'set3'); // 'key1' => {'set1', 'set2', 'set3'}\n     *\n     * $redis->sIsMember('key1', 'set1'); // TRUE\n     * $redis->sIsMember('key1', 'setX'); // FALSE\n     * </pre>\n     */\n    public function sIsMember($key, $value)\n    {\n    }\n\n    /**\n     * @see sIsMember()\n     * @link    https://redis.io/commands/sismember\n     * @deprecated use Redis::sIsMember()\n     *\n     * @param string       $key\n     * @param string|mixed $value\n     */\n    public function sContains($key, $value)\n    {\n    }\n\n    /**\n     * Returns the cardinality of the set identified by key.\n     *\n     * @param string $key\n     *\n     * @return int the cardinality of the set identified by key, 0 if the set doesn't exist.\n     *\n     * @link    https://redis.io/commands/scard\n     * @example\n     * <pre>\n     * $redis->sAdd('key1' , 'set1');\n     * $redis->sAdd('key1' , 'set2');\n     * $redis->sAdd('key1' , 'set3');   // 'key1' => {'set1', 'set2', 'set3'}\n     * $redis->sCard('key1');           // 3\n     * $redis->sCard('keyX');           // 0\n     * </pre>\n     */\n    public function sCard($key)\n    {\n    }\n\n    /**\n     * Removes and returns a random element from the set value at Key.\n     *\n     * @param string $key\n     *\n     * @return string|mixed|bool \"popped\" value\n     * bool FALSE if set identified by key is empty or doesn't exist.\n     *\n     * @link    https://redis.io/commands/spop\n     * @example\n     * <pre>\n     * $redis->sAdd('key1' , 'set1');\n     * $redis->sAdd('key1' , 'set2');\n     * $redis->sAdd('key1' , 'set3');   // 'key1' => {'set3', 'set1', 'set2'}\n     * $redis->sPop('key1');            // 'set1', 'key1' => {'set3', 'set2'}\n     * $redis->sPop('key1');            // 'set3', 'key1' => {'set2'}\n     * </pre>\n     */\n    public function sPop($key)\n    {\n    }\n\n    /**\n     * Returns a random element(s) from the set value at Key, without removing it.\n     *\n     * @param string $key\n     * @param int    $count [optional]\n     *\n     * @return string|mixed|array|bool value(s) from the set\n     * bool FALSE if set identified by key is empty or doesn't exist and count argument isn't passed.\n     *\n     * @link    https://redis.io/commands/srandmember\n     * @example\n     * <pre>\n     * $redis->sAdd('key1' , 'one');\n     * $redis->sAdd('key1' , 'two');\n     * $redis->sAdd('key1' , 'three');              // 'key1' => {'one', 'two', 'three'}\n     *\n     * var_dump( $redis->sRandMember('key1') );     // 'key1' => {'one', 'two', 'three'}\n     *\n     * // string(5) \"three\"\n     *\n     * var_dump( $redis->sRandMember('key1', 2) );  // 'key1' => {'one', 'two', 'three'}\n     *\n     * // array(2) {\n     * //   [0]=> string(2) \"one\"\n     * //   [1]=> string(2) \"three\"\n     * // }\n     * </pre>\n     */\n    public function sRandMember($key, $count = 1)\n    {\n    }\n\n    /**\n     * Returns the members of a set resulting from the intersection of all the sets\n     * held at the specified keys. If just a single key is specified, then this command\n     * produces the members of this set. If one of the keys is missing, FALSE is returned.\n     *\n     * @param string $key1         keys identifying the different sets on which we will apply the intersection.\n     * @param string ...$otherKeys variadic list of keys\n     *\n     * @return array contain the result of the intersection between those keys\n     * If the intersection between the different sets is empty, the return value will be empty array.\n     *\n     * @link    https://redis.io/commands/sinter\n     * @example\n     * <pre>\n     * $redis->sAdd('key1', 'val1');\n     * $redis->sAdd('key1', 'val2');\n     * $redis->sAdd('key1', 'val3');\n     * $redis->sAdd('key1', 'val4');\n     *\n     * $redis->sAdd('key2', 'val3');\n     * $redis->sAdd('key2', 'val4');\n     *\n     * $redis->sAdd('key3', 'val3');\n     * $redis->sAdd('key3', 'val4');\n     *\n     * var_dump($redis->sInter('key1', 'key2', 'key3'));\n     *\n     * //array(2) {\n     * //  [0]=>\n     * //  string(4) \"val4\"\n     * //  [1]=>\n     * //  string(4) \"val3\"\n     * //}\n     * </pre>\n     */\n    public function sInter($key1, ...$otherKeys)\n    {\n    }\n\n    /**\n     * Performs a sInter command and stores the result in a new set.\n     *\n     * @param string $dstKey       the key to store the diff into.\n     * @param string $key1         keys identifying the different sets on which we will apply the intersection.\n     * @param string ...$otherKeys variadic list of keys\n     *\n     * @return int|bool The cardinality of the resulting set, or FALSE in case of a missing key\n     *\n     * @link    https://redis.io/commands/sinterstore\n     * @example\n     * <pre>\n     * $redis->sAdd('key1', 'val1');\n     * $redis->sAdd('key1', 'val2');\n     * $redis->sAdd('key1', 'val3');\n     * $redis->sAdd('key1', 'val4');\n     *\n     * $redis->sAdd('key2', 'val3');\n     * $redis->sAdd('key2', 'val4');\n     *\n     * $redis->sAdd('key3', 'val3');\n     * $redis->sAdd('key3', 'val4');\n     *\n     * var_dump($redis->sInterStore('output', 'key1', 'key2', 'key3'));\n     * var_dump($redis->sMembers('output'));\n     *\n     * //int(2)\n     * //\n     * //array(2) {\n     * //  [0]=>\n     * //  string(4) \"val4\"\n     * //  [1]=>\n     * //  string(4) \"val3\"\n     * //}\n     * </pre>\n     */\n    public function sInterStore($dstKey, $key1, ...$otherKeys)\n    {\n    }\n\n    /**\n     * Performs the union between N sets and returns it.\n     *\n     * @param string $key1         first key for union\n     * @param string ...$otherKeys variadic list of keys corresponding to sets in redis\n     *\n     * @return array string[] The union of all these sets\n     *\n     * @link    https://redis.io/commands/sunionstore\n     * @example\n     * <pre>\n     * $redis->sAdd('s0', '1');\n     * $redis->sAdd('s0', '2');\n     * $redis->sAdd('s1', '3');\n     * $redis->sAdd('s1', '1');\n     * $redis->sAdd('s2', '3');\n     * $redis->sAdd('s2', '4');\n     *\n     * var_dump($redis->sUnion('s0', 's1', 's2'));\n     *\n     * array(4) {\n     * //  [0]=>\n     * //  string(1) \"3\"\n     * //  [1]=>\n     * //  string(1) \"4\"\n     * //  [2]=>\n     * //  string(1) \"1\"\n     * //  [3]=>\n     * //  string(1) \"2\"\n     * //}\n     * </pre>\n     */\n    public function sUnion($key1, ...$otherKeys)\n    {\n    }\n\n    /**\n     * Performs the same action as sUnion, but stores the result in the first key\n     *\n     * @param   string  $dstKey  the key to store the diff into.\n     * @param string $key1         first key for union\n     * @param string ...$otherKeys variadic list of keys corresponding to sets in redis\n     *\n     * @return int Any number of keys corresponding to sets in redis\n     *\n     * @link    https://redis.io/commands/sunionstore\n     * @example\n     * <pre>\n     * $redis->del('s0', 's1', 's2');\n     *\n     * $redis->sAdd('s0', '1');\n     * $redis->sAdd('s0', '2');\n     * $redis->sAdd('s1', '3');\n     * $redis->sAdd('s1', '1');\n     * $redis->sAdd('s2', '3');\n     * $redis->sAdd('s2', '4');\n     *\n     * var_dump($redis->sUnionStore('dst', 's0', 's1', 's2'));\n     * var_dump($redis->sMembers('dst'));\n     *\n     * //int(4)\n     * //array(4) {\n     * //  [0]=>\n     * //  string(1) \"3\"\n     * //  [1]=>\n     * //  string(1) \"4\"\n     * //  [2]=>\n     * //  string(1) \"1\"\n     * //  [3]=>\n     * //  string(1) \"2\"\n     * //}\n     * </pre>\n     */\n    public function sUnionStore($dstKey, $key1, ...$otherKeys)\n    {\n    }\n\n    /**\n     * Performs the difference between N sets and returns it.\n     *\n     * @param string $key1         first key for diff\n     * @param string ...$otherKeys variadic list of keys corresponding to sets in redis\n     *\n     * @return array string[] The difference of the first set will all the others\n     *\n     * @link    https://redis.io/commands/sdiff\n     * @example\n     * <pre>\n     * $redis->del('s0', 's1', 's2');\n     *\n     * $redis->sAdd('s0', '1');\n     * $redis->sAdd('s0', '2');\n     * $redis->sAdd('s0', '3');\n     * $redis->sAdd('s0', '4');\n     *\n     * $redis->sAdd('s1', '1');\n     * $redis->sAdd('s2', '3');\n     *\n     * var_dump($redis->sDiff('s0', 's1', 's2'));\n     *\n     * //array(2) {\n     * //  [0]=>\n     * //  string(1) \"4\"\n     * //  [1]=>\n     * //  string(1) \"2\"\n     * //}\n     * </pre>\n     */\n    public function sDiff($key1, ...$otherKeys)\n    {\n    }\n\n    /**\n     * Performs the same action as sDiff, but stores the result in the first key\n     *\n     * @param string $dstKey       the key to store the diff into.\n     * @param string $key1         first key for diff\n     * @param string ...$otherKeys variadic list of keys corresponding to sets in redis\n     *\n     * @return int|bool The cardinality of the resulting set, or FALSE in case of a missing key\n     *\n     * @link    https://redis.io/commands/sdiffstore\n     * @example\n     * <pre>\n     * $redis->del('s0', 's1', 's2');\n     *\n     * $redis->sAdd('s0', '1');\n     * $redis->sAdd('s0', '2');\n     * $redis->sAdd('s0', '3');\n     * $redis->sAdd('s0', '4');\n     *\n     * $redis->sAdd('s1', '1');\n     * $redis->sAdd('s2', '3');\n     *\n     * var_dump($redis->sDiffStore('dst', 's0', 's1', 's2'));\n     * var_dump($redis->sMembers('dst'));\n     *\n     * //int(2)\n     * //array(2) {\n     * //  [0]=>\n     * //  string(1) \"4\"\n     * //  [1]=>\n     * //  string(1) \"2\"\n     * //}\n     * </pre>\n     */\n    public function sDiffStore($dstKey, $key1, ...$otherKeys)\n    {\n    }\n\n    /**\n     * Returns the contents of a set.\n     *\n     * @param string $key\n     *\n     * @return array An array of elements, the contents of the set\n     *\n     * @link    https://redis.io/commands/smembers\n     * @example\n     * <pre>\n     * $redis->del('s');\n     * $redis->sAdd('s', 'a');\n     * $redis->sAdd('s', 'b');\n     * $redis->sAdd('s', 'a');\n     * $redis->sAdd('s', 'c');\n     * var_dump($redis->sMembers('s'));\n     *\n     * //array(3) {\n     * //  [0]=>\n     * //  string(1) \"c\"\n     * //  [1]=>\n     * //  string(1) \"a\"\n     * //  [2]=>\n     * //  string(1) \"b\"\n     * //}\n     * // The order is random and corresponds to redis' own internal representation of the set structure.\n     * </pre>\n     */\n    public function sMembers($key)\n    {\n    }\n\n    /**\n     * @see sMembers()\n     * @link    https://redis.io/commands/smembers\n     * @deprecated use Redis::sMembers()\n     *\n     * @param  string  $key\n     * @return array   An array of elements, the contents of the set\n     */\n    public function sGetMembers($key)\n    {\n    }\n\n    /**\n     * Scan a set for members\n     *\n     * @param string $key      The set to search.\n     * @param int    $iterator LONG (reference) to the iterator as we go.\n     * @param string   $pattern  String, optional pattern to match against.\n     * @param int    $count    How many members to return at a time (Redis might return a different amount)\n     *\n     * @return array|bool PHPRedis will return an array of keys or FALSE when we're done iterating\n     *\n     * @link    https://redis.io/commands/sscan\n     * @example\n     * <pre>\n     * $iterator = null;\n     * while ($members = $redis->sScan('set', $iterator)) {\n     *     foreach ($members as $member) {\n     *         echo $member . PHP_EOL;\n     *     }\n     * }\n     * </pre>\n     */\n    public function sScan($key, &$iterator, $pattern = null, $count = 0)\n    {\n    }\n\n    /**\n     * Sets a value and returns the previous entry at that key.\n     *\n     * @param string       $key\n     * @param string|mixed $value\n     *\n     * @return string|mixed A string (mixed, if used serializer), the previous value located at this key\n     *\n     * @link    https://redis.io/commands/getset\n     * @example\n     * <pre>\n     * $redis->set('x', '42');\n     * $exValue = $redis->getSet('x', 'lol');   // return '42', replaces x by 'lol'\n     * $newValue = $redis->get('x')'            // return 'lol'\n     * </pre>\n     */\n    public function getSet($key, $value)\n    {\n    }\n\n    /**\n     * Returns a random key\n     *\n     * @return string an existing key in redis\n     *\n     * @link    https://redis.io/commands/randomkey\n     * @example\n     * <pre>\n     * $key = $redis->randomKey();\n     * $surprise = $redis->get($key);  // who knows what's in there.\n     * </pre>\n     */\n    public function randomKey()\n    {\n    }\n\n    /**\n     * Switches to a given database\n     *\n     * @param int $dbIndex\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/select\n     * @example\n     * <pre>\n     * $redis->select(0);       // switch to DB 0\n     * $redis->set('x', '42');  // write 42 to x\n     * $redis->move('x', 1);    // move to DB 1\n     * $redis->select(1);       // switch to DB 1\n     * $redis->get('x');        // will return 42\n     * </pre>\n     */\n    public function select($dbIndex)\n    {\n    }\n\n    /**\n     * Moves a key to a different database.\n     *\n     * @param string $key\n     * @param int    $dbIndex\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/move\n     * @example\n     * <pre>\n     * $redis->select(0);       // switch to DB 0\n     * $redis->set('x', '42');  // write 42 to x\n     * $redis->move('x', 1);    // move to DB 1\n     * $redis->select(1);       // switch to DB 1\n     * $redis->get('x');        // will return 42\n     * </pre>\n     */\n    public function move($key, $dbIndex)\n    {\n    }\n\n    /**\n     * Renames a key\n     *\n     * @param string $srcKey\n     * @param string $dstKey\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/rename\n     * @example\n     * <pre>\n     * $redis->set('x', '42');\n     * $redis->rename('x', 'y');\n     * $redis->get('y');   // → 42\n     * $redis->get('x');   // → `FALSE`\n     * </pre>\n     */\n    public function rename($srcKey, $dstKey)\n    {\n    }\n\n    /**\n     * @see rename()\n     * @link    https://redis.io/commands/rename\n     * @deprecated use Redis::rename()\n     *\n     * @param   string  $srcKey\n     * @param   string  $dstKey\n     */\n    public function renameKey($srcKey, $dstKey)\n    {\n    }\n\n    /**\n     * Renames a key\n     *\n     * Same as rename, but will not replace a key if the destination already exists.\n     * This is the same behaviour as setNx.\n     *\n     * @param string $srcKey\n     * @param string $dstKey\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/renamenx\n     * @example\n     * <pre>\n     * $redis->set('x', '42');\n     * $redis->rename('x', 'y');\n     * $redis->get('y');   // → 42\n     * $redis->get('x');   // → `FALSE`\n     * </pre>\n     */\n    public function renameNx($srcKey, $dstKey)\n    {\n    }\n\n    /**\n     * Sets an expiration date (a timeout) on an item\n     *\n     * @param string $key The key that will disappear\n     * @param int    $ttl The key's remaining Time To Live, in seconds\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/expire\n     * @example\n     * <pre>\n     * $redis->set('x', '42');\n     * $redis->expire('x', 3);  // x will disappear in 3 seconds.\n     * sleep(5);                    // wait 5 seconds\n     * $redis->get('x');            // will return `FALSE`, as 'x' has expired.\n     * </pre>\n     */\n    public function expire($key, $ttl)\n    {\n    }\n\n    /**\n     * Sets an expiration date (a timeout in milliseconds) on an item\n     *\n     * @param string $key The key that will disappear.\n     * @param int    $ttl The key's remaining Time To Live, in milliseconds\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/pexpire\n     * @example\n     * <pre>\n     * $redis->set('x', '42');\n     * $redis->pExpire('x', 11500); // x will disappear in 11500 milliseconds.\n     * $redis->ttl('x');            // 12\n     * $redis->pttl('x');           // 11500\n     * </pre>\n     */\n    public function pExpire($key, $ttl)\n    {\n    }\n\n    /**\n     * @see expire()\n     * @link    https://redis.io/commands/expire\n     * @deprecated use Redis::expire()\n     *\n     * @param   string  $key\n     * @param   int     $ttl\n     * @return  bool\n     */\n    public function setTimeout($key, $ttl)\n    {\n    }\n\n    /**\n     * Sets an expiration date (a timestamp) on an item.\n     *\n     * @param string $key       The key that will disappear.\n     * @param int    $timestamp Unix timestamp. The key's date of death, in seconds from Epoch time.\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/expireat\n     * @example\n     * <pre>\n     * $redis->set('x', '42');\n     * $now = time(NULL);               // current timestamp\n     * $redis->expireAt('x', $now + 3); // x will disappear in 3 seconds.\n     * sleep(5);                        // wait 5 seconds\n     * $redis->get('x');                // will return `FALSE`, as 'x' has expired.\n     * </pre>\n     */\n    public function expireAt($key, $timestamp)\n    {\n    }\n\n    /**\n     * Sets an expiration date (a timestamp) on an item. Requires a timestamp in milliseconds\n     *\n     * @param string $key       The key that will disappear\n     * @param int    $timestamp Unix timestamp. The key's date of death, in seconds from Epoch time\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/pexpireat\n     * @example\n     * <pre>\n     * $redis->set('x', '42');\n     * $redis->pExpireAt('x', 1555555555005);\n     * echo $redis->ttl('x');                       // 218270121\n     * echo $redis->pttl('x');                      // 218270120575\n     * </pre>\n     */\n    public function pExpireAt($key, $timestamp)\n    {\n    }\n\n    /**\n     * Returns the keys that match a certain pattern.\n     *\n     * @param string $pattern pattern, using '*' as a wildcard\n     *\n     * @return array string[] The keys that match a certain pattern.\n     *\n     * @link    https://redis.io/commands/keys\n     * @example\n     * <pre>\n     * $allKeys = $redis->keys('*');   // all keys will match this.\n     * $keyWithUserPrefix = $redis->keys('user*');\n     * </pre>\n     */\n    public function keys($pattern)\n    {\n    }\n\n    /**\n     * @see keys()\n     * @deprecated use Redis::keys()\n     *\n     * @param string $pattern\n     * @link    https://redis.io/commands/keys\n     */\n    public function getKeys($pattern)\n    {\n    }\n\n    /**\n     * Returns the current database's size\n     *\n     * @return int DB size, in number of keys\n     *\n     * @link    https://redis.io/commands/dbsize\n     * @example\n     * <pre>\n     * $count = $redis->dbSize();\n     * echo \"Redis has $count keys\\n\";\n     * </pre>\n     */\n    public function dbSize()\n    {\n    }\n\n    /**\n     * Authenticate the connection using a password.\n     * Warning: The password is sent in plain-text over the network.\n     *\n     * @param string $password\n     *\n     * @return bool TRUE if the connection is authenticated, FALSE otherwise\n     *\n     * @link    https://redis.io/commands/auth\n     * @example $redis->auth('foobared');\n     */\n    public function auth($password)\n    {\n    }\n\n    /**\n     * Starts the background rewrite of AOF (Append-Only File)\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/bgrewriteaof\n     * @example $redis->bgrewriteaof();\n     */\n    public function bgrewriteaof()\n    {\n    }\n\n    /**\n     * Changes the slave status\n     * Either host and port, or no parameter to stop being a slave.\n     *\n     * @param string $host [optional]\n     * @param int    $port [optional]\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/slaveof\n     * @example\n     * <pre>\n     * $redis->slaveof('10.0.1.7', 6379);\n     * // ...\n     * $redis->slaveof();\n     * </pre>\n     */\n    public function slaveof($host = '127.0.0.1', $port = 6379)\n    {\n    }\n\n    /**\n     * Access the Redis slowLog\n     *\n     * @param string   $operation This can be either GET, LEN, or RESET\n     * @param int|null $length    If executing a SLOWLOG GET command, you can pass an optional length.\n     *\n     * @return mixed The return value of SLOWLOG will depend on which operation was performed.\n     * - SLOWLOG GET: Array of slowLog entries, as provided by Redis\n     * - SLOGLOG LEN: Integer, the length of the slowLog\n     * - SLOWLOG RESET: Boolean, depending on success\n     *\n     * @example\n     * <pre>\n     * // Get ten slowLog entries\n     * $redis->slowLog('get', 10);\n     * // Get the default number of slowLog entries\n     *\n     * $redis->slowLog('get');\n     * // Reset our slowLog\n     * $redis->slowLog('reset');\n     *\n     * // Retrieve slowLog length\n     * $redis->slowLog('len');\n     * </pre>\n     *\n     * @link https://redis.io/commands/slowlog\n     */\n    public function slowLog(string $operation, int $length = null)\n    {\n    }\n\n\n    /**\n     * Describes the object pointed to by a key.\n     * The information to retrieve (string) and the key (string).\n     * Info can be one of the following:\n     * - \"encoding\"\n     * - \"refcount\"\n     * - \"idletime\"\n     *\n     * @param string $string\n     * @param string $key\n     *\n     * @return string|int|bool for \"encoding\", int for \"refcount\" and \"idletime\", FALSE if the key doesn't exist.\n     *\n     * @link    https://redis.io/commands/object\n     * @example\n     * <pre>\n     * $redis->lPush('l', 'Hello, world!');\n     * $redis->object(\"encoding\", \"l\"); // → ziplist\n     * $redis->object(\"refcount\", \"l\"); // → 1\n     * $redis->object(\"idletime\", \"l\"); // → 400 (in seconds, with a precision of 10 seconds).\n     * </pre>\n     */\n    public function object($string = '', $key = '')\n    {\n    }\n\n    /**\n     * Performs a synchronous save.\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     * If a save is already running, this command will fail and return FALSE.\n     *\n     * @link    https://redis.io/commands/save\n     * @example $redis->save();\n     */\n    public function save()\n    {\n    }\n\n    /**\n     * Performs a background save.\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     * If a save is already running, this command will fail and return FALSE\n     *\n     * @link    https://redis.io/commands/bgsave\n     * @example $redis->bgSave();\n     */\n    public function bgsave()\n    {\n    }\n\n    /**\n     * Returns the timestamp of the last disk save.\n     *\n     * @return int timestamp\n     *\n     * @link    https://redis.io/commands/lastsave\n     * @example $redis->lastSave();\n     */\n    public function lastSave()\n    {\n    }\n\n    /**\n     * Blocks the current client until all the previous write commands are successfully transferred and\n     * acknowledged by at least the specified number of slaves.\n     *\n     * @param int $numSlaves Number of slaves that need to acknowledge previous write commands.\n     * @param int $timeout   Timeout in milliseconds.\n     *\n     * @return  int The command returns the number of slaves reached by all the writes performed in the\n     *              context of the current connection\n     *\n     * @link    https://redis.io/commands/wait\n     * @example $redis->wait(2, 1000);\n     */\n    public function wait($numSlaves, $timeout)\n    {\n    }\n\n    /**\n     * Returns the type of data pointed by a given key.\n     *\n     * @param string $key\n     *\n     * @return int\n     * Depending on the type of the data pointed by the key,\n     * this method will return the following value:\n     * - string: Redis::REDIS_STRING\n     * - set:   Redis::REDIS_SET\n     * - list:  Redis::REDIS_LIST\n     * - zset:  Redis::REDIS_ZSET\n     * - hash:  Redis::REDIS_HASH\n     * - other: Redis::REDIS_NOT_FOUND\n     *\n     * @link    https://redis.io/commands/type\n     * @example $redis->type('key');\n     */\n    public function type($key)\n    {\n    }\n\n    /**\n     * Append specified string to the string stored in specified key.\n     *\n     * @param string       $key\n     * @param string|mixed $value\n     *\n     * @return int Size of the value after the append\n     *\n     * @link    https://redis.io/commands/append\n     * @example\n     * <pre>\n     * $redis->set('key', 'value1');\n     * $redis->append('key', 'value2'); // 12\n     * $redis->get('key');              // 'value1value2'\n     * </pre>\n     */\n    public function append($key, $value)\n    {\n    }\n\n    /**\n     * Return a substring of a larger string\n     *\n     * @param string $key\n     * @param int    $start\n     * @param int    $end\n     *\n     * @return string the substring\n     *\n     * @link    https://redis.io/commands/getrange\n     * @example\n     * <pre>\n     * $redis->set('key', 'string value');\n     * $redis->getRange('key', 0, 5);   // 'string'\n     * $redis->getRange('key', -5, -1); // 'value'\n     * </pre>\n     */\n    public function getRange($key, $start, $end)\n    {\n    }\n\n    /**\n     * Return a substring of a larger string\n     *\n     * @deprecated\n     * @param   string  $key\n     * @param   int     $start\n     * @param   int     $end\n     */\n    public function substr($key, $start, $end)\n    {\n    }\n\n    /**\n     * Changes a substring of a larger string.\n     *\n     * @param string $key\n     * @param int    $offset\n     * @param string $value\n     *\n     * @return int the length of the string after it was modified\n     *\n     * @link    https://redis.io/commands/setrange\n     * @example\n     * <pre>\n     * $redis->set('key', 'Hello world');\n     * $redis->setRange('key', 6, \"redis\"); // returns 11\n     * $redis->get('key');                  // \"Hello redis\"\n     * </pre>\n     */\n    public function setRange($key, $offset, $value)\n    {\n    }\n\n    /**\n     * Get the length of a string value.\n     *\n     * @param string $key\n     * @return int\n     *\n     * @link    https://redis.io/commands/strlen\n     * @example\n     * <pre>\n     * $redis->set('key', 'value');\n     * $redis->strlen('key'); // 5\n     * </pre>\n     */\n    public function strlen($key)\n    {\n    }\n\n    /**\n     * Return the position of the first bit set to 1 or 0 in a string. The position is returned, thinking of the\n     * string as an array of bits from left to right, where the first byte's most significant bit is at position 0,\n     * the second byte's most significant bit is at position 8, and so forth.\n     *\n     * @param string $key\n     * @param int    $bit\n     * @param int    $start\n     * @param int    $end\n     *\n     * @return int The command returns the position of the first bit set to 1 or 0 according to the request.\n     * If we look for set bits (the bit argument is 1) and the string is empty or composed of just\n     * zero bytes, -1 is returned. If we look for clear bits (the bit argument is 0) and the string\n     * only contains bit set to 1, the function returns the first bit not part of the string on the\n     * right. So if the string is three bytes set to the value 0xff the command BITPOS key 0 will\n     * return 24, since up to bit 23 all the bits are 1. Basically, the function considers the right\n     * of the string as padded with zeros if you look for clear bits and specify no range or the\n     * start argument only. However, this behavior changes if you are looking for clear bits and\n     * specify a range with both start and end. If no clear bit is found in the specified range, the\n     * function returns -1 as the user specified a clear range and there are no 0 bits in that range.\n     *\n     * @link    https://redis.io/commands/bitpos\n     * @example\n     * <pre>\n     * $redis->set('key', '\\xff\\xff');\n     * $redis->bitpos('key', 1); // int(0)\n     * $redis->bitpos('key', 1, 1); // int(8)\n     * $redis->bitpos('key', 1, 3); // int(-1)\n     * $redis->bitpos('key', 0); // int(16)\n     * $redis->bitpos('key', 0, 1); // int(16)\n     * $redis->bitpos('key', 0, 1, 5); // int(-1)\n     * </pre>\n     */\n    public function bitpos($key, $bit, $start = 0, $end = null)\n    {\n    }\n\n    /**\n     * Return a single bit out of a larger string\n     *\n     * @param string $key\n     * @param int    $offset\n     *\n     * @return int the bit value (0 or 1)\n     *\n     * @link    https://redis.io/commands/getbit\n     * @example\n     * <pre>\n     * $redis->set('key', \"\\x7f\");  // this is 0111 1111\n     * $redis->getBit('key', 0);    // 0\n     * $redis->getBit('key', 1);    // 1\n     * </pre>\n     */\n    public function getBit($key, $offset)\n    {\n    }\n\n    /**\n     * Changes a single bit of a string.\n     *\n     * @param string   $key\n     * @param int      $offset\n     * @param bool|int $value  bool or int (1 or 0)\n     *\n     * @return int 0 or 1, the value of the bit before it was set\n     *\n     * @link    https://redis.io/commands/setbit\n     * @example\n     * <pre>\n     * $redis->set('key', \"*\");     // ord(\"*\") = 42 = 0x2f = \"0010 1010\"\n     * $redis->setBit('key', 5, 1); // returns 0\n     * $redis->setBit('key', 7, 1); // returns 0\n     * $redis->get('key');          // chr(0x2f) = \"/\" = b(\"0010 1111\")\n     * </pre>\n     */\n    public function setBit($key, $offset, $value)\n    {\n    }\n\n    /**\n     * Count bits in a string\n     *\n     * @param string $key\n     *\n     * @return int The number of bits set to 1 in the value behind the input key\n     *\n     * @link    https://redis.io/commands/bitcount\n     * @example\n     * <pre>\n     * $redis->set('bit', '345'); // // 11 0011  0011 0100  0011 0101\n     * var_dump( $redis->bitCount('bit', 0, 0) ); // int(4)\n     * var_dump( $redis->bitCount('bit', 1, 1) ); // int(3)\n     * var_dump( $redis->bitCount('bit', 2, 2) ); // int(4)\n     * var_dump( $redis->bitCount('bit', 0, 2) ); // int(11)\n     * </pre>\n     */\n    public function bitCount($key)\n    {\n    }\n\n    /**\n     * Bitwise operation on multiple keys.\n     *\n     * @param string $operation    either \"AND\", \"OR\", \"NOT\", \"XOR\"\n     * @param string $retKey       return key\n     * @param string $key1         first key\n     * @param string ...$otherKeys variadic list of keys\n     *\n     * @return int The size of the string stored in the destination key\n     *\n     * @link    https://redis.io/commands/bitop\n     * @example\n     * <pre>\n     * $redis->set('bit1', '1'); // 11 0001\n     * $redis->set('bit2', '2'); // 11 0010\n     *\n     * $redis->bitOp('AND', 'bit', 'bit1', 'bit2'); // bit = 110000\n     * $redis->bitOp('OR',  'bit', 'bit1', 'bit2'); // bit = 110011\n     * $redis->bitOp('NOT', 'bit', 'bit1', 'bit2'); // bit = 110011\n     * $redis->bitOp('XOR', 'bit', 'bit1', 'bit2'); // bit = 11\n     * </pre>\n     */\n    public function bitOp($operation, $retKey, $key1, ...$otherKeys)\n    {\n    }\n\n    /**\n     * Removes all entries from the current database.\n     *\n     * @return bool Always TRUE\n     * @link    https://redis.io/commands/flushdb\n     * @example $redis->flushDB();\n     */\n    public function flushDB()\n    {\n    }\n\n    /**\n     * Removes all entries from all databases.\n     *\n     * @return bool Always TRUE\n     *\n     * @link    https://redis.io/commands/flushall\n     * @example $redis->flushAll();\n     */\n    public function flushAll()\n    {\n    }\n\n    /**\n     * Sort\n     *\n     * @param string $key\n     * @param array  $option array(key => value, ...) - optional, with the following keys and values:\n     * - 'by' => 'some_pattern_*',\n     * - 'limit' => array(0, 1),\n     * - 'get' => 'some_other_pattern_*' or an array of patterns,\n     * - 'sort' => 'asc' or 'desc',\n     * - 'alpha' => TRUE,\n     * - 'store' => 'external-key'\n     *\n     * @return array\n     * An array of values, or a number corresponding to the number of elements stored if that was used\n     *\n     * @link    https://redis.io/commands/sort\n     * @example\n     * <pre>\n     * $redis->del('s');\n     * $redis->sadd('s', 5);\n     * $redis->sadd('s', 4);\n     * $redis->sadd('s', 2);\n     * $redis->sadd('s', 1);\n     * $redis->sadd('s', 3);\n     *\n     * var_dump($redis->sort('s')); // 1,2,3,4,5\n     * var_dump($redis->sort('s', array('sort' => 'desc'))); // 5,4,3,2,1\n     * var_dump($redis->sort('s', array('sort' => 'desc', 'store' => 'out'))); // (int)5\n     * </pre>\n     */\n    public function sort($key, $option = null)\n    {\n    }\n\n    /**\n     * Returns an associative array of strings and integers\n     *\n     * @param string $option Optional. The option to provide redis.\n     * SERVER | CLIENTS | MEMORY | PERSISTENCE | STATS | REPLICATION | CPU | CLASTER | KEYSPACE | COMANDSTATS\n     *\n     * Returns an associative array of strings and integers, with the following keys:\n     * - redis_version\n     * - redis_git_sha1\n     * - redis_git_dirty\n     * - arch_bits\n     * - multiplexing_api\n     * - process_id\n     * - uptime_in_seconds\n     * - uptime_in_days\n     * - lru_clock\n     * - used_cpu_sys\n     * - used_cpu_user\n     * - used_cpu_sys_children\n     * - used_cpu_user_children\n     * - connected_clients\n     * - connected_slaves\n     * - client_longest_output_list\n     * - client_biggest_input_buf\n     * - blocked_clients\n     * - used_memory\n     * - used_memory_human\n     * - used_memory_peak\n     * - used_memory_peak_human\n     * - mem_fragmentation_ratio\n     * - mem_allocator\n     * - loading\n     * - aof_enabled\n     * - changes_since_last_save\n     * - bgsave_in_progress\n     * - last_save_time\n     * - total_connections_received\n     * - total_commands_processed\n     * - expired_keys\n     * - evicted_keys\n     * - keyspace_hits\n     * - keyspace_misses\n     * - hash_max_zipmap_entries\n     * - hash_max_zipmap_value\n     * - pubsub_channels\n     * - pubsub_patterns\n     * - latest_fork_usec\n     * - vm_enabled\n     * - role\n     *\n     * @return string\n     *\n     * @link    https://redis.io/commands/info\n     * @example\n     * <pre>\n     * $redis->info();\n     *\n     * or\n     *\n     * $redis->info(\"COMMANDSTATS\"); //Information on the commands that have been run (>=2.6 only)\n     * $redis->info(\"CPU\"); // just CPU information from Redis INFO\n     * </pre>\n     */\n    public function info($option = null)\n    {\n    }\n\n    /**\n     * Resets the statistics reported by Redis using the INFO command (`info()` function).\n     * These are the counters that are reset:\n     *      - Keyspace hits\n     *      - Keyspace misses\n     *      - Number of commands processed\n     *      - Number of connections received\n     *      - Number of expired keys\n     *\n     * @return bool `TRUE` in case of success, `FALSE` in case of failure.\n     *\n     * @example $redis->resetStat();\n     * @link https://redis.io/commands/config-resetstat\n     */\n    public function resetStat()\n    {\n    }\n\n    /**\n     * Returns the time to live left for a given key, in seconds. If the key doesn't exist, FALSE is returned.\n     *\n     * @param string $key\n     *\n     * @return int|bool the time left to live in seconds\n     *\n     * @link    https://redis.io/commands/ttl\n     * @example\n     * <pre>\n     * $redis->setex('key', 123, 'test');\n     * $redis->ttl('key'); // int(123)\n     * </pre>\n     */\n    public function ttl($key)\n    {\n    }\n\n    /**\n     * Returns a time to live left for a given key, in milliseconds.\n     *\n     * If the key doesn't exist, FALSE is returned.\n     *\n     * @param string $key\n     *\n     * @return int|bool the time left to live in milliseconds\n     *\n     * @link    https://redis.io/commands/pttl\n     * @example\n     * <pre>\n     * $redis->setex('key', 123, 'test');\n     * $redis->pttl('key'); // int(122999)\n     * </pre>\n     */\n    public function pttl($key)\n    {\n    }\n\n    /**\n     * Remove the expiration timer from a key.\n     *\n     * @param string $key\n     *\n     * @return bool TRUE if a timeout was removed, FALSE if the key didn’t exist or didn’t have an expiration timer.\n     *\n     * @link    https://redis.io/commands/persist\n     * @example $redis->persist('key');\n     */\n    public function persist($key)\n    {\n    }\n\n    /**\n     * Sets multiple key-value pairs in one atomic command.\n     * MSETNX only returns TRUE if all the keys were set (see SETNX).\n     *\n     * @param array $array Pairs: array(key => value, ...)\n     *\n     * @return bool TRUE in case of success, FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/mset\n     * @example\n     * <pre>\n     * $redis->mset(array('key0' => 'value0', 'key1' => 'value1'));\n     * var_dump($redis->get('key0'));\n     * var_dump($redis->get('key1'));\n     * // Output:\n     * // string(6) \"value0\"\n     * // string(6) \"value1\"\n     * </pre>\n     */\n    public function mset(array $array)\n    {\n    }\n\n    /**\n     * Get the values of all the specified keys.\n     * If one or more keys dont exist, the array will contain FALSE at the position of the key.\n     *\n     * @param array $keys Array containing the list of the keys\n     *\n     * @return array Array containing the values related to keys in argument\n     *\n     * @deprecated use Redis::mGet()\n     * @example\n     * <pre>\n     * $redis->set('key1', 'value1');\n     * $redis->set('key2', 'value2');\n     * $redis->set('key3', 'value3');\n     * $redis->getMultiple(array('key1', 'key2', 'key3')); // array('value1', 'value2', 'value3');\n     * $redis->getMultiple(array('key0', 'key1', 'key5')); // array(`FALSE`, 'value2', `FALSE`);\n     * </pre>\n     */\n    public function getMultiple(array $keys)\n    {\n    }\n\n    /**\n     * Returns the values of all specified keys.\n     *\n     * For every key that does not hold a string value or does not exist,\n     * the special value false is returned. Because of this, the operation never fails.\n     *\n     * @param array $array\n     *\n     * @return array\n     *\n     * @link https://redis.io/commands/mget\n     * @example\n     * <pre>\n     * $redis->del('x', 'y', 'z', 'h');  // remove x y z\n     * $redis->mset(array('x' => 'a', 'y' => 'b', 'z' => 'c'));\n     * $redis->hset('h', 'field', 'value');\n     * var_dump($redis->mget(array('x', 'y', 'z', 'h')));\n     * // Output:\n     * // array(3) {\n     * //   [0]=> string(1) \"a\"\n     * //   [1]=> string(1) \"b\"\n     * //   [2]=> string(1) \"c\"\n     * //   [3]=> bool(false)\n     * // }\n     * </pre>\n     */\n    public function mget(array $array)\n    {\n    }\n\n    /**\n     * @see mset()\n     * @param array $array\n     * @return int 1 (if the keys were set) or 0 (no key was set)\n     *\n     * @link    https://redis.io/commands/msetnx\n     */\n    public function msetnx(array $array)\n    {\n    }\n\n    /**\n     * Pops a value from the tail of a list, and pushes it to the front of another list.\n     * Also return this value.\n     *\n     * @since   redis >= 1.1\n     *\n     * @param string $srcKey\n     * @param string $dstKey\n     *\n     * @return string|mixed|bool The element that was moved in case of success, FALSE in case of failure.\n     *\n     * @link    https://redis.io/commands/rpoplpush\n     * @example\n     * <pre>\n     * $redis->del('x', 'y');\n     *\n     * $redis->lPush('x', 'abc');\n     * $redis->lPush('x', 'def');\n     * $redis->lPush('y', '123');\n     * $redis->lPush('y', '456');\n     *\n     * // move the last of x to the front of y.\n     * var_dump($redis->rpoplpush('x', 'y'));\n     * var_dump($redis->lRange('x', 0, -1));\n     * var_dump($redis->lRange('y', 0, -1));\n     *\n     * //Output:\n     * //\n     * //string(3) \"abc\"\n     * //array(1) {\n     * //  [0]=>\n     * //  string(3) \"def\"\n     * //}\n     * //array(3) {\n     * //  [0]=>\n     * //  string(3) \"abc\"\n     * //  [1]=>\n     * //  string(3) \"456\"\n     * //  [2]=>\n     * //  string(3) \"123\"\n     * //}\n     * </pre>\n     */\n    public function rpoplpush($srcKey, $dstKey)\n    {\n    }\n\n    /**\n     * A blocking version of rpoplpush, with an integral timeout in the third parameter.\n     *\n     * @param string $srcKey\n     * @param string $dstKey\n     * @param int    $timeout\n     *\n     * @return  string|mixed|bool  The element that was moved in case of success, FALSE in case of timeout\n     *\n     * @link    https://redis.io/commands/brpoplpush\n     */\n    public function brpoplpush($srcKey, $dstKey, $timeout)\n    {\n    }\n\n    /**\n     * Adds the specified member with a given score to the sorted set stored at key\n     *\n     * @param string       $key     Required key\n     * @param array        $options Options if needed\n     * @param float        $score1  Required score\n     * @param string|mixed $value1  Required value\n     * @param float        $score2  Optional score\n     * @param string|mixed $value2  Optional value\n     * @param float        $scoreN  Optional score\n     * @param string|mixed $valueN  Optional value\n     *\n     * @return int Number of values added\n     *\n     * @link    https://redis.io/commands/zadd\n     * @example\n     * <pre>\n     * <pre>\n     * $redis->zAdd('z', 1, 'v1', 2, 'v2', 3, 'v3', 4, 'v4' );  // int(2)\n     * $redis->zRem('z', 'v2', 'v3');                           // int(2)\n     * $redis->zAdd('z', ['NX'], 5, 'v5');                      // int(1)\n     * $redis->zAdd('z', ['NX'], 6, 'v5');                      // int(0)\n     * $redis->zAdd('z', 7, 'v6');                              // int(1)\n     * $redis->zAdd('z', 8, 'v6');                              // int(0)\n     *\n     * var_dump( $redis->zRange('z', 0, -1) );\n     * // Output:\n     * // array(4) {\n     * //   [0]=> string(2) \"v1\"\n     * //   [1]=> string(2) \"v4\"\n     * //   [2]=> string(2) \"v5\"\n     * //   [3]=> string(2) \"v8\"\n     * // }\n     *\n     * var_dump( $redis->zRange('z', 0, -1, true) );\n     * // Output:\n     * // array(4) {\n     * //   [\"v1\"]=> float(1)\n     * //   [\"v4\"]=> float(4)\n     * //   [\"v5\"]=> float(5)\n     * //   [\"v6\"]=> float(8)\n     * </pre>\n     * </pre>\n     */\n    public function zAdd($key, $options, $score1, $value1, $score2 = null, $value2 = null, $scoreN = null, $valueN = null)\n    {\n    }\n\n    /**\n     * Returns a range of elements from the ordered set stored at the specified key,\n     * with values in the range [start, end]. start and stop are interpreted as zero-based indices:\n     * 0 the first element,\n     * 1 the second ...\n     * -1 the last element,\n     * -2 the penultimate ...\n     *\n     * @param string $key\n     * @param int    $start\n     * @param int    $end\n     * @param bool   $withscores\n     *\n     * @return array Array containing the values in specified range.\n     *\n     * @link    https://redis.io/commands/zrange\n     * @example\n     * <pre>\n     * $redis->zAdd('key1', 0, 'val0');\n     * $redis->zAdd('key1', 2, 'val2');\n     * $redis->zAdd('key1', 10, 'val10');\n     * $redis->zRange('key1', 0, -1); // array('val0', 'val2', 'val10')\n     * // with scores\n     * $redis->zRange('key1', 0, -1, true); // array('val0' => 0, 'val2' => 2, 'val10' => 10)\n     * </pre>\n     */\n    public function zRange($key, $start, $end, $withscores = null)\n    {\n    }\n\n    /**\n     * Deletes a specified member from the ordered set.\n     *\n     * @param string       $key\n     * @param string|mixed $member1\n     * @param string|mixed ...$otherMembers\n     *\n     * @return int Number of deleted values\n     *\n     * @link    https://redis.io/commands/zrem\n     * @example\n     * <pre>\n     * $redis->zAdd('z', 1, 'v1', 2, 'v2', 3, 'v3', 4, 'v4' );  // int(2)\n     * $redis->zRem('z', 'v2', 'v3');                           // int(2)\n     * var_dump( $redis->zRange('z', 0, -1) );\n     * //// Output:\n     * // array(2) {\n     * //   [0]=> string(2) \"v1\"\n     * //   [1]=> string(2) \"v4\"\n     * // }\n     * </pre>\n     */\n    public function zRem($key, $member1, ...$otherMembers)\n    {\n    }\n\n    /**\n     * @see zRem()\n     * @link https://redis.io/commands/zrem\n     * @deprecated use Redis::zRem()\n     *\n     * @param string       $key\n     * @param string|mixed $member1\n     * @param string|mixed ...$otherMembers\n     *\n     * @return int Number of deleted values\n     */\n    public function zDelete($key, $member1, ...$otherMembers)\n    {\n    }\n\n    /**\n     * Returns the elements of the sorted set stored at the specified key in the range [start, end]\n     * in reverse order. start and stop are interpretated as zero-based indices:\n     * 0 the first element,\n     * 1 the second ...\n     * -1 the last element,\n     * -2 the penultimate ...\n     *\n     * @param string $key\n     * @param int    $start\n     * @param int    $end\n     * @param bool   $withscore\n     *\n     * @return array Array containing the values in specified range.\n     *\n     * @link    https://redis.io/commands/zrevrange\n     * @example\n     * <pre>\n     * $redis->zAdd('key', 0, 'val0');\n     * $redis->zAdd('key', 2, 'val2');\n     * $redis->zAdd('key', 10, 'val10');\n     * $redis->zRevRange('key', 0, -1); // array('val10', 'val2', 'val0')\n     *\n     * // with scores\n     * $redis->zRevRange('key', 0, -1, true); // array('val10' => 10, 'val2' => 2, 'val0' => 0)\n     * </pre>\n     */\n    public function zRevRange($key, $start, $end, $withscore = null)\n    {\n    }\n\n    /**\n     * Returns the elements of the sorted set stored at the specified key which have scores in the\n     * range [start,end]. Adding a parenthesis before start or end excludes it from the range.\n     * +inf and -inf are also valid limits.\n     *\n     * zRevRangeByScore returns the same items in reverse order, when the start and end parameters are swapped.\n     *\n     * @param string $key\n     * @param int    $start\n     * @param int    $end\n     * @param array  $options Two options are available:\n     *  - withscores => TRUE,\n     *  - and limit => array($offset, $count)\n     *\n     * @return array Array containing the values in specified range.\n     *\n     * @link    https://redis.io/commands/zrangebyscore\n     * @example\n     * <pre>\n     * $redis->zAdd('key', 0, 'val0');\n     * $redis->zAdd('key', 2, 'val2');\n     * $redis->zAdd('key', 10, 'val10');\n     * $redis->zRangeByScore('key', 0, 3);                                          // array('val0', 'val2')\n     * $redis->zRangeByScore('key', 0, 3, array('withscores' => TRUE);              // array('val0' => 0, 'val2' => 2)\n     * $redis->zRangeByScore('key', 0, 3, array('limit' => array(1, 1));                        // array('val2')\n     * $redis->zRangeByScore('key', 0, 3, array('withscores' => TRUE, 'limit' => array(1, 1));  // array('val2' => 2)\n     * </pre>\n     */\n    public function zRangeByScore($key, $start, $end, array $options = array())\n    {\n    }\n\n    /**\n     * @see zRangeByScore()\n     * @param string $key\n     * @param int    $start\n     * @param int    $end\n     * @param array  $options\n     *\n     * @return array\n     */\n    public function zRevRangeByScore($key, $start, $end, array $options = array())\n    {\n    }\n\n    /**\n     * Returns a lexigraphical range of members in a sorted set, assuming the members have the same score. The\n     * min and max values are required to start with '(' (exclusive), '[' (inclusive), or be exactly the values\n     * '-' (negative inf) or '+' (positive inf).  The command must be called with either three *or* five\n     * arguments or will return FALSE.\n     *\n     * @param string $key    The ZSET you wish to run against.\n     * @param int    $min    The minimum alphanumeric value you wish to get.\n     * @param int    $max    The maximum alphanumeric value you wish to get.\n     * @param int    $offset Optional argument if you wish to start somewhere other than the first element.\n     * @param int    $limit  Optional argument if you wish to limit the number of elements returned.\n     *\n     * @return array|bool Array containing the values in the specified range.\n     *\n     * @link    https://redis.io/commands/zrangebylex\n     * @example\n     * <pre>\n     * foreach (array('a', 'b', 'c', 'd', 'e', 'f', 'g') as $char) {\n     *     $redis->zAdd('key', $char);\n     * }\n     *\n     * $redis->zRangeByLex('key', '-', '[c'); // array('a', 'b', 'c')\n     * $redis->zRangeByLex('key', '-', '(c'); // array('a', 'b')\n     * $redis->zRangeByLex('key', '-', '[c'); // array('b', 'c')\n     * </pre>\n     */\n    public function zRangeByLex($key, $min, $max, $offset = null, $limit = null)\n    {\n    }\n\n    /**\n     * @see zRangeByLex()\n     * @param string $key\n     * @param int    $min\n     * @param int    $max\n     * @param int    $offset\n     * @param int    $limit\n     *\n     * @return array\n     *\n     * @link    https://redis.io/commands/zrevrangebylex\n     */\n    public function zRevRangeByLex($key, $min, $max, $offset = null, $limit = null)\n    {\n    }\n\n    /**\n     * Returns the number of elements of the sorted set stored at the specified key which have\n     * scores in the range [start,end]. Adding a parenthesis before start or end excludes it\n     * from the range. +inf and -inf are also valid limits.\n     *\n     * @param string $key\n     * @param string $start\n     * @param string $end\n     *\n     * @return int the size of a corresponding zRangeByScore\n     *\n     * @link    https://redis.io/commands/zcount\n     * @example\n     * <pre>\n     * $redis->zAdd('key', 0, 'val0');\n     * $redis->zAdd('key', 2, 'val2');\n     * $redis->zAdd('key', 10, 'val10');\n     * $redis->zCount('key', 0, 3); // 2, corresponding to array('val0', 'val2')\n     * </pre>\n     */\n    public function zCount($key, $start, $end)\n    {\n    }\n\n    /**\n     * Deletes the elements of the sorted set stored at the specified key which have scores in the range [start,end].\n     *\n     * @param string       $key\n     * @param float|string $start double or \"+inf\" or \"-inf\" string\n     * @param float|string $end double or \"+inf\" or \"-inf\" string\n     *\n     * @return int The number of values deleted from the sorted set\n     *\n     * @link    https://redis.io/commands/zremrangebyscore\n     * @example\n     * <pre>\n     * $redis->zAdd('key', 0, 'val0');\n     * $redis->zAdd('key', 2, 'val2');\n     * $redis->zAdd('key', 10, 'val10');\n     * $redis->zRemRangeByScore('key', 0, 3); // 2\n     * </pre>\n     */\n    public function zRemRangeByScore($key, $start, $end)\n    {\n    }\n\n    /**\n     * @see zRemRangeByScore()\n     * @deprecated use Redis::zRemRangeByScore()\n     *\n     * @param string $key\n     * @param float  $start\n     * @param float  $end\n     */\n    public function zDeleteRangeByScore($key, $start, $end)\n    {\n    }\n\n    /**\n     * Deletes the elements of the sorted set stored at the specified key which have rank in the range [start,end].\n     *\n     * @param string $key\n     * @param int    $start\n     * @param int    $end\n     *\n     * @return int The number of values deleted from the sorted set\n     *\n     * @link    https://redis.io/commands/zremrangebyrank\n     * @example\n     * <pre>\n     * $redis->zAdd('key', 1, 'one');\n     * $redis->zAdd('key', 2, 'two');\n     * $redis->zAdd('key', 3, 'three');\n     * $redis->zRemRangeByRank('key', 0, 1); // 2\n     * $redis->zRange('key', 0, -1, array('withscores' => TRUE)); // array('three' => 3)\n     * </pre>\n     */\n    public function zRemRangeByRank($key, $start, $end)\n    {\n    }\n\n    /**\n     * @see zRemRangeByRank()\n     * @link    https://redis.io/commands/zremrangebyscore\n     * @deprecated use Redis::zRemRangeByRank()\n     *\n     * @param string $key\n     * @param int    $start\n     * @param int    $end\n     */\n    public function zDeleteRangeByRank($key, $start, $end)\n    {\n    }\n\n    /**\n     * Returns the cardinality of an ordered set.\n     *\n     * @param string $key\n     *\n     * @return int the set's cardinality\n     *\n     * @link    https://redis.io/commands/zsize\n     * @example\n     * <pre>\n     * $redis->zAdd('key', 0, 'val0');\n     * $redis->zAdd('key', 2, 'val2');\n     * $redis->zAdd('key', 10, 'val10');\n     * $redis->zCard('key');            // 3\n     * </pre>\n     */\n    public function zCard($key)\n    {\n    }\n\n    /**\n     * @see zCard()\n     * @deprecated use Redis::zCard()\n     *\n     * @param string $key\n     * @return int\n     */\n    public function zSize($key)\n    {\n    }\n\n    /**\n     * Returns the score of a given member in the specified sorted set.\n     *\n     * @param string       $key\n     * @param string|mixed $member\n     *\n     * @return float|bool false if member or key not exists\n     *\n     * @link    https://redis.io/commands/zscore\n     * @example\n     * <pre>\n     * $redis->zAdd('key', 2.5, 'val2');\n     * $redis->zScore('key', 'val2'); // 2.5\n     * </pre>\n     */\n    public function zScore($key, $member)\n    {\n    }\n\n    /**\n     * Returns the rank of a given member in the specified sorted set, starting at 0 for the item\n     * with the smallest score. zRevRank starts at 0 for the item with the largest score.\n     *\n     * @param string       $key\n     * @param string|mixed $member\n     *\n     * @return int|bool the item's score, or false if key or member is not exists\n     *\n     * @link    https://redis.io/commands/zrank\n     * @example\n     * <pre>\n     * $redis->del('z');\n     * $redis->zAdd('key', 1, 'one');\n     * $redis->zAdd('key', 2, 'two');\n     * $redis->zRank('key', 'one');     // 0\n     * $redis->zRank('key', 'two');     // 1\n     * $redis->zRevRank('key', 'one');  // 1\n     * $redis->zRevRank('key', 'two');  // 0\n     * </pre>\n     */\n    public function zRank($key, $member)\n    {\n    }\n\n    /**\n     * @see zRank()\n     * @param string       $key\n     * @param string|mixed $member\n     *\n     * @return int|bool the item's score, false - if key or member is not exists\n     *\n     * @link   https://redis.io/commands/zrevrank\n     */\n    public function zRevRank($key, $member)\n    {\n    }\n\n    /**\n     * Increments the score of a member from a sorted set by a given amount.\n     *\n     * @param string $key\n     * @param float  $value (double) value that will be added to the member's score\n     * @param string $member\n     *\n     * @return float the new value\n     *\n     * @link    https://redis.io/commands/zincrby\n     * @example\n     * <pre>\n     * $redis->del('key');\n     * $redis->zIncrBy('key', 2.5, 'member1');  // key or member1 didn't exist, so member1's score is to 0\n     *                                          // before the increment and now has the value 2.5\n     * $redis->zIncrBy('key', 1, 'member1');    // 3.5\n     * </pre>\n     */\n    public function zIncrBy($key, $value, $member)\n    {\n    }\n\n    /**\n     * Creates an union of sorted sets given in second argument.\n     * The result of the union will be stored in the sorted set defined by the first argument.\n     * The third optionnel argument defines weights to apply to the sorted sets in input.\n     * In this case, the weights will be multiplied by the score of each element in the sorted set\n     * before applying the aggregation. The forth argument defines the AGGREGATE option which\n     * specify how the results of the union are aggregated.\n     *\n     * @param string $output\n     * @param array  $zSetKeys\n     * @param array  $weights\n     * @param string $aggregateFunction  Either \"SUM\", \"MIN\", or \"MAX\": defines the behaviour to use on\n     * duplicate entries during the zUnionStore\n     *\n     * @return int The number of values in the new sorted set\n     *\n     * @link    https://redis.io/commands/zunionstore\n     * @example\n     * <pre>\n     * $redis->del('k1');\n     * $redis->del('k2');\n     * $redis->del('k3');\n     * $redis->del('ko1');\n     * $redis->del('ko2');\n     * $redis->del('ko3');\n     *\n     * $redis->zAdd('k1', 0, 'val0');\n     * $redis->zAdd('k1', 1, 'val1');\n     *\n     * $redis->zAdd('k2', 2, 'val2');\n     * $redis->zAdd('k2', 3, 'val3');\n     *\n     * $redis->zUnionStore('ko1', array('k1', 'k2')); // 4, 'ko1' => array('val0', 'val1', 'val2', 'val3')\n     *\n     * // Weighted zUnionStore\n     * $redis->zUnionStore('ko2', array('k1', 'k2'), array(1, 1)); // 4, 'ko2' => array('val0', 'val1', 'val2', 'val3')\n     * $redis->zUnionStore('ko3', array('k1', 'k2'), array(5, 1)); // 4, 'ko3' => array('val0', 'val2', 'val3', 'val1')\n     * </pre>\n     */\n    public function zUnionStore($output, $zSetKeys, array $weights = null, $aggregateFunction = 'SUM')\n    {\n    }\n\n    /**\n     * @see zUnionStore\n     * @deprecated use Redis::zUnionStore()\n     *\n     * @param string     $Output\n     * @param array      $ZSetKeys\n     * @param array|null $Weights\n     * @param string     $aggregateFunction\n     */\n    public function zUnion($Output, $ZSetKeys, array $Weights = null, $aggregateFunction = 'SUM')\n    {\n    }\n\n    /**\n     * Creates an intersection of sorted sets given in second argument.\n     * The result of the union will be stored in the sorted set defined by the first argument.\n     * The third optional argument defines weights to apply to the sorted sets in input.\n     * In this case, the weights will be multiplied by the score of each element in the sorted set\n     * before applying the aggregation. The forth argument defines the AGGREGATE option which\n     * specify how the results of the union are aggregated.\n     *\n     * @param string $output\n     * @param array  $zSetKeys\n     * @param array  $weights\n     * @param string $aggregateFunction Either \"SUM\", \"MIN\", or \"MAX\":\n     * defines the behaviour to use on duplicate entries during the zInterStore.\n     *\n     * @return int The number of values in the new sorted set.\n     *\n     * @link    https://redis.io/commands/zinterstore\n     * @example\n     * <pre>\n     * $redis->del('k1');\n     * $redis->del('k2');\n     * $redis->del('k3');\n     *\n     * $redis->del('ko1');\n     * $redis->del('ko2');\n     * $redis->del('ko3');\n     * $redis->del('ko4');\n     *\n     * $redis->zAdd('k1', 0, 'val0');\n     * $redis->zAdd('k1', 1, 'val1');\n     * $redis->zAdd('k1', 3, 'val3');\n     *\n     * $redis->zAdd('k2', 2, 'val1');\n     * $redis->zAdd('k2', 3, 'val3');\n     *\n     * $redis->zInterStore('ko1', array('k1', 'k2'));               // 2, 'ko1' => array('val1', 'val3')\n     * $redis->zInterStore('ko2', array('k1', 'k2'), array(1, 1));  // 2, 'ko2' => array('val1', 'val3')\n     *\n     * // Weighted zInterStore\n     * $redis->zInterStore('ko3', array('k1', 'k2'), array(1, 5), 'min'); // 2, 'ko3' => array('val1', 'val3')\n     * $redis->zInterStore('ko4', array('k1', 'k2'), array(1, 5), 'max'); // 2, 'ko4' => array('val3', 'val1')\n     * </pre>\n     */\n    public function zInterStore($output, $zSetKeys, array $weights = null, $aggregateFunction = 'SUM')\n    {\n    }\n\n    /**\n     * @see zInterStore\n     * @deprecated use Redis::zInterStore()\n     *\n     * @param $Output\n     * @param $ZSetKeys\n     * @param array|null $Weights\n     * @param string $aggregateFunction\n     */\n    public function zInter($Output, $ZSetKeys, array $Weights = null, $aggregateFunction = 'SUM')\n    {\n    }\n\n    /**\n     * Scan a sorted set for members, with optional pattern and count\n     *\n     * @param string $key      String, the set to scan.\n     * @param int    $iterator Long (reference), initialized to NULL.\n     * @param string $pattern  String (optional), the pattern to match.\n     * @param int    $count    How many keys to return per iteration (Redis might return a different number).\n     *\n     * @return array|bool PHPRedis will return matching keys from Redis, or FALSE when iteration is complete\n     *\n     * @link    https://redis.io/commands/zscan\n     * @example\n     * <pre>\n     * $iterator = null;\n     * while ($members = $redis-zscan('zset', $iterator)) {\n     *     foreach ($members as $member => $score) {\n     *         echo $member . ' => ' . $score . PHP_EOL;\n     *     }\n     * }\n     * </pre>\n     */\n    public function zScan($key, &$iterator, $pattern = null, $count = 0)\n    {\n    }\n\n    /**\n     * Block until Redis can pop the highest or lowest scoring members from one or more ZSETs.\n     * There are two commands (BZPOPMIN and BZPOPMAX for popping the lowest and highest scoring elements respectively.)\n     *\n     * @param string|array $key1\n     * @param string|array $key2 ...\n     * @param int $timeout\n     *\n     * @return array Either an array with the key member and score of the higest or lowest element or an empty array\n     * if the timeout was reached without an element to pop.\n     *\n     * @since >= 5.0\n     * @link https://redis.io/commands/bzpopmax\n     * @example\n     * <pre>\n     * // Wait up to 5 seconds to pop the *lowest* scoring member from sets `zs1` and `zs2`.\n     * $redis->bzPopMin(['zs1', 'zs2'], 5);\n     * $redis->bzPopMin('zs1', 'zs2', 5);\n     *\n     * // Wait up to 5 seconds to pop the *highest* scoring member from sets `zs1` and `zs2`\n     * $redis->bzPopMax(['zs1', 'zs2'], 5);\n     * $redis->bzPopMax('zs1', 'zs2', 5);\n     * </pre>\n     */\n    public function bzPopMax($key1, $key2, $timeout)\n    {\n    }\n\n    /**\n     * @param string|array $key1\n     * @param string|array $key2 ...\n     * @param int $timeout\n     *\n     * @return array Either an array with the key member and score of the higest or lowest element or an empty array\n     * if the timeout was reached without an element to pop.\n     *\n     * @see bzPopMax\n     * @since >= 5.0\n     * @link https://redis.io/commands/bzpopmin\n     */\n    public function bzPopMin($key1, $key2, $timeout)\n    {\n    }\n\n    /**\n     * Adds a value to the hash stored at key. If this value is already in the hash, FALSE is returned.\n     *\n     * @param string $key\n     * @param string $hashKey\n     * @param string $value\n     *\n     * @return int|bool\n     * - 1 if value didn't exist and was added successfully,\n     * - 0 if the value was already present and was replaced, FALSE if there was an error.\n     *\n     * @link    https://redis.io/commands/hset\n     * @example\n     * <pre>\n     * $redis->del('h')\n     * $redis->hSet('h', 'key1', 'hello');  // 1, 'key1' => 'hello' in the hash at \"h\"\n     * $redis->hGet('h', 'key1');           // returns \"hello\"\n     *\n     * $redis->hSet('h', 'key1', 'plop');   // 0, value was replaced.\n     * $redis->hGet('h', 'key1');           // returns \"plop\"\n     * </pre>\n     */\n    public function hSet($key, $hashKey, $value)\n    {\n    }\n\n    /**\n     * Adds a value to the hash stored at key only if this field isn't already in the hash.\n     *\n     * @param string $key\n     * @param string $hashKey\n     * @param string $value\n     *\n     * @return  bool TRUE if the field was set, FALSE if it was already present.\n     *\n     * @link    https://redis.io/commands/hsetnx\n     * @example\n     * <pre>\n     * $redis->del('h')\n     * $redis->hSetNx('h', 'key1', 'hello'); // TRUE, 'key1' => 'hello' in the hash at \"h\"\n     * $redis->hSetNx('h', 'key1', 'world'); // FALSE, 'key1' => 'hello' in the hash at \"h\". No change since the field\n     * wasn't replaced.\n     * </pre>\n     */\n    public function hSetNx($key, $hashKey, $value)\n    {\n    }\n\n    /**\n     * Gets a value from the hash stored at key.\n     * If the hash table doesn't exist, or the key doesn't exist, FALSE is returned.\n     *\n     * @param string $key\n     * @param string $hashKey\n     *\n     * @return string The value, if the command executed successfully BOOL FALSE in case of failure\n     *\n     * @link    https://redis.io/commands/hget\n     */\n    public function hGet($key, $hashKey)\n    {\n    }\n\n    /**\n     * Returns the length of a hash, in number of items\n     *\n     * @param string $key\n     *\n     * @return int|false the number of items in a hash, FALSE if the key doesn't exist or isn't a hash\n     *\n     * @link    https://redis.io/commands/hlen\n     * @example\n     * <pre>\n     * $redis->del('h')\n     * $redis->hSet('h', 'key1', 'hello');\n     * $redis->hSet('h', 'key2', 'plop');\n     * $redis->hLen('h'); // returns 2\n     * </pre>\n     */\n    public function hLen($key)\n    {\n    }\n\n    /**\n     * Removes a values from the hash stored at key.\n     * If the hash table doesn't exist, or the key doesn't exist, FALSE is returned.\n     *\n     * @param string $key\n     * @param string $hashKey1\n     * @param string ...$otherHashKeys\n     *\n     * @return int|false Number of deleted fields\n     *\n     * @link    https://redis.io/commands/hdel\n     * @example\n     * <pre>\n     * $redis->hMSet('h',\n     *               array(\n     *                    'f1' => 'v1',\n     *                    'f2' => 'v2',\n     *                    'f3' => 'v3',\n     *                    'f4' => 'v4',\n     *               ));\n     *\n     * var_dump( $redis->hDel('h', 'f1') );        // int(1)\n     * var_dump( $redis->hDel('h', 'f2', 'f3') );  // int(2)\n     * s\n     * var_dump( $redis->hGetAll('h') );\n     * //// Output:\n     * //  array(1) {\n     * //    [\"f4\"]=> string(2) \"v4\"\n     * //  }\n     * </pre>\n     */\n    public function hDel($key, $hashKey1, ...$otherHashKeys)\n    {\n    }\n\n    /**\n     * Returns the keys in a hash, as an array of strings.\n     *\n     * @param string $key\n     *\n     * @return array An array of elements, the keys of the hash. This works like PHP's array_keys().\n     *\n     * @link    https://redis.io/commands/hkeys\n     * @example\n     * <pre>\n     * $redis->del('h');\n     * $redis->hSet('h', 'a', 'x');\n     * $redis->hSet('h', 'b', 'y');\n     * $redis->hSet('h', 'c', 'z');\n     * $redis->hSet('h', 'd', 't');\n     * var_dump($redis->hKeys('h'));\n     *\n     * // Output:\n     * // array(4) {\n     * // [0]=>\n     * // string(1) \"a\"\n     * // [1]=>\n     * // string(1) \"b\"\n     * // [2]=>\n     * // string(1) \"c\"\n     * // [3]=>\n     * // string(1) \"d\"\n     * // }\n     * // The order is random and corresponds to redis' own internal representation of the set structure.\n     * </pre>\n     */\n    public function hKeys($key)\n    {\n    }\n\n    /**\n     * Returns the values in a hash, as an array of strings.\n     *\n     * @param string $key\n     *\n     * @return array An array of elements, the values of the hash. This works like PHP's array_values().\n     *\n     * @link    https://redis.io/commands/hvals\n     * @example\n     * <pre>\n     * $redis->del('h');\n     * $redis->hSet('h', 'a', 'x');\n     * $redis->hSet('h', 'b', 'y');\n     * $redis->hSet('h', 'c', 'z');\n     * $redis->hSet('h', 'd', 't');\n     * var_dump($redis->hVals('h'));\n     *\n     * // Output\n     * // array(4) {\n     * //   [0]=>\n     * //   string(1) \"x\"\n     * //   [1]=>\n     * //   string(1) \"y\"\n     * //   [2]=>\n     * //   string(1) \"z\"\n     * //   [3]=>\n     * //   string(1) \"t\"\n     * // }\n     * // The order is random and corresponds to redis' own internal representation of the set structure.\n     * </pre>\n     */\n    public function hVals($key)\n    {\n    }\n\n    /**\n     * Returns the whole hash, as an array of strings indexed by strings.\n     *\n     * @param string $key\n     *\n     * @return array An array of elements, the contents of the hash.\n     *\n     * @link    https://redis.io/commands/hgetall\n     * @example\n     * <pre>\n     * $redis->del('h');\n     * $redis->hSet('h', 'a', 'x');\n     * $redis->hSet('h', 'b', 'y');\n     * $redis->hSet('h', 'c', 'z');\n     * $redis->hSet('h', 'd', 't');\n     * var_dump($redis->hGetAll('h'));\n     *\n     * // Output:\n     * // array(4) {\n     * //   [\"a\"]=>\n     * //   string(1) \"x\"\n     * //   [\"b\"]=>\n     * //   string(1) \"y\"\n     * //   [\"c\"]=>\n     * //   string(1) \"z\"\n     * //   [\"d\"]=>\n     * //   string(1) \"t\"\n     * // }\n     * // The order is random and corresponds to redis' own internal representation of the set structure.\n     * </pre>\n     */\n    public function hGetAll($key)\n    {\n    }\n\n    /**\n     * Verify if the specified member exists in a key.\n     *\n     * @param string $key\n     * @param string $hashKey\n     *\n     * @return bool If the member exists in the hash table, return TRUE, otherwise return FALSE.\n     *\n     * @link    https://redis.io/commands/hexists\n     * @example\n     * <pre>\n     * $redis->hSet('h', 'a', 'x');\n     * $redis->hExists('h', 'a');               //  TRUE\n     * $redis->hExists('h', 'NonExistingKey');  // FALSE\n     * </pre>\n     */\n    public function hExists($key, $hashKey)\n    {\n    }\n\n    /**\n     * Increments the value of a member from a hash by a given amount.\n     *\n     * @param string $key\n     * @param string $hashKey\n     * @param int    $value (integer) value that will be added to the member's value\n     *\n     * @return int the new value\n     *\n     * @link    https://redis.io/commands/hincrby\n     * @example\n     * <pre>\n     * $redis->del('h');\n     * $redis->hIncrBy('h', 'x', 2); // returns 2: h[x] = 2 now.\n     * $redis->hIncrBy('h', 'x', 1); // h[x] ← 2 + 1. Returns 3\n     * </pre>\n     */\n    public function hIncrBy($key, $hashKey, $value)\n    {\n    }\n\n    /**\n     * Increment the float value of a hash field by the given amount\n     *\n     * @param string $key\n     * @param string $field\n     * @param float  $increment\n     *\n     * @return float\n     *\n     * @link    https://redis.io/commands/hincrbyfloat\n     * @example\n     * <pre>\n     * $redis = new Redis();\n     * $redis->connect('127.0.0.1');\n     * $redis->hset('h', 'float', 3);\n     * $redis->hset('h', 'int',   3);\n     * var_dump( $redis->hIncrByFloat('h', 'float', 1.5) ); // float(4.5)\n     *\n     * var_dump( $redis->hGetAll('h') );\n     *\n     * // Output\n     *  array(2) {\n     *    [\"float\"]=>\n     *    string(3) \"4.5\"\n     *    [\"int\"]=>\n     *    string(1) \"3\"\n     *  }\n     * </pre>\n     */\n    public function hIncrByFloat($key, $field, $increment)\n    {\n    }\n\n    /**\n     * Fills in a whole hash. Non-string values are converted to string, using the standard (string) cast.\n     * NULL values are stored as empty strings\n     *\n     * @param string $key\n     * @param array  $hashKeys key → value array\n     *\n     * @return bool\n     *\n     * @link    https://redis.io/commands/hmset\n     * @example\n     * <pre>\n     * $redis->del('user:1');\n     * $redis->hMSet('user:1', array('name' => 'Joe', 'salary' => 2000));\n     * $redis->hIncrBy('user:1', 'salary', 100); // Joe earns 100 more now.\n     * </pre>\n     */\n    public function hMSet($key, $hashKeys)\n    {\n    }\n\n    /**\n     * Retirieve the values associated to the specified fields in the hash.\n     *\n     * @param string $key\n     * @param array  $hashKeys\n     *\n     * @return array Array An array of elements, the values of the specified fields in the hash,\n     * with the hash keys as array keys.\n     *\n     * @link    https://redis.io/commands/hmget\n     * @example\n     * <pre>\n     * $redis->del('h');\n     * $redis->hSet('h', 'field1', 'value1');\n     * $redis->hSet('h', 'field2', 'value2');\n     * $redis->hmGet('h', array('field1', 'field2')); // returns array('field1' => 'value1', 'field2' => 'value2')\n     * </pre>\n     */\n    public function hMGet($key, $hashKeys)\n    {\n    }\n\n    /**\n     * Scan a HASH value for members, with an optional pattern and count.\n     *\n     * @param string $key\n     * @param int    $iterator\n     * @param string $pattern    Optional pattern to match against.\n     * @param int    $count      How many keys to return in a go (only a sugestion to Redis).\n     *\n     * @return array An array of members that match our pattern.\n     *\n     * @link    https://redis.io/commands/hscan\n     * @example\n     * <pre>\n     * // $iterator = null;\n     * // while($elements = $redis->hscan('hash', $iterator)) {\n     * //     foreach($elements as $key => $value) {\n     * //         echo $key . ' => ' . $value . PHP_EOL;\n     * //     }\n     * // }\n     * </pre>\n     */\n    public function hScan($key, &$iterator, $pattern = null, $count = 0)\n    {\n    }\n\n    /**\n     * Get the string length of the value associated with field in the hash stored at key\n     *\n     * @param string $key\n     * @param string $field\n     *\n     * @return int the string length of the value associated with field, or zero when field is not present in the hash\n     * or key does not exist at all.\n     *\n     * @link https://redis.io/commands/hstrlen\n     * @since >= 3.2\n     */\n    public function hStrLen(string $key, string $field)\n    {\n    }\n\n    /**\n     * Add one or more geospatial items to the specified key.\n     * This function must be called with at least one longitude, latitude, member triplet.\n     *\n     * @param string $key\n     * @param float  $longitude\n     * @param float  $latitude\n     * @param string $member\n     *\n     * @return int The number of elements added to the geospatial key\n     *\n     * @link https://redis.io/commands/geoadd\n     * @since >=3.2\n     *\n     * @example\n     * <pre>\n     * $redis->del(\"myplaces\");\n     *\n     * // Since the key will be new, $result will be 2\n     * $result = $redis->geoAdd(\n     *   \"myplaces\",\n     *   -122.431, 37.773, \"San Francisco\",\n     *   -157.858, 21.315, \"Honolulu\"\n     * ); // 2\n     * </pre>\n     */\n    public function geoadd($key, $longitude, $latitude, $member)\n    {\n    }\n\n    /**\n     * Retrieve Geohash strings for one or more elements of a geospatial index.\n\n     * @param string $key\n     * @param string ...$member variadic list of members\n     *\n     * @return array One or more Redis Geohash encoded strings\n     *\n     * @link https://redis.io/commands/geohash\n     * @since >=3.2\n     *\n     * @example\n     * <pre>\n     * $redis->geoAdd(\"hawaii\", -157.858, 21.306, \"Honolulu\", -156.331, 20.798, \"Maui\");\n     * $hashes = $redis->geoHash(\"hawaii\", \"Honolulu\", \"Maui\");\n     * var_dump($hashes);\n     * // Output: array(2) {\n     * //   [0]=>\n     * //   string(11) \"87z9pyek3y0\"\n     * //   [1]=>\n     * //   string(11) \"8e8y6d5jps0\"\n     * // }\n     * </pre>\n     */\n    public function geohash($key, ...$member)\n    {\n    }\n\n    /**\n     * Return longitude, latitude positions for each requested member.\n     *\n     * @param string $key\n     * @param string $member\n     * @return array One or more longitude/latitude positions\n     *\n     * @link https://redis.io/commands/geopos\n     * @since >=3.2\n     *\n     * @example\n     * <pre>\n     * $redis->geoAdd(\"hawaii\", -157.858, 21.306, \"Honolulu\", -156.331, 20.798, \"Maui\");\n     * $positions = $redis->geoPos(\"hawaii\", \"Honolulu\", \"Maui\");\n     * var_dump($positions);\n     *\n     * // Output:\n     * array(2) {\n     *  [0]=> array(2) {\n     *      [0]=> string(22) \"-157.85800248384475708\"\n     *      [1]=> string(19) \"21.3060004581273077\"\n     *  }\n     *  [1]=> array(2) {\n     *      [0]=> string(22) \"-156.33099943399429321\"\n     *      [1]=> string(20) \"20.79799924753607598\"\n     *  }\n     * }\n     * </pre>\n     */\n    public function geopos(string $key, string $member)\n    {\n    }\n\n    /**\n     * Return the distance between two members in a geospatial set.\n     *\n     * If units are passed it must be one of the following values:\n     * - 'm' => Meters\n     * - 'km' => Kilometers\n     * - 'mi' => Miles\n     * - 'ft' => Feet\n     *\n     * @param string $key\n     * @param string $member1\n     * @param string $member2\n     * @param string|null $unit\n     *\n     * @return float The distance between the two passed members in the units requested (meters by default)\n     *\n     * @link https://redis.io/commands/geodist\n     * @since >=3.2\n     *\n     * @example\n     * <pre>\n     * $redis->geoAdd(\"hawaii\", -157.858, 21.306, \"Honolulu\", -156.331, 20.798, \"Maui\");\n     *\n     * $meters = $redis->geoDist(\"hawaii\", \"Honolulu\", \"Maui\");\n     * $kilometers = $redis->geoDist(\"hawaii\", \"Honolulu\", \"Maui\", 'km');\n     * $miles = $redis->geoDist(\"hawaii\", \"Honolulu\", \"Maui\", 'mi');\n     * $feet = $redis->geoDist(\"hawaii\", \"Honolulu\", \"Maui\", 'ft');\n     *\n     * echo \"Distance between Honolulu and Maui:\\n\";\n     * echo \"  meters    : $meters\\n\";\n     * echo \"  kilometers: $kilometers\\n\";\n     * echo \"  miles     : $miles\\n\";\n     * echo \"  feet      : $feet\\n\";\n     *\n     * // Bad unit\n     * $inches = $redis->geoDist(\"hawaii\", \"Honolulu\", \"Maui\", 'in');\n     * echo \"Invalid unit returned:\\n\";\n     * var_dump($inches);\n     *\n     * // Output\n     * Distance between Honolulu and Maui:\n     * meters    : 168275.204\n     * kilometers: 168.2752\n     * miles     : 104.5616\n     * feet      : 552084.0028\n     * Invalid unit returned:\n     * bool(false)\n     * </pre>\n     */\n    public function geodist($key, $member1, $member2, $unit = null)\n    {\n    }\n\n    /**\n     * Return members of a set with geospatial information that are within the radius specified by the caller.\n     *\n     * @param $key\n     * @param $longitude\n     * @param $latitude\n     * @param $radius\n     * @param $unit\n     * @param array|null $options\n     * <pre>\n     * |Key         |Value          |Description                                        |\n     * |------------|---------------|---------------------------------------------------|\n     * |COUNT       |integer > 0    |Limit how many results are returned                |\n     * |            |WITHCOORD      |Return longitude and latitude of matching members  |\n     * |            |WITHDIST       |Return the distance from the center                |\n     * |            |WITHHASH       |Return the raw geohash-encoded score               |\n     * |            |ASC            |Sort results in ascending order                    |\n     * |            |DESC           |Sort results in descending order                   |\n     * |STORE       |key            |Store results in key                               |\n     * |STOREDIST   |key            |Store the results as distances in key              |\n     * </pre>\n     * Note: It doesn't make sense to pass both ASC and DESC options but if both are passed\n     * the last one passed will be used.\n     * Note: When using STORE[DIST] in Redis Cluster, the store key must has to the same slot as\n     * the query key or you will get a CROSSLOT error.\n     * @return mixed When no STORE option is passed, this function returns an array of results.\n     * If it is passed this function returns the number of stored entries.\n     *\n     * @link https://redis.io/commands/georadius\n     * @since >= 3.2\n     * @example\n     * <pre>\n     * // Add some cities\n     * $redis->geoAdd(\"hawaii\", -157.858, 21.306, \"Honolulu\", -156.331, 20.798, \"Maui\");\n     *\n     * echo \"Within 300 miles of Honolulu:\\n\";\n     * var_dump($redis->geoRadius(\"hawaii\", -157.858, 21.306, 300, 'mi'));\n     *\n     * echo \"\\nWithin 300 miles of Honolulu with distances:\\n\";\n     * $options = ['WITHDIST'];\n     * var_dump($redis->geoRadius(\"hawaii\", -157.858, 21.306, 300, 'mi', $options));\n     *\n     * echo \"\\nFirst result within 300 miles of Honolulu with distances:\\n\";\n     * $options['count'] = 1;\n     * var_dump($redis->geoRadius(\"hawaii\", -157.858, 21.306, 300, 'mi', $options));\n     *\n     * echo \"\\nFirst result within 300 miles of Honolulu with distances in descending sort order:\\n\";\n     * $options[] = 'DESC';\n     * var_dump($redis->geoRadius(\"hawaii\", -157.858, 21.306, 300, 'mi', $options));\n     *\n     * // Output\n     * Within 300 miles of Honolulu:\n     * array(2) {\n     *  [0]=> string(8) \"Honolulu\"\n     *  [1]=> string(4) \"Maui\"\n     * }\n     *\n     * Within 300 miles of Honolulu with distances:\n     * array(2) {\n     *     [0]=>\n     *   array(2) {\n     *         [0]=>\n     *     string(8) \"Honolulu\"\n     *         [1]=>\n     *     string(6) \"0.0002\"\n     *   }\n     *   [1]=>\n     *   array(2) {\n     *         [0]=>\n     *     string(4) \"Maui\"\n     *         [1]=>\n     *     string(8) \"104.5615\"\n     *   }\n     * }\n     *\n     * First result within 300 miles of Honolulu with distances:\n     * array(1) {\n     *     [0]=>\n     *   array(2) {\n     *         [0]=>\n     *     string(8) \"Honolulu\"\n     *         [1]=>\n     *     string(6) \"0.0002\"\n     *   }\n     * }\n     *\n     * First result within 300 miles of Honolulu with distances in descending sort order:\n     * array(1) {\n     *     [0]=>\n     *   array(2) {\n     *         [0]=>\n     *     string(4) \"Maui\"\n     *         [1]=>\n     *     string(8) \"104.5615\"\n     *   }\n     * }\n     * </pre>\n     */\n    public function georadius($key, $longitude, $latitude, $radius, $unit, array $options = null)\n    {\n    }\n\n    /**\n     * This method is identical to geoRadius except that instead of passing a longitude and latitude as the \"source\"\n     * you pass an existing member in the geospatial set\n     *\n     * @param string $key\n     * @param string $member\n     * @param $radius\n     * @param $units\n     * @param array|null $options see georadius\n     *\n     * @return array The zero or more entries that are close enough to the member given the distance and radius specified\n     *\n     * @link https://redis.io/commands/georadiusbymember\n     * @since >= 3.2\n     * @see georadius\n     * @example\n     * <pre>\n     * $redis->geoAdd(\"hawaii\", -157.858, 21.306, \"Honolulu\", -156.331, 20.798, \"Maui\");\n     *\n     * echo \"Within 300 miles of Honolulu:\\n\";\n     * var_dump($redis->geoRadiusByMember(\"hawaii\", \"Honolulu\", 300, 'mi'));\n     *\n     * echo \"\\nFirst match within 300 miles of Honolulu:\\n\";\n     * var_dump($redis->geoRadiusByMember(\"hawaii\", \"Honolulu\", 300, 'mi', ['count' => 1]));\n     *\n     * // Output\n     * Within 300 miles of Honolulu:\n     * array(2) {\n     *  [0]=> string(8) \"Honolulu\"\n     *  [1]=> string(4) \"Maui\"\n     * }\n     *\n     * First match within 300 miles of Honolulu:\n     * array(1) {\n     *  [0]=> string(8) \"Honolulu\"\n     * }\n     * </pre>\n     */\n    public function georadiusbymember($key, $member, $radius, $units, array $options = null)\n    {\n    }\n\n    /**\n     * Get or Set the redis config keys.\n     *\n     * @param string       $operation either `GET` or `SET`\n     * @param string       $key       for `SET`, glob-pattern for `GET`\n     * @param string|mixed $value     optional string (only for `SET`)\n     *\n     * @return array Associative array for `GET`, key -> value\n     *\n     * @link    https://redis.io/commands/config-get\n     * @example\n     * <pre>\n     * $redis->config(\"GET\", \"*max-*-entries*\");\n     * $redis->config(\"SET\", \"dir\", \"/var/run/redis/dumps/\");\n     * </pre>\n     */\n    public function config($operation, $key, $value)\n    {\n    }\n\n    /**\n     * Evaluate a LUA script serverside\n     *\n     * @param string $script\n     * @param array  $args\n     * @param int    $numKeys\n     *\n     * @return mixed What is returned depends on what the LUA script itself returns, which could be a scalar value\n     * (int/string), or an array. Arrays that are returned can also contain other arrays, if that's how it was set up in\n     * your LUA script.  If there is an error executing the LUA script, the getLastError() function can tell you the\n     * message that came back from Redis (e.g. compile error).\n     *\n     * @link   https://redis.io/commands/eval\n     * @example\n     * <pre>\n     * $redis->eval(\"return 1\"); // Returns an integer: 1\n     * $redis->eval(\"return {1,2,3}\"); // Returns Array(1,2,3)\n     * $redis->del('mylist');\n     * $redis->rpush('mylist','a');\n     * $redis->rpush('mylist','b');\n     * $redis->rpush('mylist','c');\n     * // Nested response:  Array(1,2,3,Array('a','b','c'));\n     * $redis->eval(\"return {1,2,3,redis.call('lrange','mylist',0,-1)}}\");\n     * </pre>\n     */\n    public function eval($script, $args = array(), $numKeys = 0)\n    {\n    }\n\n    /**\n     * @see eval()\n     * @deprecated use Redis::eval()\n     *\n     * @param   string  $script\n     * @param   array   $args\n     * @param   int     $numKeys\n     * @return  mixed   @see eval()\n     */\n    public function evaluate($script, $args = array(), $numKeys = 0)\n    {\n    }\n\n    /**\n     * Evaluate a LUA script serverside, from the SHA1 hash of the script instead of the script itself.\n     * In order to run this command Redis will have to have already loaded the script, either by running it or via\n     * the SCRIPT LOAD command.\n     *\n     * @param string $scriptSha\n     * @param array  $args\n     * @param int    $numKeys\n     *\n     * @return mixed @see eval()\n     *\n     * @see     eval()\n     * @link    https://redis.io/commands/evalsha\n     * @example\n     * <pre>\n     * $script = 'return 1';\n     * $sha = $redis->script('load', $script);\n     * $redis->evalSha($sha); // Returns 1\n     * </pre>\n     */\n    public function evalSha($scriptSha, $args = array(), $numKeys = 0)\n    {\n    }\n\n    /**\n     * @see evalSha()\n     * @deprecated use Redis::evalSha()\n     *\n     * @param string $scriptSha\n     * @param array  $args\n     * @param int    $numKeys\n     */\n    public function evaluateSha($scriptSha, $args = array(), $numKeys = 0)\n    {\n    }\n\n    /**\n     * Execute the Redis SCRIPT command to perform various operations on the scripting subsystem.\n     * @param string $command load | flush | kill | exists\n     * @param string $script\n     *\n     * @return  mixed\n     *\n     * @link    https://redis.io/commands/script-load\n     * @link    https://redis.io/commands/script-kill\n     * @link    https://redis.io/commands/script-flush\n     * @link    https://redis.io/commands/script-exists\n     * @example\n     * <pre>\n     * $redis->script('load', $script);\n     * $redis->script('flush');\n     * $redis->script('kill');\n     * $redis->script('exists', $script1, [$script2, $script3, ...]);\n     * </pre>\n     *\n     * SCRIPT LOAD will return the SHA1 hash of the passed script on success, and FALSE on failure.\n     * SCRIPT FLUSH should always return TRUE\n     * SCRIPT KILL will return true if a script was able to be killed and false if not\n     * SCRIPT EXISTS will return an array with TRUE or FALSE for each passed script\n     */\n    public function script($command, $script)\n    {\n    }\n\n    /**\n     * The last error message (if any)\n     *\n     * @return string|null A string with the last returned script based error message, or NULL if there is no error\n     *\n     * @example\n     * <pre>\n     * $redis->eval('this-is-not-lua');\n     * $err = $redis->getLastError();\n     * // \"ERR Error compiling script (new function): user_script:1: '=' expected near '-'\"\n     * </pre>\n     */\n    public function getLastError()\n    {\n    }\n\n    /**\n     * Clear the last error message\n     *\n     * @return bool true\n     *\n     * @example\n     * <pre>\n     * $redis->set('x', 'a');\n     * $redis->incr('x');\n     * $err = $redis->getLastError();\n     * // \"ERR value is not an integer or out of range\"\n     * $redis->clearLastError();\n     * $err = $redis->getLastError();\n     * // NULL\n     * </pre>\n     */\n    public function clearLastError()\n    {\n    }\n\n    /**\n     * Issue the CLIENT command with various arguments.\n     * The Redis CLIENT command can be used in four ways:\n     * - CLIENT LIST\n     * - CLIENT GETNAME\n     * - CLIENT SETNAME [name]\n     * - CLIENT KILL [ip:port]\n     *\n     * @param string $command\n     * @param string $value\n     * @return mixed This will vary depending on which client command was executed:\n     * - CLIENT LIST will return an array of arrays with client information.\n     * - CLIENT GETNAME will return the client name or false if none has been set\n     * - CLIENT SETNAME will return true if it can be set and false if not\n     * - CLIENT KILL will return true if the client can be killed, and false if not\n     *\n     * Note: phpredis will attempt to reconnect so you can actually kill your own connection but may not notice losing it!\n     *\n     * @link https://redis.io/commands/client-list\n     * @link https://redis.io/commands/client-getname\n     * @link https://redis.io/commands/client-setname\n     * @link https://redis.io/commands/client-kill\n     *\n     * @example\n     * <pre>\n     * $redis->client('list'); // Get a list of clients\n     * $redis->client('getname'); // Get the name of the current connection\n     * $redis->client('setname', 'somename'); // Set the name of the current connection\n     * $redis->client('kill', <ip:port>); // Kill the process at ip:port\n     * </pre>\n     */\n    public function client($command, $value = '')\n    {\n    }\n\n    /**\n     * A utility method to prefix the value with the prefix setting for phpredis.\n     *\n     * @param mixed $value The value you wish to prefix\n     *\n     * @return string If a prefix is set up, the value now prefixed.\n     * If there is no prefix, the value will be returned unchanged.\n     *\n     * @example\n     * <pre>\n     * $redis->setOption(Redis::OPT_PREFIX, 'my-prefix:');\n     * $redis->_prefix('my-value'); // Will return 'my-prefix:my-value'\n     * </pre>\n     */\n    public function _prefix($value)\n    {\n    }\n\n    /**\n     * A utility method to unserialize data with whatever serializer is set up.  If there is no serializer set, the\n     * value will be returned unchanged.  If there is a serializer set up, and the data passed in is malformed, an\n     * exception will be thrown. This can be useful if phpredis is serializing values, and you return something from\n     * redis in a LUA script that is serialized.\n     *\n     * @param string $value The value to be unserialized\n     *\n     * @return mixed\n     * @example\n     * <pre>\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);\n     * $redis->_unserialize('a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}'); // Will return Array(1,2,3)\n     * </pre>\n     */\n    public function _unserialize($value)\n    {\n    }\n\n    /**\n     * A utility method to serialize values manually. This method allows you to serialize a value with whatever\n     * serializer is configured, manually. This can be useful for serialization/unserialization of data going in\n     * and out of EVAL commands as phpredis can't automatically do this itself.  Note that if no serializer is\n     * set, phpredis will change Array values to 'Array', and Objects to 'Object'.\n     *\n     * @param mixed $value The value to be serialized.\n     *\n     * @return  mixed\n     * @example\n     * <pre>\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE);\n     * $redis->_serialize(\"foo\"); // returns \"foo\"\n     * $redis->_serialize(Array()); // Returns \"Array\"\n     * $redis->_serialize(new stdClass()); // Returns \"Object\"\n     *\n     * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);\n     * $redis->_serialize(\"foo\"); // Returns 's:3:\"foo\";'\n     * </pre>\n     */\n    public function _serialize($value)\n    {\n    }\n\n    /**\n     * Dump a key out of a redis database, the value of which can later be passed into redis using the RESTORE command.\n     * The data that comes out of DUMP is a binary representation of the key as Redis stores it.\n     * @param string $key\n     *\n     * @return string|bool The Redis encoded value of the key, or FALSE if the key doesn't exist\n     *\n     * @link    https://redis.io/commands/dump\n     * @example\n     * <pre>\n     * $redis->set('foo', 'bar');\n     * $val = $redis->dump('foo'); // $val will be the Redis encoded key value\n     * </pre>\n     */\n    public function dump($key)\n    {\n    }\n\n    /**\n     * Restore a key from the result of a DUMP operation.\n     *\n     * @param string $key   The key name\n     * @param int    $ttl   How long the key should live (if zero, no expire will be set on the key)\n     * @param string $value (binary).  The Redis encoded key value (from DUMP)\n     *\n     * @return bool\n     *\n     * @link    https://redis.io/commands/restore\n     * @example\n     * <pre>\n     * $redis->set('foo', 'bar');\n     * $val = $redis->dump('foo');\n     * $redis->restore('bar', 0, $val); // The key 'bar', will now be equal to the key 'foo'\n     * </pre>\n     */\n    public function restore($key, $ttl, $value)\n    {\n    }\n\n    /**\n     * Migrates a key to a different Redis instance.\n     *\n     * @param string $host    The destination host\n     * @param int    $port    The TCP port to connect to.\n     * @param string $key     The key to migrate.\n     * @param int    $db      The target DB.\n     * @param int    $timeout The maximum amount of time given to this transfer.\n     * @param bool   $copy    Should we send the COPY flag to redis.\n     * @param bool   $replace Should we send the REPLACE flag to redis.\n     *\n     * @return bool\n     *\n     * @link    https://redis.io/commands/migrate\n     * @example\n     * <pre>\n     * $redis->migrate('backup', 6379, 'foo', 0, 3600);\n     * </pre>\n     */\n    public function migrate($host, $port, $key, $db, $timeout, $copy = false, $replace = false)\n    {\n    }\n\n    /**\n     * Return the current Redis server time.\n     *\n     * @return array If successfull, the time will come back as an associative array with element zero being the\n     * unix timestamp, and element one being microseconds.\n     *\n     * @link    https://redis.io/commands/time\n     * @example\n     * <pre>\n     * var_dump( $redis->time() );\n     * // array(2) {\n     * //   [0] => string(10) \"1342364352\"\n     * //   [1] => string(6) \"253002\"\n     * // }\n     * </pre>\n     */\n    public function time()\n    {\n    }\n\n    /**\n     * Scan the keyspace for keys\n     *\n     * @param int    $iterator Iterator, initialized to NULL.\n     * @param string $pattern  Pattern to match.\n     * @param int    $count    Count of keys per iteration (only a suggestion to Redis).\n     *\n     * @return array|bool This function will return an array of keys or FALSE if there are no more keys.\n     *\n     * @link   https://redis.io/commands/scan\n     * @example\n     * <pre>\n     * $iterator = null;\n     * while(false !== ($keys = $redis->scan($iterator))) {\n     *     foreach($keys as $key) {\n     *         echo $key . PHP_EOL;\n     *     }\n     * }\n     * </pre>\n     */\n    public function scan(&$iterator, $pattern = null, $count = 0)\n    {\n    }\n\n    /**\n     * Adds all the element arguments to the HyperLogLog data structure stored at the key.\n     *\n     * @param string $key\n     * @param array  $elements\n     *\n     * @return bool\n     *\n     * @link    https://redis.io/commands/pfadd\n     * @example $redis->pfAdd('key', array('elem1', 'elem2'))\n     */\n    public function pfAdd($key, array $elements)\n    {\n    }\n\n    /**\n     * When called with a single key, returns the approximated cardinality computed by the HyperLogLog data\n     * structure stored at the specified variable, which is 0 if the variable does not exist.\n     *\n     * @param string|array $key\n     *\n     * @return int\n     *\n     * @link    https://redis.io/commands/pfcount\n     * @example\n     * <pre>\n     * $redis->pfAdd('key1', array('elem1', 'elem2'));\n     * $redis->pfAdd('key2', array('elem3', 'elem2'));\n     * $redis->pfCount('key1'); // int(2)\n     * $redis->pfCount(array('key1', 'key2')); // int(3)\n     */\n    public function pfCount($key)\n    {\n    }\n\n    /**\n     * Merge multiple HyperLogLog values into an unique value that will approximate the cardinality\n     * of the union of the observed Sets of the source HyperLogLog structures.\n     *\n     * @param string $destKey\n     * @param array  $sourceKeys\n     *\n     * @return bool\n     *\n     * @link    https://redis.io/commands/pfmerge\n     * @example\n     * <pre>\n     * $redis->pfAdd('key1', array('elem1', 'elem2'));\n     * $redis->pfAdd('key2', array('elem3', 'elem2'));\n     * $redis->pfMerge('key3', array('key1', 'key2'));\n     * $redis->pfCount('key3'); // int(3)\n     */\n    public function pfMerge($destKey, array $sourceKeys)\n    {\n    }\n\n    /**\n     * Send arbitrary things to the redis server.\n     *\n     * @param string $command   Required command to send to the server.\n     * @param mixed  $arguments Optional variable amount of arguments to send to the server.\n     *\n     * @return mixed\n     *\n     * @example\n     * <pre>\n     * $redis->rawCommand('SET', 'key', 'value'); // bool(true)\n     * $redis->rawCommand('GET\", 'key'); // string(5) \"value\"\n     * </pre>\n     */\n    public function rawCommand($command, $arguments)\n    {\n    }\n\n    /**\n     * Detect whether we're in ATOMIC/MULTI/PIPELINE mode.\n     *\n     * @return int Either Redis::ATOMIC, Redis::MULTI or Redis::PIPELINE\n     *\n     * @example $redis->getMode();\n     */\n    public function getMode()\n    {\n    }\n\n    /**\n     * Acknowledge one or more messages on behalf of a consumer group.\n     *\n     * @param string $stream\n     * @param string $group\n     * @param array  $messages\n     *\n     * @return int The number of messages Redis reports as acknowledged.\n     *\n     * @link    https://redis.io/commands/xack\n     * @example\n     * <pre>\n     * $redis->xAck('stream', 'group1', ['1530063064286-0', '1530063064286-1']);\n     * </pre>\n     */\n    public function xAck($stream, $group, $messages)\n    {\n    }\n\n    /**\n     * Add a message to a stream\n     *\n     * @param string $key\n     * @param string $id\n     * @param array  $messages\n     * @param int    $maxLen\n     * @param bool   $isApproximate\n     *\n     * @return string The added message ID.\n     *\n     * @link    https://redis.io/commands/xadd\n     * @example\n     * <pre>\n     * $redis->xAdd('mystream', \"*\", ['field' => 'value']);\n     * $redis->xAdd('mystream', \"*\", ['field' => 'value'], 10);\n     * $redis->xAdd('mystream', \"*\", ['field' => 'value'], 10, true);\n     * </pre>\n     */\n    public function xAdd($key, $id, $messages, $maxLen = 0, $isApproximate = false)\n    {\n    }\n\n    /**\n     * Claim ownership of one or more pending messages\n     *\n     * @param string $key\n     * @param string $group\n     * @param string $consumer\n     * @param int    $minIdleTime\n     * @param array  $ids\n     * @param array  $options ['IDLE' => $value, 'TIME' => $value, 'RETRYCOUNT' => $value, 'FORCE', 'JUSTID']\n     *\n     * @return array Either an array of message IDs along with corresponding data, or just an array of IDs\n     * (if the 'JUSTID' option was passed).\n     *\n     * @link    https://redis.io/commands/xclaim\n     * @example\n     * <pre>\n     * $ids = ['1530113681011-0', '1530113681011-1', '1530113681011-2'];\n     *\n     * // Without any options\n     * $redis->xClaim('mystream', 'group1', 'myconsumer1', 0, $ids);\n     *\n     * // With options\n     * $redis->xClaim(\n     *     'mystream', 'group1', 'myconsumer2', 0, $ids,\n     *     [\n     *         'IDLE' => time() * 1000,\n     *         'RETRYCOUNT' => 5,\n     *         'FORCE',\n     *         'JUSTID'\n     *     ]\n     * );\n     * </pre>\n     */\n    public function xClaim($key, $group, $consumer, $minIdleTime, $ids, $options = [])\n    {\n    }\n\n    /**\n     * Delete one or more messages from a stream\n     *\n     * @param string $key\n     * @param array  $ids\n     *\n     * @return int The number of messages removed\n     *\n     * @link    https://redis.io/commands/xdel\n     * @example\n     * <pre>\n     * $redis->xDel('mystream', ['1530115304877-0', '1530115305731-0']);\n     * </pre>\n     */\n    public function xDel($key, $ids)\n    {\n    }\n\n    /**\n     * @param string $operation  e.g.: 'HELP', 'SETID', 'DELGROUP', 'CREATE', 'DELCONSUMER'\n     * @param string $key\n     * @param string $group\n     * @param string $msgId\n     * @param bool   $mkStream\n     *\n     * @return mixed This command returns different types depending on the specific XGROUP command executed.\n     *\n     * @link    https://redis.io/commands/xgroup\n     * @example\n     * <pre>\n     * $redis->xGroup('CREATE', 'mystream', 'mygroup', 0);\n     * $redis->xGroup('CREATE', 'mystream', 'mygroup', 0, true); // create stream\n     * $redis->xGroup('DESTROY', 'mystream', 'mygroup');\n     * </pre>\n     */\n    public function xGroup($operation, $key, $group, $msgId = '', $mkStream = false)\n    {\n    }\n\n    /**\n     * Get information about a stream or consumer groups\n     *\n     * @param string $operation  e.g.: 'CONSUMERS', 'GROUPS', 'STREAM', 'HELP'\n     * @param string $stream\n     * @param string $group\n     *\n     * @return mixed This command returns different types depending on which subcommand is used.\n     *\n     * @link    https://redis.io/commands/xinfo\n     * @example\n     * <pre>\n     * $redis->xInfo('STREAM', 'mystream');\n     * </pre>\n     */\n    public function xInfo($operation, $stream, $group)\n    {\n    }\n\n    /**\n     * Get the length of a given stream.\n     *\n     * @param string $stream\n     *\n     * @return int The number of messages in the stream.\n     *\n     * @link    https://redis.io/commands/xlen\n     * @example\n     * <pre>\n     * $redis->xLen('mystream');\n     * </pre>\n     */\n    public function xLen($stream)\n    {\n    }\n\n    /**\n     * Get information about pending messages in a given stream\n     *\n     * @param string $stream\n     * @param string $group\n     * @param string $start\n     * @param string $end\n     * @param int    $count\n     * @param string $consumer\n     *\n     * @return array Information about the pending messages, in various forms depending on\n     * the specific invocation of XPENDING.\n     *\n     * @link https://redis.io/commands/xpending\n     * @example\n     * <pre>\n     * $redis->xPending('mystream', 'mygroup');\n     * $redis->xPending('mystream', 'mygroup', '-', '+', 1, 'consumer-1');\n     * </pre>\n     */\n    public function xPending($stream, $group, $start = null, $end = null, $count = null, $consumer = null)\n    {\n    }\n\n    /**\n     * Get a range of messages from a given stream\n     *\n     * @param string $stream\n     * @param string $start\n     * @param string $end\n     * @param int    $count\n     *\n     * @return array The messages in the stream within the requested range.\n     *\n     * @link    https://redis.io/commands/xrange\n     * @example\n     * <pre>\n     * // Get everything in this stream\n     * $redis->xRange('mystream', '-', '+');\n     * // Only the first two messages\n     * $redis->xRange('mystream', '-', '+', 2);\n     * </pre>\n     */\n    public function xRange($stream, $start, $end, $count = null)\n    {\n    }\n\n    /**\n     * Read data from one or more streams and only return IDs greater than sent in the command.\n     *\n     * @param array      $streams\n     * @param int|string $count\n     * @param int|string $block\n     *\n     * @return array The messages in the stream newer than the IDs passed to Redis (if any)\n     *\n     * @link    https://redis.io/commands/xread\n     * @example\n     * <pre>\n     * $redis->xRead(['stream1' => '1535222584555-0', 'stream2' => '1535222584555-0']);\n     * </pre>\n     */\n    public function xRead($streams, $count = null, $block = null)\n    {\n    }\n\n    /**\n     * This method is similar to xRead except that it supports reading messages for a specific consumer group.\n     *\n     * @param string   $group\n     * @param string   $consumer\n     * @param array    $streams\n     * @param int|null $count\n     * @param int|null $block\n     *\n     * @return array The messages delivered to this consumer group (if any).\n     *\n     * @link    https://redis.io/commands/xreadgroup\n     * @example\n     * <pre>\n     * // Consume messages for 'mygroup', 'consumer1'\n     * $redis->xReadGroup('mygroup', 'consumer1', ['s1' => 0, 's2' => 0]);\n     * // Read a single message as 'consumer2' for up to a second until a message arrives.\n     * $redis->xReadGroup('mygroup', 'consumer2', ['s1' => 0, 's2' => 0], 1, 1000);\n     * </pre>\n     */\n    public function xReadGroup($group, $consumer, $streams, $count = null, $block = null)\n    {\n    }\n\n    /**\n     * This is identical to xRange except the results come back in reverse order.\n     * Also note that Redis reverses the order of \"start\" and \"end\".\n     *\n     * @param string $stream\n     * @param string $end\n     * @param string $start\n     * @param int    $count\n     *\n     * @return array The messages in the range specified\n     *\n     * @link    https://redis.io/commands/xrevrange\n     * @example\n     * <pre>\n     * $redis->xRevRange('mystream', '+', '-');\n     * </pre>\n     */\n    public function xRevRange($stream, $end, $start, $count = null)\n    {\n    }\n\n    /**\n     * Trim the stream length to a given maximum.\n     * If the \"approximate\" flag is pasesed, Redis will use your size as a hint but only trim trees in whole nodes\n     * (this is more efficient)\n     *\n     * @param string $stream\n     * @param int    $maxLen\n     * @param bool   $isApproximate\n     *\n     * @return int The number of messages trimed from the stream.\n     *\n     * @link    https://redis.io/commands/xtrim\n     * @example\n     * <pre>\n     * // Trim to exactly 100 messages\n     * $redis->xTrim('mystream', 100);\n     * // Let Redis approximate the trimming\n     * $redis->xTrim('mystream', 100, true);\n     * </pre>\n     */\n    public function xTrim($stream, $maxLen, $isApproximate)\n    {\n    }\n\n    /**\n     * Adds a values to the set value stored at key.\n     *\n     * @param string $key Required key\n     * @param array  $values Required values\n     *\n     * @return  int|bool The number of elements added to the set.\n     * If this value is already in the set, FALSE is returned\n     *\n     * @link    https://redis.io/commands/sadd\n     * @link    https://github.com/phpredis/phpredis/commit/3491b188e0022f75b938738f7542603c7aae9077\n     * @since   phpredis 2.2.8\n     * @example\n     * <pre>\n     * $redis->sAddArray('k', array('v1'));                // boolean\n     * $redis->sAddArray('k', array('v1', 'v2', 'v3'));    // boolean\n     * </pre>\n     */\n    public function sAddArray($key, array $values)\n    {\n    }\n}\n\nclass RedisException extends Exception\n{\n}\n\n/**\n * @mixin \\Redis\n */\nclass RedisArray\n{\n    /**\n     * Constructor\n     *\n     * @param string|array $hosts Name of the redis array from redis.ini or array of hosts to construct the array with\n     * @param array        $opts  Array of options\n     *\n     * @link    https://github.com/nicolasff/phpredis/blob/master/arrays.markdown\n     */\n    public function __construct($hosts, array $opts = null)\n    {\n    }\n\n    /**\n     * @return array list of hosts for the selected array\n     */\n    public function _hosts()\n    {\n    }\n\n    /**\n     * @return string the name of the function used to extract key parts during consistent hashing\n     */\n    public function _function()\n    {\n    }\n\n    /**\n     * @param string $key The key for which you want to lookup the host\n     *\n     * @return  string  the host to be used for a certain key\n     */\n    public function _target($key)\n    {\n    }\n\n    /**\n     * Use this function when a new node is added and keys need to be rehashed.\n     */\n    public function _rehash()\n    {\n    }\n\n    /**\n     * Returns an associative array of strings and integers, with the following keys:\n     * - redis_version\n     * - redis_git_sha1\n     * - redis_git_dirty\n     * - redis_build_id\n     * - redis_mode\n     * - os\n     * - arch_bits\n     * - multiplexing_api\n     * - atomicvar_api\n     * - gcc_version\n     * - process_id\n     * - run_id\n     * - tcp_port\n     * - uptime_in_seconds\n     * - uptime_in_days\n     * - hz\n     * - lru_clock\n     * - executable\n     * - config_file\n     * - connected_clients\n     * - client_longest_output_list\n     * - client_biggest_input_buf\n     * - blocked_clients\n     * - used_memory\n     * - used_memory_human\n     * - used_memory_rss\n     * - used_memory_rss_human\n     * - used_memory_peak\n     * - used_memory_peak_human\n     * - used_memory_peak_perc\n     * - used_memory_peak\n     * - used_memory_overhead\n     * - used_memory_startup\n     * - used_memory_dataset\n     * - used_memory_dataset_perc\n     * - total_system_memory\n     * - total_system_memory_human\n     * - used_memory_lua\n     * - used_memory_lua_human\n     * - maxmemory\n     * - maxmemory_human\n     * - maxmemory_policy\n     * - mem_fragmentation_ratio\n     * - mem_allocator\n     * - active_defrag_running\n     * - lazyfree_pending_objects\n     * - mem_fragmentation_ratio\n     * - loading\n     * - rdb_changes_since_last_save\n     * - rdb_bgsave_in_progress\n     * - rdb_last_save_time\n     * - rdb_last_bgsave_status\n     * - rdb_last_bgsave_time_sec\n     * - rdb_current_bgsave_time_sec\n     * - rdb_last_cow_size\n     * - aof_enabled\n     * - aof_rewrite_in_progress\n     * - aof_rewrite_scheduled\n     * - aof_last_rewrite_time_sec\n     * - aof_current_rewrite_time_sec\n     * - aof_last_bgrewrite_status\n     * - aof_last_write_status\n     * - aof_last_cow_size\n     * - changes_since_last_save\n     * - aof_current_size\n     * - aof_base_size\n     * - aof_pending_rewrite\n     * - aof_buffer_length\n     * - aof_rewrite_buffer_length\n     * - aof_pending_bio_fsync\n     * - aof_delayed_fsync\n     * - loading_start_time\n     * - loading_total_bytes\n     * - loading_loaded_bytes\n     * - loading_loaded_perc\n     * - loading_eta_seconds\n     * - total_connections_received\n     * - total_commands_processed\n     * - instantaneous_ops_per_sec\n     * - total_net_input_bytes\n     * - total_net_output_bytes\n     * - instantaneous_input_kbps\n     * - instantaneous_output_kbps\n     * - rejected_connections\n     * - maxclients\n     * - sync_full\n     * - sync_partial_ok\n     * - sync_partial_err\n     * - expired_keys\n     * - evicted_keys\n     * - keyspace_hits\n     * - keyspace_misses\n     * - pubsub_channels\n     * - pubsub_patterns\n     * - latest_fork_usec\n     * - migrate_cached_sockets\n     * - slave_expires_tracked_keys\n     * - active_defrag_hits\n     * - active_defrag_misses\n     * - active_defrag_key_hits\n     * - active_defrag_key_misses\n     * - role\n     * - master_replid\n     * - master_replid2\n     * - master_repl_offset\n     * - second_repl_offset\n     * - repl_backlog_active\n     * - repl_backlog_size\n     * - repl_backlog_first_byte_offset\n     * - repl_backlog_histlen\n     * - master_host\n     * - master_port\n     * - master_link_status\n     * - master_last_io_seconds_ago\n     * - master_sync_in_progress\n     * - slave_repl_offset\n     * - slave_priority\n     * - slave_read_only\n     * - master_sync_left_bytes\n     * - master_sync_last_io_seconds_ago\n     * - master_link_down_since_seconds\n     * - connected_slaves\n     * - min-slaves-to-write\n     * - min-replicas-to-write\n     * - min_slaves_good_slaves\n     * - used_cpu_sys\n     * - used_cpu_user\n     * - used_cpu_sys_children\n     * - used_cpu_user_children\n     * - cluster_enabled\n     *\n     * @link    https://redis.io/commands/info\n     * @return  array\n     * @example\n     * <pre>\n     * $redis->info();\n     * </pre>\n     */\n    public function info() {\n    }\n}\n"
  },
  {
    "path": ".phan/internal_stubs/memcache.phan_php",
    "content": "<?php\n\n// Start of memcache v.3.0.8\n\nclass MemcachePool  {\n\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Open memcached server connection\n     * @link https://php.net/manual/en/memcache.connect.php\n     * @param string $host <p>\n     * Point to the host where memcached is listening for connections. This parameter\n     * may also specify other transports like <em>unix:///path/to/memcached.sock</em>\n     * to use UNIX domain sockets, in this case <b>port</b> must also\n     * be set to <em>0</em>.\n     * </p>\n     * @param int $port [optional] <p>\n     * Point to the port where memcached is listening for connections. Set this\n     * parameter to <em>0</em> when using UNIX domain sockets.\n     * </p>\n     * <p>\n     * Please note: <b>port</b> defaults to\n     * {@link https://php.net/manual/ru/memcache.ini.php#ini.memcache.default-port memcache.default_port}\n     * if not specified. For this reason it is wise to specify the port\n     * explicitly in this method call.\n     * </p>\n     * @param int $timeout [optional] <p>Value in seconds which will be used for connecting to the daemon. Think twice before changing the default value of 1 second - you can lose all the advantages of caching if your connection is too slow.</p>\n     * @return bool <p>Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.</p>\n     */\n    public function connect ($host, $port, $timeout = 1) {}\n\n    /**\n     * (PECL memcache &gt;= 2.0.0)<br/>\n     * Add a memcached server to connection pool\n     * @link https://php.net/manual/en/memcache.addserver.php\n     * @param string $host <p>\n     * Point to the host where memcached is listening for connections. This parameter\n     * may also specify other transports like unix:///path/to/memcached.sock\n     * to use UNIX domain sockets, in this case <i>port</i> must also\n     * be set to 0.\n     * </p>\n     * @param int $port [optional] <p>\n     * Point to the port where memcached is listening for connections.\n     * Set this\n     * parameter to 0 when using UNIX domain sockets.\n     * </p>\n     * <p>\n     * Please note: <i>port</i> defaults to\n     * memcache.default_port\n     * if not specified. For this reason it is wise to specify the port\n     * explicitly in this method call.\n     * </p>\n     * @param bool $persistent [optional] <p>\n     * Controls the use of a persistent connection. Default to <b>TRUE</b>.\n     * </p>\n     * @param int $weight [optional] <p>\n     * Number of buckets to create for this server which in turn control its\n     * probability of it being selected. The probability is relative to the\n     * total weight of all servers.\n     * </p>\n     * @param int $timeout [optional] <p>\n     * Value in seconds which will be used for connecting to the daemon. Think\n     * twice before changing the default value of 1 second - you can lose all\n     * the advantages of caching if your connection is too slow.\n     * </p>\n     * @param int $retry_interval [optional] <p>\n     * Controls how often a failed server will be retried, the default value\n     * is 15 seconds. Setting this parameter to -1 disables automatic retry.\n     * Neither this nor the <i>persistent</i> parameter has any\n     * effect when the extension is loaded dynamically via <b>dl</b>.\n     * </p>\n     * <p>\n     * Each failed connection struct has its own timeout and before it has expired\n     * the struct will be skipped when selecting backends to serve a request. Once\n     * expired the connection will be successfully reconnected or marked as failed\n     * for another <i>retry_interval</i> seconds. The typical\n     * effect is that each web server child will retry the connection about every\n     * <i>retry_interval</i> seconds when serving a page.\n     * </p>\n     * @param bool $status [optional] <p>\n     * Controls if the server should be flagged as online. Setting this parameter\n     * to <b>FALSE</b> and <i>retry_interval</i> to -1 allows a failed\n     * server to be kept in the pool so as not to affect the key distribution\n     * algorithm. Requests for this server will then failover or fail immediately\n     * depending on the <i>memcache.allow_failover</i> setting.\n     * Default to <b>TRUE</b>, meaning the server should be considered online.\n     * </p>\n     * @param callable $failure_callback [optional] <p>\n     * Allows the user to specify a callback function to run upon encountering an\n     * error. The callback is run before failover is attempted. The function takes\n     * two parameters, the hostname and port of the failed server.\n     * </p>\n     * @param int $timeoutms [optional] <p>\n     * </p>\n     * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n     */\n    public function addServer ($host, $port = 11211, $persistent = true, $weight = null, $timeout = 1, $retry_interval = 15, $status = true, callable $failure_callback = null, $timeoutms = null) {}\n\n    /**\n     * (PECL memcache &gt;= 2.1.0)<br/>\n     * Changes server parameters and status at runtime\n     * @link https://secure.php.net/manual/en/memcache.setserverparams.php\n     * @param string $host <p>Point to the host where memcached is listening for connections.</p.\n     * @param int $port [optional] <p>\n     * Point to the port where memcached is listening for connections.\n     * </p>\n     * @param int $timeout [optional] <p>\n     * Value in seconds which will be used for connecting to the daemon. Think twice before changing the default value of 1 second - you can lose all the advantages of caching if your connection is too slow.\n     * </p>\n     * @param int $retry_interval [optional] <p>\n     * Controls how often a failed server will be retried, the default value\n     * is 15 seconds. Setting this parameter to -1 disables automatic retry.\n     * Neither this nor the <b>persistent</b> parameter has any\n     * effect when the extension is loaded dynamically via {@link https://secure.php.net/manual/en/function.dl.php dl()}.\n     * </p>\n     * @param bool $status [optional] <p>\n     * Controls if the server should be flagged as online. Setting this parameter\n     * to <b>FALSE</b> and <b>retry_interval</b> to -1 allows a failed\n     * server to be kept in the pool so as not to affect the key distribution\n     * algorithm. Requests for this server will then failover or fail immediately\n     * depending on the <b>memcache.allow_failover</b> setting.\n     * Default to <b>TRUE</b>, meaning the server should be considered online.\n     * </p>\n     * @param callable $failure_callback [optional] <p>\n     * Allows the user to specify a callback function to run upon encountering an error. The callback is run before failover is attempted.\n     * The function takes two parameters, the hostname and port of the failed server.\n     * </p>\n     * @return bool <p>Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.</p>\n     */\n    public function setServerParams ($host, $port = 11211, $timeout = 1, $retry_interval = 15, $status = true, callable $failure_callback = null) {}\n\n    /**\n     *\n     */\n    public function setFailureCallback () {}\n\n    /**\n     * (PECL memcache &gt;= 2.1.0)<br/>\n     * Returns server status\n     * @link https://php.net/manual/en/memcache.getserverstatus.php\n     * @param string $host Point to the host where memcached is listening for connections.\n     * @param int $port Point to the port where memcached is listening for connections.\n     * @return int Returns a the servers status. 0 if server is failed, non-zero otherwise\n     */\n    public function getServerStatus ($host, $port = 11211) {}\n\n    /**\n     *\n     */\n    public function findServer () {}\n\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Return version of the server\n     * @link https://php.net/manual/en/memcache.getversion.php\n     * @return string|false Returns a string of server version number or <b>FALSE</b> on failure.\n     */\n    public function getVersion () {}\n\n    /**\n     * (PECL memcache &gt;= 2.0.0)<br/>\n     * Add an item to the server. If the key already exists, the value will not be added and <b>FALSE</b> will be returned.\n     * @link https://php.net/manual/en/memcache.add.php\n     * @param string $key The key that will be associated with the item.\n     * @param mixed $var The variable to store. Strings and integers are stored as is, other types are stored serialized.\n     * @param int $flag [optional] <p>\n     * Use <b>MEMCACHE_COMPRESSED</b> to store the item\n     * compressed (uses zlib).\n     * </p>\n     * @param int $expire [optional] <p>Expiration time of the item.\n     * If it's equal to zero, the item will never expire.\n     * You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days).</p>\n     * @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure. Returns <b>FALSE</b> if such key already exist. For the rest Memcache::add() behaves similarly to Memcache::set().\n     */\n    public function add ($key , $var, $flag = null, $expire = null) {}\n\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Stores an item var with key on the memcached server. Parameter expire is expiration time in seconds.\n     * If it's 0, the item never expires (but memcached server doesn't guarantee this item to be stored all the time,\n     * it could be deleted from the cache to make place for other items).\n     * You can use MEMCACHE_COMPRESSED constant as flag value if you want to use on-the-fly compression (uses zlib).\n     * @link https://php.net/manual/en/memcache.set.php\n     * @param string $key The key that will be associated with the item.\n     * @param mixed $var The variable to store. Strings and integers are stored as is, other types are stored serialized.\n     * @param int $flag [optional] Use MEMCACHE_COMPRESSED to store the item compressed (uses zlib).\n     * @param int $expire [optional] Expiration time of the item. If it's equal to zero, the item will never expire. You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days).\n     * @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.\n     */\n    public function set ($key, $var, $flag = null, $expire = null) {}\n\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Replace value of the existing item\n     * @link https://php.net/manual/en/memcache.replace.php\n     * @param string $key <p>The key that will be associated with the item.</p>\n     * @param mixed $var <p>The variable to store. Strings and integers are stored as is, other types are stored serialized.</p>\n     * @param int $flag [optional] <p>Use <b>MEMCACHE_COMPRESSED</b> to store the item compressed (uses zlib).</p>\n     * @param int $expire [optional] <p>Expiration time of the item. If it's equal to zero, the item will never expire. You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days).</p>\n     * @return bool Returns TRUE on success or FALSE on failure.\n     */\n    public function replace ($key,  $var, $flag = null, $expire = null) {}\n\n\tpublic function cas () {}\n\n\tpublic function append () {}\n\n    /**\n     * @return string\n     */\n    public function prepend () {}\n\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Retrieve item from the server\n     * @link https://php.net/manual/en/memcache.get.php\n     * @param string|array $key <p>\n     * The key or array of keys to fetch.\n     * </p>\n     * @param int|array $flags [optional] <p>\n     * If present, flags fetched along with the values will be written to this parameter. These\n     * flags are the same as the ones given to for example {@link https://php.net/manual/en/memcache.set.php Memcache::set()}.\n     * The lowest byte of the int is reserved for pecl/memcache internal usage (e.g. to indicate\n     * compression and serialization status).\n     * </p>\n     * @return string|array|false <p>\n     * Returns the string associated with the <b>key</b> or\n     * an array of found key-value pairs when <b>key</b> is an {@link https://php.net/manual/en/language.types.array.php array}.\n     * Returns <b>FALSE</b> on failure, <b>key</b> is not found or\n     * <b>key</b> is an empty {@link https://php.net/manual/en/language.types.array.php array}.\n     * </p>\n     */\n    public function get ($key, &$flags = null) {}\n\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Delete item from the server\n     * https://secure.php.net/manual/ru/memcache.delete.php\n     * @param $key string The key associated with the item to delete.\n     * @param $timeout int [optional] This deprecated parameter is not supported, and defaults to 0 seconds. Do not use this parameter.\n     * @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.\n     */\n    public function delete ($key, $timeout = 0 ) {}\n\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Get statistics of the server\n     * @link https://php.net/manual/ru/memcache.getstats.php\n     * @param string $type [optional] <p>\n     * The type of statistics to fetch.\n     * Valid values are {reset, malloc, maps, cachedump, slabs, items, sizes}.\n     * According to the memcached protocol spec these additional arguments \"are subject to change for the convenience of memcache developers\".</p>\n     * @param int $slabid [optional] <p>\n     * Used in conjunction with <b>type</b> set to\n     * cachedump to identify the slab to dump from. The cachedump\n     * command ties up the server and is strictly to be used for\n     * debugging purposes.\n     * </p>\n     * @param int $limit [optional] <p>\n     * Used in conjunction with <b>type</b> set to cachedump to limit the number of entries to dump.\n     * </p>\n     * @return array|false Returns an associative array of server statistics or <b>FALSE</b> on failure.\n     */\n    public function getStats ($type = null, $slabid = null, $limit = 100) {}\n\n    /**\n     * (PECL memcache &gt;= 2.0.0)<br/>\n     * Get statistics from all servers in pool\n     * @link https://php.net/manual/en/memcache.getextendedstats.php\n     * @param string $type [optional] <p>The type of statistics to fetch. Valid values are {reset, malloc, maps, cachedump, slabs, items, sizes}. According to the memcached protocol spec these additional arguments \"are subject to change for the convenience of memcache developers\".</p>\n     * @param int $slabid [optional] <p>\n     * Used in conjunction with <b>type</b> set to\n     * cachedump to identify the slab to dump from. The cachedump\n     * command ties up the server and is strictly to be used for\n     * debugging purposes.\n     * </p>\n     * @param int $limit Used in conjunction with type set to cachedump to limit the number of entries to dump.\n     * @return array|false Returns a two-dimensional associative array of server statistics or <b>FALSE</b>\n     * Returns a two-dimensional associative array of server statistics or <b>FALSE</b>\n     * on failure.\n     */\n    public function getExtendedStats ($type = null, $slabid = null, $limit = 100) {}\n\n    /**\n     * (PECL memcache &gt;= 2.0.0)<br/>\n     * Enable automatic compression of large values\n     * @link https://php.net/manual/en/memcache.setcompressthreshold.php\n     * @param int $thresold <p>Controls the minimum value length before attempting to compress automatically.</p>\n     * @param float $min_saving [optional] <p>Specifies the minimum amount of savings to actually store the value compressed. The supplied value must be between 0 and 1. Default value is 0.2 giving a minimum 20% compression savings.</p>\n     * @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.\n     */\n    public function setCompressThreshold ($thresold, $min_saving = 0.2) {}\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Increment item's value\n     * @link https://php.net/manual/en/memcache.increment.php\n     * @param $key string Key of the item to increment.\n     * @param $value int [optional] increment the item by <b>value</b>\n     * @return int|false Returns new items value on success or <b>FALSE</b> on failure.\n     */\n    public function increment ($key, $value = 1) {}\n\n    /**\n     * (PECL memcache &gt;= 0.2.0)<br/>\n     * Decrement item's value\n     * @link https://php.net/manual/en/memcache.decrement.php\n     * @param $key string Key of the item do decrement.\n     * @param $value int Decrement the item by <b>value</b>.\n     * @return int|false Returns item's new value on success or <b>FALSE</b> on failure.\n     */\n    public function decrement ($key, $value = 1) {}\n\n    /**\n     * (PECL memcache &gt;= 0.4.0)<br/>\n     * Close memcached server connection\n     * @link https://php.net/manual/en/memcache.close.php\n     * @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.\n     */\n    public function close () {}\n\n    /**\n     * (PECL memcache &gt;= 1.0.0)<br/>\n     * Flush all existing items at the server\n     * @link https://php.net/manual/en/memcache.flush.php\n     * @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.\n     */\n    public function flush () {}\n\n}\n\n/**\n * Represents a connection to a set of memcache servers.\n * @link https://php.net/manual/en/class.memcache.php\n */\nclass Memcache extends MemcachePool  {\n\n\n\t/**\n\t * (PECL memcache &gt;= 0.4.0)<br/>\n\t * Open memcached server persistent connection\n\t * @link https://php.net/manual/en/memcache.pconnect.php\n\t * @param string $host <p>\n\t * Point to the host where memcached is listening for connections. This parameter\n\t * may also specify other transports like unix:///path/to/memcached.sock\n\t * to use UNIX domain sockets, in this case <i>port</i> must also\n\t * be set to 0.\n\t * </p>\n\t * @param int $port [optional] <p>\n\t * Point to the port where memcached is listening for connections. Set this\n\t * parameter to 0 when using UNIX domain sockets.\n\t * </p>\n\t * @param int $timeout [optional] <p>\n\t * Value in seconds which will be used for connecting to the daemon. Think\n\t * twice before changing the default value of 1 second - you can lose all\n\t * the advantages of caching if your connection is too slow.\n\t * </p>\n\t * @return mixed a Memcache object or <b>FALSE</b> on failure.\n\t */\n\tpublic function pconnect ($host, $port, $timeout = 1) {}\n}\n\n//  string $host [, int $port [, int $timeout ]]\n\n/**\n * (PECL memcache >= 0.2.0)</br>\n * Memcache::connect — Open memcached server connection\n * @link https://php.net/manual/en/memcache.connect.php\n * @param string $host <p>\n * Point to the host where memcached is listening for connections.\n * This parameter may also specify other transports like\n * unix:///path/to/memcached.sock to use UNIX domain sockets,\n * in this case port must also be set to 0.\n * </p>\n * @param int $port [optional] <p>\n * Point to the port where memcached is listening for connections.\n * Set this parameter to 0 when using UNIX domain sockets.\n * Note:  port defaults to memcache.default_port if not specified.\n * For this reason it is wise to specify the port explicitly in this method call.\n * </p>\n * @param int $timeout [optional] <p>\n * Value in seconds which will be used for connecting to the daemon.\n * </p>\n * @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.\n */\nfunction memcache_connect ($host, $port, $timeout = 1) {}\n\n/**\n * (PECL memcache >= 0.4.0)\n * Memcache::pconnect — Open memcached server persistent connection\n * \n * @link https://php.net/manual/en/memcache.pconnect.php#example-5242\n * @param      $host\n * @param null $port\n * @param int  $timeout\n * @return Memcache\n */\nfunction memcache_pconnect ($host, $port=null, $timeout=1) {}\n\nfunction memcache_add_server () {}\n\nfunction memcache_set_server_params () {}\n\nfunction memcache_set_failure_callback () {}\n\nfunction memcache_get_server_status () {}\n\nfunction memcache_get_version () {}\n\nfunction memcache_add () {}\n\nfunction memcache_set () {}\n\nfunction memcache_replace () {}\n\nfunction memcache_cas () {}\n\nfunction memcache_append () {}\n\nfunction memcache_prepend () {}\n\nfunction memcache_get () {}\n\nfunction memcache_delete () {}\n\n/**\n * (PECL memcache &gt;= 0.2.0)<br/>\n * Turn debug output on/off\n * @link https://php.net/manual/en/function.memcache-debug.php\n * @param bool $on_off <p>\n * Turns debug output on if equals to <b>TRUE</b>.\n * Turns debug output off if equals to <b>FALSE</b>.\n * </p>\n * @return bool <b>TRUE</b> if PHP was built with --enable-debug option, otherwise\n * returns <b>FALSE</b>.\n */\nfunction memcache_debug ($on_off) {}\n\nfunction memcache_get_stats () {}\n\nfunction memcache_get_extended_stats () {}\n\nfunction memcache_set_compress_threshold () {}\n\nfunction memcache_increment () {}\n\nfunction memcache_decrement () {}\n\nfunction memcache_close () {}\n\nfunction memcache_flush () {}\n\ndefine ('MEMCACHE_COMPRESSED', 2);\ndefine ('MEMCACHE_USER1', 65536);\ndefine ('MEMCACHE_USER2', 131072);\ndefine ('MEMCACHE_USER3', 262144);\ndefine ('MEMCACHE_USER4', 524288);\ndefine ('MEMCACHE_HAVE_SESSION', 1);\n\n// End of memcache v.3.0.8\n?>\n"
  },
  {
    "path": ".phan/internal_stubs/memcached.phan_php",
    "content": "<?php\n\n// Start of memcached v.3.0.4\n\n/**\n * Represents a connection to a set of memcached servers.\n * @link https://php.net/manual/en/class.memcached.php\n */\nclass Memcached  {\n\n\t/**\n\t * <p>Enables or disables payload compression. When enabled,\n\t * item values longer than a certain threshold (currently 100 bytes) will be\n\t * compressed during storage and decompressed during retrieval\n\t * transparently.</p>\n\t * <p>Type: boolean, default: <b>TRUE</b>.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_COMPRESSION = -1001;\n\tconst OPT_COMPRESSION_TYPE = -1004;\n\n\t/**\n\t * <p>This can be used to create a \"domain\" for your item keys. The value\n\t * specified here will be prefixed to each of the keys. It cannot be\n\t * longer than 128 characters and will reduce the\n\t * maximum available key size. The prefix is applied only to the item keys,\n\t * not to the server keys.</p>\n\t * <p>Type: string, default: \"\".</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_PREFIX_KEY = -1002;\n\n\t/**\n\t * <p>\n\t * Specifies the serializer to use for serializing non-scalar values.\n\t * The valid serializers are <b>Memcached::SERIALIZER_PHP</b>\n\t * or <b>Memcached::SERIALIZER_IGBINARY</b>. The latter is\n\t * supported only when memcached is configured with\n\t * --enable-memcached-igbinary option and the\n\t * igbinary extension is loaded.\n\t * </p>\n\t * <p>Type: integer, default: <b>Memcached::SERIALIZER_PHP</b>.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_SERIALIZER = -1003;\n\n\t/**\n\t * <p>Indicates whether igbinary serializer support is available.</p>\n\t * <p>Type: boolean.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HAVE_IGBINARY = 0;\n\n\t/**\n\t * <p>Indicates whether JSON serializer support is available.</p>\n\t * <p>Type: boolean.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HAVE_JSON = 0;\n\tconst HAVE_SESSION = 1;\n\tconst HAVE_SASL = 0;\n\n\t/**\n\t * <p>Specifies the hashing algorithm used for the item keys. The valid\n\t * values are supplied via <b>Memcached::HASH_*</b> constants.\n\t * Each hash algorithm has its advantages and its disadvantages. Go with the\n\t * default if you don't know or don't care.</p>\n\t * <p>Type: integer, default: <b>Memcached::HASH_DEFAULT</b></p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_HASH = 2;\n\n\t/**\n\t * <p>The default (Jenkins one-at-a-time) item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_DEFAULT = 0;\n\n\t/**\n\t * <p>MD5 item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_MD5 = 1;\n\n\t/**\n\t * <p>CRC item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_CRC = 2;\n\n\t/**\n\t * <p>FNV1_64 item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_FNV1_64 = 3;\n\n\t/**\n\t * <p>FNV1_64A item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_FNV1A_64 = 4;\n\n\t/**\n\t * <p>FNV1_32 item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_FNV1_32 = 5;\n\n\t/**\n\t * <p>FNV1_32A item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_FNV1A_32 = 6;\n\n\t/**\n\t * <p>Hsieh item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_HSIEH = 7;\n\n\t/**\n\t * <p>Murmur item key hashing algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst HASH_MURMUR = 8;\n\n\t/**\n\t * <p>Specifies the method of distributing item keys to the servers.\n\t * Currently supported methods are modulo and consistent hashing. Consistent\n\t * hashing delivers better distribution and allows servers to be added to\n\t * the cluster with minimal cache losses.</p>\n\t * <p>Type: integer, default: <b>Memcached::DISTRIBUTION_MODULA.</b></p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_DISTRIBUTION = 9;\n\n\t/**\n\t * <p>Modulo-based key distribution algorithm.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst DISTRIBUTION_MODULA = 0;\n\n\t/**\n\t * <p>Consistent hashing key distribution algorithm (based on libketama).</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst DISTRIBUTION_CONSISTENT = 1;\n\tconst DISTRIBUTION_VIRTUAL_BUCKET = 6;\n\n\t/**\n\t * <p>Enables or disables compatibility with libketama-like behavior. When\n\t * enabled, the item key hashing algorithm is set to MD5 and distribution is\n\t * set to be weighted consistent hashing distribution. This is useful\n\t * because other libketama-based clients (Python, Ruby, etc.) with the same\n\t * server configuration will be able to access the keys transparently.\n\t * </p>\n\t * <p>\n\t * It is highly recommended to enable this option if you want to use\n\t * consistent hashing, and it may be enabled by default in future\n\t * releases.\n\t * </p>\n\t * <p>Type: boolean, default: <b>FALSE</b>.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_LIBKETAMA_COMPATIBLE = 16;\n\tconst OPT_LIBKETAMA_HASH = 17;\n\tconst OPT_TCP_KEEPALIVE = 32;\n\n\t/**\n\t * <p>Enables or disables buffered I/O. Enabling buffered I/O causes\n\t * storage commands to \"buffer\" instead of being sent. Any action that\n\t * retrieves data causes this buffer to be sent to the remote connection.\n\t * Quitting the connection or closing down the connection will also cause\n\t * the buffered data to be pushed to the remote connection.</p>\n\t * <p>Type: boolean, default: <b>FALSE</b>.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_BUFFER_WRITES = 10;\n\n\t/**\n\t * <p>Enable the use of the binary protocol. Please note that you cannot\n\t * toggle this option on an open connection.</p>\n\t * <p>Type: boolean, default: <b>FALSE</b>.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_BINARY_PROTOCOL = 18;\n\n\t/**\n\t * <p>Enables or disables asynchronous I/O. This is the fastest transport\n\t * available for storage functions.</p>\n\t * <p>Type: boolean, default: <b>FALSE</b>.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_NO_BLOCK = 0;\n\n\t/**\n\t * <p>Enables or disables the no-delay feature for connecting sockets (may\n\t * be faster in some environments).</p>\n\t * <p>Type: boolean, default: <b>FALSE</b>.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_TCP_NODELAY = 1;\n\n\t/**\n\t * <p>The maximum socket send buffer in bytes.</p>\n\t * <p>Type: integer, default: varies by platform/kernel\n\t * configuration.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_SOCKET_SEND_SIZE = 4;\n\n\t/**\n\t * <p>The maximum socket receive buffer in bytes.</p>\n\t * <p>Type: integer, default: varies by platform/kernel\n\t * configuration.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_SOCKET_RECV_SIZE = 5;\n\n\t/**\n\t * <p>In non-blocking mode this set the value of the timeout during socket\n\t * connection, in milliseconds.</p>\n\t * <p>Type: integer, default: 1000.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_CONNECT_TIMEOUT = 14;\n\n\t/**\n\t * <p>The amount of time, in seconds, to wait until retrying a failed\n\t * connection attempt.</p>\n\t * <p>Type: integer, default: 0.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_RETRY_TIMEOUT = 15;\n\n\t/**\n\t * <p>Socket sending timeout, in microseconds. In cases where you cannot\n\t * use non-blocking I/O this will allow you to still have timeouts on the\n\t * sending of data.</p>\n\t * <p>Type: integer, default: 0.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_SEND_TIMEOUT = 19;\n\n\t/**\n\t * <p>Socket reading timeout, in microseconds. In cases where you cannot\n\t * use non-blocking I/O this will allow you to still have timeouts on the\n\t * reading of data.</p>\n\t * <p>Type: integer, default: 0.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_RECV_TIMEOUT = 20;\n\n\t/**\n\t * <p>Timeout for connection polling, in milliseconds.</p>\n\t * <p>Type: integer, default: 1000.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_POLL_TIMEOUT = 8;\n\n\t/**\n\t * <p>Enables or disables caching of DNS lookups.</p>\n\t * <p>Type: boolean, default: <b>FALSE</b>.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_CACHE_LOOKUPS = 6;\n\n\t/**\n\t * <p>Specifies the failure limit for server connection attempts. The\n\t * server will be removed after this many continuous connection\n\t * failures.</p>\n\t * <p>Type: integer, default: 0.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst OPT_SERVER_FAILURE_LIMIT = 21;\n\tconst OPT_AUTO_EJECT_HOSTS = 28;\n\tconst OPT_HASH_WITH_PREFIX_KEY = 25;\n\tconst OPT_NOREPLY = 26;\n\tconst OPT_SORT_HOSTS = 12;\n\tconst OPT_VERIFY_KEY = 13;\n\tconst OPT_USE_UDP = 27;\n\tconst OPT_NUMBER_OF_REPLICAS = 29;\n\tconst OPT_RANDOMIZE_REPLICA_READ = 30;\n\tconst OPT_CORK = 31;\n\tconst OPT_REMOVE_FAILED_SERVERS = 35;\n\tconst OPT_DEAD_TIMEOUT = 36;\n\tconst OPT_SERVER_TIMEOUT_LIMIT = 37;\n\tconst OPT_MAX = 38;\n\tconst OPT_IO_BYTES_WATERMARK = 23;\n\tconst OPT_IO_KEY_PREFETCH = 24;\n\tconst OPT_IO_MSG_WATERMARK = 22;\n\tconst OPT_LOAD_FROM_FILE = 34;\n\tconst OPT_SUPPORT_CAS = 7;\n\tconst OPT_TCP_KEEPIDLE = 33;\n\tconst OPT_USER_DATA = 11;\n\n\n\t/**\n\t * <p>The operation was successful.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_SUCCESS = 0;\n\n\t/**\n\t * <p>The operation failed in some fashion.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_FAILURE = 1;\n\n\t/**\n\t * <p>DNS lookup failed.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_HOST_LOOKUP_FAILURE = 2;\n\n\t/**\n\t * <p>Failed to read network data.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_UNKNOWN_READ_FAILURE = 7;\n\n\t/**\n\t * <p>Bad command in memcached protocol.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_PROTOCOL_ERROR = 8;\n\n\t/**\n\t * <p>Error on the client side.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_CLIENT_ERROR = 9;\n\n\t/**\n\t * <p>Error on the server side.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_SERVER_ERROR = 10;\n\n\t/**\n\t * <p>Failed to write network data.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_WRITE_FAILURE = 5;\n\n\t/**\n\t * <p>Failed to do compare-and-swap: item you are trying to store has been\n\t * modified since you last fetched it.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_DATA_EXISTS = 12;\n\n\t/**\n\t * <p>Item was not stored: but not because of an error. This normally\n\t * means that either the condition for an \"add\" or a \"replace\" command\n\t * wasn't met, or that the item is in a delete queue.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_NOTSTORED = 14;\n\n\t/**\n\t * <p>Item with this key was not found (with \"get\" operation or \"cas\"\n\t * operations).</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_NOTFOUND = 16;\n\n\t/**\n\t * <p>Partial network data read error.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_PARTIAL_READ = 18;\n\n\t/**\n\t * <p>Some errors occurred during multi-get.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_SOME_ERRORS = 19;\n\n\t/**\n\t * <p>Server list is empty.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_NO_SERVERS = 20;\n\n\t/**\n\t * <p>End of result set.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_END = 21;\n\n\t/**\n\t * <p>System error.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_ERRNO = 26;\n\n\t/**\n\t * <p>The operation was buffered.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_BUFFERED = 32;\n\n\t/**\n\t * <p>The operation timed out.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_TIMEOUT = 31;\n\n\t/**\n\t * <p>Bad key.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_BAD_KEY_PROVIDED = 33;\n\tconst RES_STORED = 15;\n\tconst RES_DELETED = 22;\n\tconst RES_STAT = 24;\n\tconst RES_ITEM = 25;\n\tconst RES_NOT_SUPPORTED = 28;\n\tconst RES_FETCH_NOTFINISHED = 30;\n\tconst RES_SERVER_MARKED_DEAD = 35;\n\tconst RES_UNKNOWN_STAT_KEY = 36;\n\tconst RES_INVALID_HOST_PROTOCOL = 34;\n\tconst RES_MEMORY_ALLOCATION_FAILURE = 17;\n\tconst RES_E2BIG = 37;\n\tconst RES_KEY_TOO_BIG = 39;\n\tconst RES_SERVER_TEMPORARILY_DISABLED = 47;\n\tconst RES_SERVER_MEMORY_ALLOCATION_FAILURE = 48;\n\tconst RES_AUTH_PROBLEM = 40;\n\tconst RES_AUTH_FAILURE = 41;\n\tconst RES_AUTH_CONTINUE = 42;\n\tconst RES_CONNECTION_FAILURE = 3;\n\tconst RES_CONNECTION_BIND_FAILURE = 4;\n\tconst RES_READ_FAILURE = 6;\n\tconst RES_DATA_DOES_NOT_EXIST = 13;\n\tconst RES_VALUE = 23;\n\tconst RES_FAIL_UNIX_SOCKET = 27;\n\tconst RES_NO_KEY_PROVIDED = 29;\n\tconst RES_INVALID_ARGUMENTS = 38;\n\tconst RES_PARSE_ERROR = 43;\n\tconst RES_PARSE_USER_ERROR = 44;\n\tconst RES_DEPRECATED = 45;\n\tconst RES_IN_PROGRESS = 46;\n\tconst RES_MAXIMUM_RETURN = 49;\n\n\n\n\t/**\n\t * <p>Failed to create network socket.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_CONNECTION_SOCKET_CREATE_FAILURE = 11;\n\n\t/**\n\t * <p>Payload failure: could not compress/decompress or serialize/unserialize the value.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst RES_PAYLOAD_FAILURE = -1001;\n\n\t/**\n\t * <p>The default PHP serializer.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst SERIALIZER_PHP = 1;\n\n\t/**\n\t * <p>The igbinary serializer.\n\t * Instead of textual representation it stores PHP data structures in a\n\t * compact binary form, resulting in space and time gains.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst SERIALIZER_IGBINARY = 2;\n\n\t/**\n\t * <p>The JSON serializer. Requires PHP 5.2.10+.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst SERIALIZER_JSON = 3;\n\tconst SERIALIZER_JSON_ARRAY = 4;\n\tconst COMPRESSION_FASTLZ = 2;\n\tconst COMPRESSION_ZLIB = 1;\n\n\t/**\n\t * <p>A flag for <b>Memcached::getMulti</b> and\n\t * <b>Memcached::getMultiByKey</b> to ensure that the keys are\n\t * returned in the same order as they were requested in. Non-existing keys\n\t * get a default value of NULL.</p>\n\t * @link https://php.net/manual/en/memcached.constants.php\n\t */\n\tconst GET_PRESERVE_ORDER = 1;\n\tconst GET_ERROR_RETURN_VALUE = false;\n\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Create a Memcached instance\n\t * @link https://php.net/manual/en/memcached.construct.php\n\t * @param $persistent_id [optional]\n\t * @param $callback [optional]\n\t */\n\tpublic function __construct ($persistent_id = '', $on_new_object_cb = null) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Return the result code of the last operation\n\t * @link https://php.net/manual/en/memcached.getresultcode.php\n\t * @return int Result code of the last Memcached operation.\n\t */\n\tpublic function getResultCode () {}\n\n\t/**\n\t * (PECL memcached &gt;= 1.0.0)<br/>\n\t * Return the message describing the result of the last operation\n\t * @link https://php.net/manual/en/memcached.getresultmessage.php\n\t * @return string Message describing the result of the last Memcached operation.\n\t */\n\tpublic function getResultMessage () {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Retrieve an item\n\t * @link https://php.net/manual/en/memcached.get.php\n\t * @param string $key <p>\n\t * The key of the item to retrieve.\n\t * </p>\n\t * @param callable $cache_cb [optional] <p>\n\t * Read-through caching callback or <b>NULL</b>.\n\t * </p>\n\t * @param int $flags [optional] <p>\n\t * The flags for the get operation.\n\t * </p>\n\t * @return mixed the value stored in the cache or <b>FALSE</b> otherwise.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTFOUND</b> if the key does not exist.\n\t */\n\tpublic function get ($key, callable $cache_cb = null, $flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Retrieve an item from a specific server\n\t * @link https://php.net/manual/en/memcached.getbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key of the item to fetch.\n\t * </p>\n\t * @param callable $cache_cb [optional] <p>\n\t * Read-through caching callback or <b>NULL</b>\n\t * </p>\n\t * @param int $flags [optional] <p>\n\t * The flags for the get operation.\n\t * </p>\n\t * @return mixed the value stored in the cache or <b>FALSE</b> otherwise.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTFOUND</b> if the key does not exist.\n\t */\n\tpublic function getByKey ($server_key, $key, callable $cache_cb = null, $flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Retrieve multiple items\n\t * @link https://php.net/manual/en/memcached.getmulti.php\n\t * @param array $keys <p>\n\t * Array of keys to retrieve.\n\t * </p>\n\t * @param int $flags [optional] <p>\n\t * The flags for the get operation.\n\t * </p>\n\t * @return mixed the array of found items or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function getMulti (array $keys, $flags = null) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Retrieve multiple items from a specific server\n\t * @link https://php.net/manual/en/memcached.getmultibykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param array $keys <p>\n\t * Array of keys to retrieve.\n\t * </p>\n\t * @param int $flags [optional] <p>\n\t * The flags for the get operation.\n\t * </p>\n\t * @return array|false the array of found items or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function getMultiByKey ($server_key, array $keys, $flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Request multiple items\n\t * @link https://php.net/manual/en/memcached.getdelayed.php\n\t * @param array $keys <p>\n\t * Array of keys to request.\n\t * </p>\n\t * @param bool $with_cas [optional] <p>\n\t * Whether to request CAS token values also.\n\t * </p>\n\t * @param callable $value_cb [optional] <p>\n\t * The result callback or <b>NULL</b>.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function getDelayed (array $keys, $with_cas = null, callable $value_cb = null) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Request multiple items from a specific server\n\t * @link https://php.net/manual/en/memcached.getdelayedbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param array $keys <p>\n\t * Array of keys to request.\n\t * </p>\n\t * @param bool $with_cas [optional] <p>\n\t * Whether to request CAS token values also.\n\t * </p>\n\t * @param callable $value_cb [optional] <p>\n\t * The result callback or <b>NULL</b>.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function getDelayedByKey ($server_key, array $keys, $with_cas = null, callable $value_cb = null) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Fetch the next result\n\t * @link https://php.net/manual/en/memcached.fetch.php\n\t * @return array|false the next result or <b>FALSE</b> otherwise.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_END</b> if result set is exhausted.\n\t */\n\tpublic function fetch () {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Fetch all the remaining results\n\t * @link https://php.net/manual/en/memcached.fetchall.php\n\t * @return array|false the results or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function fetchAll () {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Store an item\n\t * @link https://php.net/manual/en/memcached.set.php\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param mixed $value <p>\n\t * The value to store.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function set ($key, $value, $expiration = 0, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Store an item on a specific server\n\t * @link https://php.net/manual/en/memcached.setbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param mixed $value <p>\n\t * The value to store.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function setByKey ($server_key, $key, $value, $expiration = 0, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Set a new expiration on an item\n\t * @link https://php.net/manual/en/memcached.touch.php\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param int $expiration <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function touch ($key, $expiration = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Set a new expiration on an item on a specific server\n\t * @link https://php.net/manual/en/memcached.touchbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param int $expiration <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function touchByKey ($server_key, $key, $expiration) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Store multiple items\n\t * @link https://php.net/manual/en/memcached.setmulti.php\n\t * @param array $items <p>\n\t * An array of key/value pairs to store on the server.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function setMulti (array $items, $expiration = 0, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Store multiple items on a specific server\n\t * @link https://php.net/manual/en/memcached.setmultibykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param array $items <p>\n\t * An array of key/value pairs to store on the server.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function setMultiByKey ($server_key, array $items, $expiration = 0, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Compare and swap an item\n\t * @link https://php.net/manual/en/memcached.cas.php\n\t * @param float $cas_token <p>\n\t * Unique value associated with the existing item. Generated by memcache.\n\t * </p>\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param mixed $value <p>\n\t * The value to store.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_DATA_EXISTS</b> if the item you are trying\n\t * to store has been modified since you last fetched it.\n\t */\n\tpublic function cas ($cas_token, $key, $value, $expiration = 0, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Compare and swap an item on a specific server\n\t * @link https://php.net/manual/en/memcached.casbykey.php\n\t * @param float $cas_token <p>\n\t * Unique value associated with the existing item. Generated by memcache.\n\t * </p>\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param mixed $value <p>\n\t * The value to store.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_DATA_EXISTS</b> if the item you are trying\n\t * to store has been modified since you last fetched it.\n\t */\n\tpublic function casByKey ($cas_token, $server_key, $key, $value, $expiration = 0, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Add an item under a new key\n\t * @link https://php.net/manual/en/memcached.add.php\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param mixed $value <p>\n\t * The value to store.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTSTORED</b> if the key already exists.\n\t */\n\tpublic function add ($key, $value, $expiration = 0, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Add an item under a new key on a specific server\n\t * @link https://php.net/manual/en/memcached.addbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param mixed $value <p>\n\t * The value to store.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTSTORED</b> if the key already exists.\n\t */\n\tpublic function addByKey ($server_key, $key, $value, $expiration = 0, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Append data to an existing item\n\t * @link https://php.net/manual/en/memcached.append.php\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param string $value <p>\n\t * The string to append.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTSTORED</b> if the key does not exist.\n\t */\n\tpublic function append ($key, $value) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Append data to an existing item on a specific server\n\t * @link https://php.net/manual/en/memcached.appendbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param string $value <p>\n\t * The string to append.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTSTORED</b> if the key does not exist.\n\t */\n\tpublic function appendByKey ($server_key, $key, $value) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Prepend data to an existing item\n\t * @link https://php.net/manual/en/memcached.prepend.php\n\t * @param string $key <p>\n\t * The key of the item to prepend the data to.\n\t * </p>\n\t * @param string $value <p>\n\t * The string to prepend.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTSTORED</b> if the key does not exist.\n\t */\n\tpublic function prepend ($key, $value) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Prepend data to an existing item on a specific server\n\t * @link https://php.net/manual/en/memcached.prependbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key of the item to prepend the data to.\n\t * </p>\n\t * @param string $value <p>\n\t * The string to prepend.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTSTORED</b> if the key does not exist.\n\t */\n\tpublic function prependByKey ($server_key, $key, $value) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Replace the item under an existing key\n\t * @link https://php.net/manual/en/memcached.replace.php\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param mixed $value <p>\n\t * The value to store.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTSTORED</b> if the key does not exist.\n\t */\n\tpublic function replace ($key, $value, $expiration = null, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Replace the item under an existing key on a specific server\n\t * @link https://php.net/manual/en/memcached.replacebykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key under which to store the value.\n\t * </p>\n\t * @param mixed $value <p>\n\t * The value to store.\n\t * </p>\n\t * @param int $expiration [optional] <p>\n\t * The expiration time, defaults to 0. See Expiration Times for more info.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTSTORED</b> if the key does not exist.\n\t */\n\tpublic function replaceByKey ($server_key, $key, $value, $expiration = null, $udf_flags = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Delete an item\n\t * @link https://php.net/manual/en/memcached.delete.php\n\t * @param string $key <p>\n\t * The key to be deleted.\n\t * </p>\n\t * @param int $time [optional] <p>\n\t * The amount of time the server will wait to delete the item.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTFOUND</b> if the key does not exist.\n\t */\n\tpublic function delete ($key, $time = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Delete multiple items\n\t * @link https://php.net/manual/en/memcached.deletemulti.php\n\t * @param array $keys <p>\n\t * The keys to be deleted.\n\t * </p>\n\t * @param int $time [optional] <p>\n\t * The amount of time the server will wait to delete the items.\n\t * </p>\n\t * @return array Returns array indexed by keys and where values are indicating whether operation succeeded or not.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTFOUND</b> if the key does not exist.\n\t */\n\tpublic function deleteMulti (array $keys, $time = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Delete an item from a specific server\n\t * @link https://php.net/manual/en/memcached.deletebykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key to be deleted.\n\t * </p>\n\t * @param int $time [optional] <p>\n\t * The amount of time the server will wait to delete the item.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTFOUND</b> if the key does not exist.\n\t */\n\tpublic function deleteByKey ($server_key, $key, $time = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Delete multiple items from a specific server\n\t * @link https://php.net/manual/en/memcached.deletemultibykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param array $keys <p>\n\t * The keys to be deleted.\n\t * </p>\n\t * @param int $time [optional] <p>\n\t * The amount of time the server will wait to delete the items.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * The <b>Memcached::getResultCode</b> will return\n\t * <b>Memcached::RES_NOTFOUND</b> if the key does not exist.\n\t */\n\tpublic function deleteMultiByKey ($server_key, array $keys, $time = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Increment numeric item's value\n\t * @link https://php.net/manual/en/memcached.increment.php\n\t * @param string $key <p>\n\t * The key of the item to increment.\n\t * </p>\n\t * @param int $offset [optional] <p>\n\t * The amount by which to increment the item's value.\n\t * </p>\n\t * @param int $initial_value [optional] <p>\n\t * The value to set the item to if it doesn't currently exist.\n\t * </p>\n\t * @param int $expiry [optional] <p>\n\t * The expiry time to set on the item.\n\t * </p>\n\t * @return int|false new item's value on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function increment ($key, $offset = 1, $initial_value = 0, $expiry = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Decrement numeric item's value\n\t * @link https://php.net/manual/en/memcached.decrement.php\n\t * @param string $key <p>\n\t * The key of the item to decrement.\n\t * </p>\n\t * @param int $offset [optional] <p>\n\t * The amount by which to decrement the item's value.\n\t * </p>\n\t * @param int $initial_value [optional] <p>\n\t * The value to set the item to if it doesn't currently exist.\n\t * </p>\n\t * @param int $expiry [optional] <p>\n\t * The expiry time to set on the item.\n\t * </p>\n\t * @return int|false item's new value on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function decrement ($key, $offset = 1, $initial_value = 0, $expiry = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Increment numeric item's value, stored on a specific server\n\t * @link https://php.net/manual/en/memcached.incrementbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key of the item to increment.\n\t * </p>\n\t * @param int $offset [optional] <p>\n\t * The amount by which to increment the item's value.\n\t * </p>\n\t * @param int $initial_value [optional] <p>\n\t * The value to set the item to if it doesn't currently exist.\n\t * </p>\n\t * @param int $expiry [optional] <p>\n\t * The expiry time to set on the item.\n\t * </p>\n\t * @return int|false new item's value on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function incrementByKey ($server_key, $key, $offset = 1, $initial_value = 0, $expiry = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Decrement numeric item's value, stored on a specific server\n\t * @link https://php.net/manual/en/memcached.decrementbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @param string $key <p>\n\t * The key of the item to decrement.\n\t * </p>\n\t * @param int $offset [optional] <p>\n\t * The amount by which to decrement the item's value.\n\t * </p>\n\t * @param int $initial_value [optional] <p>\n\t * The value to set the item to if it doesn't currently exist.\n\t * </p>\n\t * @param int $expiry [optional] <p>\n\t * The expiry time to set on the item.\n\t * </p>\n\t * @return int|false item's new value on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function decrementByKey ($server_key, $key, $offset = 1, $initial_value = 0, $expiry = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Add a server to the server pool\n\t * @link https://php.net/manual/en/memcached.addserver.php\n\t * @param string $host <p>\n\t * The hostname of the memcache server. If the hostname is invalid, data-related\n\t * operations will set\n\t * <b>Memcached::RES_HOST_LOOKUP_FAILURE</b> result code.\n\t * </p>\n\t * @param int $port <p>\n\t * The port on which memcache is running. Usually, this is\n\t * 11211.\n\t * </p>\n\t * @param int $weight [optional] <p>\n\t * The weight of the server relative to the total weight of all the\n\t * servers in the pool. This controls the probability of the server being\n\t * selected for operations. This is used only with consistent distribution\n\t * option and usually corresponds to the amount of memory available to\n\t * memcache on that server.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function addServer ($host, $port, $weight = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.1)<br/>\n\t * Add multiple servers to the server pool\n\t * @link https://php.net/manual/en/memcached.addservers.php\n\t * @param array $servers\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function addServers (array $servers) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Get the list of the servers in the pool\n\t * @link https://php.net/manual/en/memcached.getserverlist.php\n\t * @return array The list of all servers in the server pool.\n\t */\n\tpublic function getServerList () {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Map a key to a server\n\t * @link https://php.net/manual/en/memcached.getserverbykey.php\n\t * @param string $server_key <p>\n\t * The key identifying the server to store the value on or retrieve it from. Instead of hashing on the actual key for the item, we hash on the server key when deciding which memcached server to talk to. This allows related items to be grouped together on a single server for efficiency with multi operations.\n\t * </p>\n\t * @return array an array containing three keys of host,\n\t * port, and weight on success or <b>FALSE</b>\n\t * on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function getServerByKey ($server_key) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Clears all servers from the server list\n\t * @link https://php.net/manual/en/memcached.resetserverlist.php\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function resetServerList () {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Close any open connections\n\t * @link https://php.net/manual/en/memcached.quit.php\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function quit () {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Get server pool statistics\n\t * @link https://php.net/manual/en/memcached.getstats.php\n\t * @param string $type\n\t * @return array Array of server statistics, one entry per server.\n\t */\n\tpublic function getStats ($type = null) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.5)<br/>\n\t * Get server pool version info\n\t * @link https://php.net/manual/en/memcached.getversion.php\n\t * @return array Array of server versions, one entry per server.\n\t */\n\tpublic function getVersion () {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Gets the keys stored on all the servers\n\t * @link https://php.net/manual/en/memcached.getallkeys.php\n\t * @return array|false the keys stored on all the servers on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function getAllKeys () {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Invalidate all items in the cache\n\t * @link https://php.net/manual/en/memcached.flush.php\n\t * @param int $delay [optional] <p>\n\t * Numer of seconds to wait before invalidating the items.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t * Use <b>Memcached::getResultCode</b> if necessary.\n\t */\n\tpublic function flush ($delay = 0) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Retrieve a Memcached option value\n\t * @link https://php.net/manual/en/memcached.getoption.php\n\t * @param int $option <p>\n\t * One of the Memcached::OPT_* constants.\n\t * </p>\n\t * @return mixed the value of the requested option, or <b>FALSE</b> on\n\t * error.\n\t */\n\tpublic function getOption ($option) {}\n\n\t/**\n\t * (PECL memcached &gt;= 0.1.0)<br/>\n\t * Set a Memcached option\n\t * @link https://php.net/manual/en/memcached.setoption.php\n\t * @param int $option\n\t * @param mixed $value\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function setOption ($option, $value) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Set Memcached options\n\t * @link https://php.net/manual/en/memcached.setoptions.php\n\t * @param array $options <p>\n\t * An associative array of options where the key is the option to set and\n\t * the value is the new value for the option.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function setOptions (array $options) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Set the credentials to use for authentication\n\t * @link https://secure.php.net/manual/en/memcached.setsaslauthdata.php\n\t * @param string $username <p>\n\t * The username to use for authentication.\n\t * </p>\n\t * @param string $password <p>\n\t * The password to use for authentication.\n\t * </p>\n\t * @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.\n\t */\n\tpublic function setSaslAuthData (string $username , string $password) {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Check if a persitent connection to memcache is being used\n\t * @link https://php.net/manual/en/memcached.ispersistent.php\n\t * @return bool true if Memcache instance uses a persistent connection, false otherwise.\n\t */\n\tpublic function isPersistent () {}\n\n\t/**\n\t * (PECL memcached &gt;= 2.0.0)<br/>\n\t * Check if the instance was recently created\n\t * @link https://php.net/manual/en/memcached.ispristine.php\n\t * @return bool the true if instance is recently created, false otherwise.\n\t */\n\tpublic function isPristine () {}\n\n\tpublic function flushBuffers () {}\n\n\tpublic function setEncodingKey ( $key ) {}\n\n\tpublic function getLastDisconnectedServer () {}\n\n\tpublic function getLastErrorErrno () {}\n\n\tpublic function getLastErrorCode () {}\n\n\tpublic function getLastErrorMessage () {}\n\n\tpublic function setBucket (array $host_map, array $forward_map, $replicas) {}\n\n}\n\n/**\n * @link https://php.net/manual/en/class.memcachedexception.php\n */\nclass MemcachedException extends RuntimeException  {\n\n}\n// End of memcached v.3.0.4\n?>\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: php\nphp:\n  - '7.1'\n  - '7.2'\n  - '7.3'\n  - '7.4'\nbranches:\n  only:\n    - build_test\nnotifications:\n  email:\n    on_success: never\n    on_failure: always\n  slack:\n    secure: dowksPsxxCxGKT6nis5hUgkp6+ZDAhoqzQHF9rJnx4hx0iEygPhVBs7pKl9yL2jubYJoLs+EXwE7z1dYgDAEJh4BnfrCokCMLpFGcxVxQC/HeAUdSQ2/RtdBYR5PRT75ScaFpqM/SfXXZVtnwVXAw9Z+JC6BjQ9vmn23m51Jw4k=\nenv:\n  global:\n    # Colors!\n    - TEXTRESET=$(tput sgr0) # reset the foreground colour\n    - RED=$(tput setaf 1)\n    - GREEN=$(tput setaf 2)\n    - YELLOW=$(tput setaf 3)\n    - BLUE=$(tput setaf 4)\n    - BOLD=$(tput bold)\n    # User\n    - GH_USER=\"getgrav\"\n    # Paths\n    - RT_DEVTOOLS=$HOME/devtools\n    - GOPATH=\"$HOME/go\"\n    - PATH=\"$GOPATH/bin:$PATH\"\n    # GH_TOKEN [API Key]\n    - secure: \"NR9pV7YteY9OoPmjDTQG0fDfocVu+tCeiDH1F2GFhXCu71UOIvqWXpOxp0RHkG5GIXdCFHx59yu+ZO275lbaHkbF8+4lVSVrV4RcGn+pIncvxr6iZCVW05dbAxV3H8alK+xYJRGmbyfQl5wIM49WvmuGHZjcmIloS4t/omQ3N+I=\"\n    # BB_TOKEN value => \"user:pass@\"\n    - secure: \"einUtSEkUWy2IrqLXyVjwUU+mwaaoiOXRRVdLBpA3Zye6bZx8cm5h/5AplkPWhM/NmCJoW/MwNZHHkFhlr3mDRov5iOxVmTTYfnXB+I5lxYTSgduOLLErS7mU8hfADpVDU8bHNU44fNGD3UEiG1PD4qQBX4DMlqIFmR20mjs81k=\"\n    # GH_API_USER [for curl]\n    - secure: \"AQGcX1B2NrI8ajflY4AimZDNcK2kBA3F6mbtEFQ78NkDoWhMipsQHayWXiSTzRc0YJKvQl2Y16MTwQF4VHzjTAiiZFATgA8J88vQUjIPabi/kKjqSmcLFoaAOAxStQbW6e0z2GiQ6KBMcNF1y5iUuI63xVrBvtKrYX/w5y+ako8=\"\n\nbefore_install:\n  - export TZ=Pacific/Honolulu\n  - echo $TRAVIS_PHP_VERSION\n  - echo $TRAVIS_BRANCH\n  - echo $TRAVIS_PULL_REQUEST\n  - composer self-update\n  - if [ $TRAVIS_BRANCH == 'develop' ] || [ $TRAVIS_PULL_REQUEST != 'false' ]; then\n        composer install --dev --prefer-dist;\n    fi\n  - |\n    if [ $TRAVIS_BRANCH != 'develop' ] && [ $TRAVIS_PHP_VERSION == \"7.1\" ] && [ $TRAVIS_PULL_REQUEST == \"false\" ]; then\n        export TRAVIS_TAG=$(curl -H \"Authorization: token ${GH_TOKEN}\" --fail -s https://api.github.com/repos/getgrav/grav/releases/latest | grep tag_name | head -n 1 | cut -d '\"' -f 4);\n        eval \"$(curl -sL https://raw.githubusercontent.com/travis-ci/gimme/master/gimme | GIMME_GO_VERSION=1.13 bash)\";\n        go get github.com/github-release/github-release;\n        git clone --quiet --depth=50 --branch=master https://${BB_TOKEN}bitbucket.org/rockettheme/grav-devtools.git $RT_DEVTOOLS  &>/dev/null;\n        if [ ! -z \"$TRAVIS_TAG\" ]; then\n            cd ${RT_DEVTOOLS};\n            ./build-grav.sh skeletons.txt;\n        fi;\n    fi\nbefore_script:\n  - phpenv config-rm xdebug.ini\nscript:\n  - if [ $TRAVIS_BRANCH == 'develop' ] || [ $TRAVIS_PULL_REQUEST != 'false' ]; then\n        vendor/bin/codecept run;\n    fi\n  - echo \"Latest Release Tag - ${TRAVIS_TAG}\"\n  - if [ ! -z \"$TRAVIS_TAG\" ] && [ $TRAVIS_BRANCH != 'develop' ] && [ $TRAVIS_PHP_VERSION == \"7.1\" ] && [ $TRAVIS_PULL_REQUEST == \"false\" ]; then\n      FILES=\"$RT_DEVTOOLS/grav-dist/*.zip\";\n      for file in ${FILES[@]}; do\n        NAME=${file##*/};\n        if [[ \"$NAME\" == *\"-rc\"* ]]; then\n            REPO=\"$(echo ${NAME} | rev | cut -f 3- -d \"-\" | rev)\";\n        else\n            REPO=\"$(echo ${NAME} | rev | cut -f 2- -d \"-\" | rev)\";\n        fi;\n        if [[ $REPO == 'grav' || $REPO == 'grav-admin' || $REPO == 'grav-update' ]]; then\n          REPO=\"grav\";\n        fi;\n        API=\"$(curl --fail --user \"${GH_API_USER}\" -s https://api.github.com/repos/${GH_USER}/${REPO}/releases/latest)\";\n        ASSETS=\"$(echo \"${API}\" | node gh-assets.js)\";\n        TAG=\"$(echo \"${API}\" | grep tag_name | head -n 1 | cut -d '\"' -f 4)\";\n        if [ $REPO == \"grav\" ]; then\n          TAG=\"$TRAVIS_TAG\";\n        fi;\n        if [ ! -z \"$ASSETS\" ]; then\n          for asset in ${ASSETS[@]}; do\n            asset_id=$(echo ${asset} | cut -d ':' -f 1);\n            asset_name=$(echo ${asset} | cut -d ':' -f 2);\n            if [ \"${NAME}\" == \"${asset_name}\" ]; then\n              echo -e \"\\nAsset ${BOLD}${BLUE}${NAME}${TEXTRESET} already exists in ${YELLOW}${REPO}${TEXTRESET}@${BOLD}${YELLOW}${TAG}${TEXTRESET}... deleting id ${BOLD}${RED}${asset_id}${TEXTRESET}...\";\n              curl -X DELETE --fail --user \"${GH_API_USER}\" \"https://api.github.com/repos/${GH_USER}/${REPO}/releases/assets/${asset_id}\";\n            fi;\n          done;\n        fi;\n        echo \"Uploading package ${BOLD}${BLUE}${NAME}${TEXTRESET} to ${YELLOW}${REPO}${TEXTRESET}@${YELLOW}${TAG}${TEXTRESET}\";\n        github-release upload --security-token $GH_TOKEN --user ${GH_USER} --repo $REPO --tag \"$TAG\" --name \"$NAME\" --file \"$file\";\n      done;\n    fi\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# v1.7.49.5\n## 09/10/2025\n\n1. [](#bugfix)\n    * Backup not honoring ignored paths [#3952](https://github.com/getgrav/grav/issues/3952)\n\n# v1.7.49.4\n## 09/03/2025\n\n1. [](#bugfix)\n    * Fixed cron force running jobs severy minute! [#3951](https://github.com/getgrav/grav/issues/3951)\n\n# v1.7.49.3\n## 09/02/2025\n\n1. [](#bugfix)\n    * Fixed an error in ZipArchive that was causing issues on some systems\n    * Fixed namespace change for `Cron\\Expression`\n    * Removed broken cron install field... use 'instructions' instead\n    * Fixed duplicate jobs listing in some CLI commands\n\n# v1.7.49.2\n## 08/28/2025\n\n1. [](#bugfix)\n    * Fix translation of key for image adapter [#3944](https://github.com/getgrav/grav/pull/3944)\n\n# v1.7.49.1\n## 08/25/2025\n\n1. [](#new)\n    * Rerelease to include all updated plugins/theme etc.\n\n# v1.7.49\n## 08/25/2025\n\n1. [](#new)\n    * Revamped Grav Scheduler to support webhook to call call scheduler + concurrent jobs + jobs queue + logging, and other improvements\n    * Revamped Grav Cache purge capabilities to only clear obsolete old cache items\n    * Added full imagick support in Grav Image library\n    * Added support for Validate `match` and `match_any` in forms\n1. [](#improved)\n    * Handle empty values on require with ignore fields in Forms\n    * Use `actions/cache@v4` in github workflows\n    * Use `actions/checkout@v4`in github workflows [#3867](https://github.com/getgrav/grav/pull/3867)\n    * Update code block in README.md [#3886](https://github.com/getgrav/grav/pull/3886)\n    * Updated vendor libs to latest\n1. [](#bugfix)\n    * Bug in `exif_read_data` [#3878](https://github.com/getgrav/grav/pull/3878)\n    * Fix parser error in URI: [#3894](https://github.com/getgrav/grav/issues/3894)\n\n\n# v1.7.48\n## 10/28/2024\n\n1. [](#new)\n    * New Trait for fetchPriority attribute on images [#3850](https://github.com/getgrav/grav/pull/3850)\n1. [](#improved)\n    * Fix for #3164. Adds aliases as possible commands during lookup [#3863](https://github.com/getgrav/grav/pull/3863)\n1. [](#bugfix)\n    * Fix style conflict with Clockwork and tooltips [#3861](https://github.com/getgrav/grav/pull/3861)\n\n# v1.7.47\n## 10/23/2024\n\n1. [](#new)\n  * New `Utils::toAscii()` method  \n  * Added support for Clockwork Debugger to allow web UI (requires new `clockwork-web` plugin)\n1. [](#improved) \n  * Include modular sub-pages in last-modification date computation [#3562](https://github.com/getgrav/grav/pull/3562)\n  * Updated vendor libs to latest versions\n  * Updated JQuery to `3.7.1` [#3787](https://github.com/getgrav/grav/pull/3827)\n  * Updated vendor libraries to latest versions\n  * Support for Fediverse Creator meta tag [#3844](https://github.com/getgrav/grav/pull/3844)\n1. [](#bugfix)\n  * Fixes deprecated for return type in Filesystem with PHP 8.3.6 [#3831](https://github.com/getgrav/grav/issues/3831) \n  * Fix for `exif_imagtetype()` throwing an exception when file doesn't exist\n  * Fix JSON output comments check with content type [#3859](https://github.com/getgrav/grav/pull/3859)\n\n# v1.7.46\n## 05/15/2024\n\n1. [](#new)\n   * Added a new `Utils::toAscii()` method to remove UTF-8 characters from string\n1. [](#improved) \n   * Removed unused `symfony/service-contracts` [#3828](https://github.com/getgrav/grav/pull/3828)\n   * Upgraded bundled legacy JQuery to `3.7.1` [#3727](https://github.com/getgrav/grav/pull/3827)\n   * Include modular pages in header `last-modified:` calculation [#3562](https://github.com/getgrav/grav/pull/3562)\n   * Updated vendor libs to latest versions\n1. [](#bugfix)\n   * Fixed some deprecated issues in Filesystem [#3831](https://github.com/getgrav/grav/issues/3831)\n\n# v1.7.46\n## 05/15/2024\n\n1. [](#improved) \n   * Better handling of external protocols in `Utils::url()` such as `mailto:`, `tel:`, etc.\n   * Handle `GRAV_ROOT` or `GRAV_WEBROOT` when `/` [#3667](https://github.com/getgrav/grav/pull/3667)\n1. [](#bugfix)\n   * Fixes for multi-lang taxonomy when reinitializing the languages (e.g. LangSwitcher plugin) \n   * Ensure the full filepath is checked for invalid filename in `MediaUploadTrait::checkFileMetadata()`\n   * Fixed a bug in the `on_events` REGEX pattern of `Security::detectXss()` as it was not matching correctly.\n   * Fixed an issue where `read_file()` Twig function could be used nefariously in content [#GHSA-f8v5-jmfh-pr69](https://github.com/getgrav/grav/security/advisories/GHSA-f8v5-jmfh-pr69)\n\n# v1.7.45\n## 03/18/2024\n\n1. [](#new)\n   * Added new Image trait for `decoding` attribute [#3796](https://github.com/getgrav/grav/pull/3796)\n1. [](#bugfix)\n   * Fixed some multibyte issues in Inflector class [#732](https://github.com/getgrav/grav/issues/732)\n   * Fallback to page modified date if Page date provided is invalid and can't be parsed [getgrav/grav-plugin-admin#2394](https://github.com/getgrav/grav-plugin-admin/issues/2394)\n   * Fixed a path traversal vulnerability with file uploads [#GHSA-m7hx-hw6h-mqmc](https://github.com/getgrav/grav/security/advisories/GHSA-m7hx-hw6h-mqmc)\n   * Fixed a security issue with insecure Twig functions be processed [#GHSA-2m7x-c7px-hp58](https://github.com/getgrav/grav/security/advisories/GHSA-2m7x-c7px-hp58) [#GHSA-r6vw-8v8r-pmp4](https://github.com/getgrav/grav/security/advisories/GHSA-r6vw-8v8r-pmp4) [#GHSA-qfv4-q44r-g7rv](https://github.com/getgrav/grav/security/advisories/GHSA-qfv4-q44r-g7rv) [#GHSA-c9gp-64c4-2rrh](https://github.com/getgrav/grav/security/advisories/GHSA-c9gp-64c4-2rrh)\n1. [](#improved) \n   * Updated composer packages\n   * Updated `bin/composer.phar` to latest `2.7.2`\n\n# v1.7.44\n## 01/05/2024\n\n1. [](#new)\n   * Added PHP `8.3` to tests [#3782](https://github.com/getgrav/grav/pull/3782)\n   * Added debugger messages when Page routes conflict\n   * Added `ISO 8601` date format [#3721](https://github.com/getgrav/grav/pull/37210)\n   * Added support for `.vcf` (vCard) in media configuration [#3772](https://github.com/getgrav/grav/pull/3772)\n1. [](#improved) \n   * Update jQuery to `v3.6.4` [#3713](https://github.com/getgrav/grav/pull/3713)\n   * Updated vendor libraries including Dom-Sanitizer `v1.0.7` that addresses an XSS issue \n   * Updated `bin/composer.phar` to latest `2.6.6`\n   * Updated vendor libraries to latest\n   * Updated language files\n   * Updated copyright year\n1. [](#bugfix)\n   * Fixed a math rounding issue with number validation when using floating point steps [#3761](https://github.com/getgrav/grav/issues/3761)\n   * Fixed an issue with `Inflector::ordinalize()` not working as expected [#3759](https://github.com/getgrav/grav/pull/3759)\n   * Fixed various issues with file extension checking with dangerous extensions [#3756(https://github.com/getgrav/grav/pull/3756)]\n   * Fix for invalid input to foreach in `UserGroupObject` [#3724](https://github.com/getgrav/grav/pull/3724)\n   * Fixed exception: `Property 'jsmodule_pipeline_include_externals' does not exist in object` [#3661](https://github.com/getgrav/grav/pull/3661)\n   * Fixed `too few arguments exception` in FlexObjects [#3658](https://github.com/getgrav/grav/pull/3658)\n\n# v1.7.43\n## 10/02/2023\n\n1. [](#new)\n   * Add the ability to programatically set a page's `modified` timestamp via a `modified:` frontmatter entry\n2. [](#improved)\n   * Update vendor libraries\n   * Include `phar` in the list of `security.uploads_dangerous_extensions`\n   * When enabled `system.languages.debug` now dumps **Key -> Value** to debugger [#3752](https://github.com/getgrav/grav/issues/3752)\n   * Updated built-in composer to latest `2.6.4` [#3748](https://github.com/getgrav/grav/issues/3748)\n   * Added support for `@import` to ensure paths are rewritten correctly in CSS pipeline [#3750](https://github.com/getgrav/grav/pull/3750)\n\n# v1.7.42.3\n## 07/18/2023\n\n2. [](#improved)\n   * Fixed a typo in `Utils::isDangerousFunction`\n\n# v1.7.42.2\n## 07/18/2023\n\n2. [](#improved)\n   * In `Utils::isDangerousFunction`, handle double `\\\\` in `|map` twig filter to mitigate SSTI attack\n   * Better handle empty email in `Validatoin::typeEmail()`\n\n# v1.7.42.1\n## 06/15/2023\n\n2. [](#improved)\n   * Quick fix for `isDangerousFunction` when `$name` was a closure [#3727](https://github.com/getgrav/grav/issues/3727)\n\n# v1.7.42\n## 06/14/2023\n\n1. [](#new)\n   * Added a new `system.languages.debug` option that adds a `<span class=\"translate-debug\"></span>` around strings translated with `|t`. This can be styled by the theme as needed.\n1. [](#improved)\n   * More robust SSTI handling in `filter`, `map`, and `reduce` Twig filters and functions\n   * Various SSTI improvements `Utils::isDangerousFunction()`\n1. [](#bugfix)\n   * Fixed Twig `|map()` allowing code execution\n   * Fixed Twig `|reduce()` allowing code execution\n\n# v1.7.41.2\n## 06/01/2023\n\n1. [](#improved)\n   * Added the ability to set a configurable 'key' for the Twig Cache Tag: `{% cache 'my-key' 600 %}`\n1. [](#bugfix)\n   * Fixed an issue with special characters in slug's would cause redirect loops\n\n# v1.7.41.1\n## 05/10/2023\n\n1. [](#bugfix)\n   * Fixed certain UTF-8 characters breaking `Truncator` class [#3716](https://github.com/getgrav/grav/issues/3716)\n\n# v1.7.41\n## 05/09/2023\n\n1. [](#improved)\n   * Removed `FILTER_SANITIZE_STRING` input filter in favor of `htmlspecialchars(strip_tags())` for PHP 8.2+\n   * Added `GRAV_SANITIZE_STRING` constant to replace `FILTER_SANITIZE_STRING` for PHP 8.2+\n   * Support non-deprecated style dynamic properties in `Parsedown` class via `ParseDownGravTrait` for PHP 8.2+\n   * Modified `Truncator` to not use deprecated `mb_convert_encoding()` for PHP 8.2+\n   * Fixed passing null into `mb_strpos()` deprecated for PHP 8.2+\n   * Updated internal `TwigDeferredExtension` to be PHP 8.2+ compatible\n   * Upgraded `getgrav/image` fork to take advantage of various PHP 8.2+ fixes\n   * Use `UserGroupObject::groupNames` method in blueprints for PHP 8.2+\n   * Comment out `files-upload` deprecated message as this is not going to be removed\n   * Added various public `Twig` class variables used by admin to address deprecated messages for PHP 8.2+\n   * Added `parse_url` to list of PHP functions supported in Twig Extension\n   * Added support for dynamic functions in `Parsedown` to stop deprecation messages in PHP 8.2+\n \n# v1.7.40\n## 03/22/2023\n\n1. [](#new)\n    * Added a new `timestamp: true|false` option for individual assets\n1. [](#improved)\n    * Removed outdated `xcache` setting [#3615](https://github.com/getgrav/grav/pull/3615)\n    * Updated `robots.txt` [#3625](https://github.com/getgrav/grav/pull/3625)\n    * Handle the situation when GRAV_ROOT or GRAV_WEBROOT are `/` [#3625](https://github.com/getgrav/grav/pull/3667)\n1. [](#bugfix)\n    * Fixed `force_ssl` redirect in case of undefined hostname [#3702](https://github.com/getgrav/grav/pull/3702)\n    * Fixed an issue with duplicate identical page paths\n    * Fixed `BlueprintSchema:flattenData` to properly handle ignored fields\n    * Fixed LogViewer regex greediness [#3684](https://github.com/getgrav/grav/pull/3684)\n    * Fixed `whoami` command [#3695](https://github.com/getgrav/grav/pull/3695)\n\n# v1.7.39.4\n## 02/22/2023\n\n1. [](#bugfix)\n    * Reverted a reorganization of `account.yaml` that caused username to be disabled [admin#2344](https://github.com/getgrav/grav-plugin-admin/issues/2344)\n\n# v1.7.39.3\n## 02/21/2023\n\n1. [](#bugfix)\n    * Fix for overzealous modular page template rendering fix in 1.7.39 causing Feed plugin to break [#3689](https://github.com/getgrav/grav/issues/3689)\n\n# v1.7.39.2\n## 02/20/2023\n\n1. [](#bugfix)\n    * Fix for invalid session breaking Flex Accounts (when switching from Regular to Flex)\n\n# v1.7.39.1\n## 02/20/2023\n\n1. [](#bugfix)\n    * Fix for broken image CSS with the latest version of DebugBar\n\n# v1.7.39\n## 02/19/2023\n\n1. [](#improved)\n    * Vendor library updates to latest versions\n1. [](#bugfix)\n    * Various PHP 8.2 fixes\n    * Fixed an issue with modular pages rendering thew wrong template when dynamically changing the page\n    * Fixed an issue with `email` validation that was failing on UTF-8 characters. Following best practices and now only check for `@` and length.\n    * Fixed PHPUnit tests to remove deprecation warnings\n\n# v1.7.38\n## 01/02/2023\n\n1. [](#new)\n    * New `onBeforeSessionStart()` event to be used to store data lost during session regeneration (e.g. login)\n1. [](#improved)\n   * Vendor library updates to latest versions\n   * Updated `bin/composer.phar` to latest `2.4.4` version [#3627](https://github.com/getgrav/grav/issues/3627)\n1. [](#bugfix)\n   * Don't fail hard if pages recurse with same path\n   * Github workflows security hardening [#3624](https://github.com/getgrav/grav/pull/3624)\n\n# v1.7.37.1\n## 10/05/2022\n\n1. [](#bugfix)\n    * Fixed a bad return type [#3630](https://github.com/getgrav/grav/issues/3630)\n\n# v1.7.37\n## 10/05/2022\n\n1. [](#new)\n    * Added new `onPageHeaders()` event to allow for header modification as needed\n    * Added a `system.pages.dirs` configuration option to allow for configurable paths, and multiple page paths\n    * Added new `Pages::getSimplePagesHash` which is useful for caching pages specific data\n    * Updated to latest vendor libraries\n1. [](#bugfix)\n    * An attempt to workaround windows reading locked file issue [getgrav/grav-plugin-admin#2299](https://github.com/getgrav/grav-plugin-admin/issues/2299)\n    * Force user index file to be updated to fix email addresses [getgrav/grav-plugin-login#229](https://github.com/getgrav/grav-plugin-login/issues/229)\n\n# v1.7.36\n## 09/08/2022\n\n1. [](#new)\n    * Added `authorize-*@:` support for Flex blueprints, e.g. `authorize-disabled@: not delete` disables the field if user does not have access to delete object\n    * Added support for `flex-ignore@` to hide all the nested fields in the blueprint\n1. [](#bugfix)\n    * Fixed login with a capitalised email address when using old users [getgrav/grav-plugin-login#229](https://github.com/getgrav/grav-plugin-login/issues/229)\n\n# v1.7.35\n## 08/04/2022\n\n1. [](#new)\n   * Added support for `multipart/form-data` content type in PUT and PATCH requests\n   * Added support for object relationships\n   * Added variables `$environment` (string), `$request` (PSR-7 ServerRequestInterface|null) and `$uri` (PSR-7 Uri|null) to be used in `setup.php`\n1. [](#improved)\n   * Minor vendor updates\n\n# v1.7.34\n## 06/14/2022\n\n1. [](#new)\n    * Added back Yiddish to Language Codes [#3336](https://github.com/getgrav/grav/pull/3336)\n    * Ignore upcoming `media.json` file in media\n1. [](#bugfix)\n    * Regression: Fixed saving page with a new language causing cache corruption [getgrav/grav-plugin-admin#2282](https://github.com/getgrav/grav-plugin-admin/issues/2282)\n    * Fixed a potential fatal error when using watermark in images\n    * Fixed `bin/grav install` command with arbitrary destination folder name\n    * Fixed Twig `|filter()` allowing code execution\n    * Fixed login and user search by email not being case-insensitive when using Flex Users\n\n# v1.7.33\n## 04/25/2022\n\n1. [](#improved)\n    * When saving yaml and markdown, create also a cached version of the file and recompile it in opcache\n2. [](#bugfix)\n    * Fixed missing changes in **yaml** & **markdown** files if saved multiple times during the same second because of a caching issue\n    * Fixed XSS check not detecting onX events without quotes\n    * Fixed default collection ordering in pages admin\n\n# v1.7.32\n## 03/28/2022\n\n1. [](#new)\n    * Added `|replace_last(search, replace)` filter\n    * Added `parseurl` Twig function to expose PHP's `parse_url` function\n2. [](#improved)\n    * Added multi-language support for page routes in `Utils::url()`\n    * Set default maximum length for text fields\n      - `password`: 256\n      - `email`: 320\n      - `text`, `url`, `hidden`, `commalist`: 2048\n      - `text` (multiline), `textarea`: 65536\n3. [](#bugfix)\n   * Fixed issue with `system.cache.gzip: true` resulted in \"Fetch Failed\" for PHP 8.0.17 and PHP 8.1.4 [PHP issue #8218](https://github.com/php/php-src/issues/8218)\n   * Fix for multi-lang issues with Security Report\n   * Fixed page search not working with selected language [#3316](https://github.com/getgrav/grav/issues/3316)\n\n# v1.7.31\n## 03/14/2022\n\n1. [](#new)\n   * Added new local Multiavatar (local generation). **This will be default in Grav 1.8**\n   * Added support to get image size for SVG vector images [#3533](https://github.com/getgrav/grav/pull/3533)\n   * Added XSS check for uploaded SVG files before they get stored\n   * Fixed phpstan issues (All level 2, Framework level 5)\n2. [](#improved)\n   * Moved Accounts out of Experimental section of System configuration to new \"Accounts\" tab\n3. [](#bugfix)\n   * Fixed `'mbstring' extension is not loaded` error, use Polyfill instead [#3504](https://github.com/getgrav/grav/pull/3504)\n   * Fixed new `Utils::pathinfo()` and `Utils::basename()` being too strict for legacy use [#3542](https://github.com/getgrav/grav/issues/3542)\n   * Fixed non-standard video html atributes generated by `{{ media.html() }}` [#3540](https://github.com/getgrav/grav/issues/3540)\n   * Fixed entity sanitization for XSS detection\n   * Fixed avatar save location when `account://` stream points to custom directory\n   * Fixed bug in `Utils::url()` when path contains part of root\n\n# v1.7.30\n## 02/07/2022\n\n1. [](#new)\n    * Added twig filter `|field_parent` to get parent field name\n2. [](#bugfix)\n    * Fixed error while deleting retina image in admin\n    * Fixed \"Page Authors\" field in Security tab, wrongly loading and saving the value [#3525](https://github.com/getgrav/grav/issues/3525)\n    * Fixed accounts filter only matches against email address [getgrav/grav-plugin-admin#2224](https://github.com/getgrav/grav-plugin-admin/issues/2224)\n\n# v1.7.29.1\n## 01/31/2022\n\n1. [](#bugfix)\n    * Fixed `Call to undefined method` error when upgrading from Grav 1.6 [#3523](https://github.com/getgrav/grav/issues/3523)\n\n# v1.7.29\n## 01/28/2022\n\n1. [](#new)\n    * Added support for registering assets from `HtmlBlock`\n    * Added unicode-safe `Utils::basename()` and `Utils::pathinfo()` methods\n2. [](#improved)\n    * Improved `Filesystem::basename()` and `Filesystem::pathinfo()` to be unicode-safe\n    * Made path handling unicode-safe, use new `Utils::basename()` and `Utils::pathinfo()` everywhere\n3. [](#bugfix)\n    * Fixed error on thumbnail image creation\n    * Fixed MimeType for `gzip` (`application/x-gzip`)\n\n# v1.7.28\n## 01/24/2022\n\n1. [](#new)\n    * Added links and modules support to `HtmlBlock` class\n    * Added module support for twig script tag: `{% script module 'theme://js/module.mjs' %}`\n    * Added twig tag for links: `{% link icon 'theme://images/favicon.png' priority: 20 with { type: 'image/png' } %}`\n    * Added `HtmlBlock` support for `{% style %}`, `{% script %}` and `{% link %}` tags\n    * Support for page-level `redirect_default_route` frontmatter header override\n3. [](#bugfix)\n    * Fixed XSS check not detecting escaped `&#58`\n\n# v1.7.27.1\n## 01/12/2022\n\n3. [](#bugfix)\n   * Fixed a typo in CSS Asset pipeline that was erroneously joining files with `;`\n\n# v1.7.27\n## 01/12/2022\n\n1. [](#new)\n   * Support for `YubiKey OTP` 2-Factor authenticator\n   * Added support for generic `assets.link()` for external references. No pipeline support\n   * Added support for `assets.addJsModule()` with full pipeline support\n   * Added `Utils::getExtensionsByMime()` method to get all the registered extensions for the specific mime type\n   * Added `Media::getRoute()` and `Media::getRawRoute()` methods to get page route if available\n   * Added `Medium::getAlternatives()` to be able to list all the retina sizes\n2. [](#improved)\n   * Improved `Utils::download()` method to allow overrides on download name, mime and expires header\n   * Improved `onPageFallBackUrl` event\n   * Reorganized the Asset system configuration blueprint for clarity\n3. [](#bugfix)\n   * Fixed CLI `--env` and `--lang` options having no effect if they aren't added before all the other options\n   * Fixed scaled image medium filename when using non-existing retina file\n   * Fixed an issue with JS `imports` and pipelining Assets\n\n# v1.7.26.1\n## 01/04/2022\n\n3. [](#bugfix)\n   * Fixed `UserObject::getAccess()` after cloning the object\n\n# v1.7.26\n## 01/03/2022\n\n1. [](#new)\n    * Made `Grav::redirect()` to accept `Route` class\n    * Added `translated()` method to `PageTranslateInterface`\n    * Added second parameter to `UserObject::isMyself()` method\n    * Added `UserObject::$isAuthorizedCallable` to allow `$user->isAuthorized()` customization\n    * Use secure session cookies in HTTPS by default (`system.session.secure_https: true`)\n    * Added new `Plugin::inheritedConfigOption()` function to access plugin specific functions for page overrides\n2. [](#improved)\n   * Upgraded vendor libs for PHP 8.1 compatibility\n   * Upgraded to **composer v2.1.14** for PHP 8.1 compatibility\n   * Added third `$name` parameter to `Blueprint::flattenData()` method, useful for flattening repeating data\n   * `ControllerResponseTrait`: Redirect response should be json if the extension is .json\n   * When symlinking Grav install, include also tests\n   * Updated copyright year to `2022`\n3. [](#bugfix)\n   * Fixed bad key lookup in `FlexRelatedDirectoryTrait::getCollectionByProperty()`\n   * Fixed RequestHandlers `NotFoundException` having empty request\n   * Block `.json` files in web server configs\n   * Disabled pretty debug info for Flex as it slows down Twig rendering\n   * Fixed Twig being very slow when template overrides do not exist\n   * Fixed `UserObject::$authorizeCallable` binding to the user object\n   * Fixed `FlexIndex::call()` to return null instead of failing to call undefined method\n   * Fixed Flex directory configuration creating environment configuration when it should not\n\n# v1.7.25\n## 11/16/2021\n\n1. [](#new)\n    * Updated phpstan to v1.0\n    * Added `FlexObject::getDiff()` to see difference to the saved object\n2. [](#improved)\n    * Use Symfony `dump` instead of PHP's `vardump` in side the `{{ vardump(x) }}` Twig vardump function\n    * Added `route` and `request` to `onPagesInitialized` event\n    * Improved page cloning, added method `Page::initialize()`\n    * Improved `FlexObject::getChanges()`: return changed lists and arrays as whole instead of just changed keys/values\n    * Improved form validation JSON responses to contain list of failed fields with their error messages\n    * Improved redirects: send redirect response in JSON if the request was in JSON\n3. [](#bugfix)\n    * Fixed path traversal vulnerability when using `bin/grav server`\n    * Fixed unescaped error messages in JSON error responses\n    * Fixed `|t(variable)` twig filter in admin\n    * Fixed `FlexObject::getChanges()` always returning empty array\n    * Fixed form validation exceptions to use `400 Bad Request` instead of `500 Internal Server Error`\n\n# v1.7.24\n## 10/26/2021\n\n1. [](#new)\n    * Added support for image watermarks\n    * Added support to disable a form, making it readonly\n2. [](#improved)\n    * Flex `$user->authorize()` now checks user groups before `admin.super`, allowing deny rules to work properly\n3. [](#bugfix)\n    * Fixed a bug in `PermissionsReader` in PHP 7.3\n    * Fixed `session_store_active` language option (#3464)\n    * Fixed deprecated warnings on `ArrayAccess` in PHP 8.1\n    * Fixed XSS detection with `&colon;`\n\n# v1.7.23\n## 09/29/2021\n\n1. [](#new)\n    * Added method `Pages::referrerRoute()` to get the referrer route and language\n    * Added true unique `Utils::uniqueId()` / `{{ unique_id() }}` utilities  with length, prefix, and suffix support\n    * Added `UserObject::isMyself()` method to check if flex user is currently logged in\n    * Added support for custom form field options validation with `validate: options: key|ignore`\n2. [](#improved)\n   * Replaced GPL `SVG-Sanitizer` with MIT licensed `DOM-Sanitizer`\n   * `Uri::referrer()` now accepts third parameter, if set to `true`, it returns route without base or language code [#3411](https://github.com/getgrav/grav/issues/3411)\n   * Updated vendor libs with latest\n   * Updated with latest language strings via Crowdin.com\n3. [](#bugfix)\n    * Fixed `Folder::move()` throwing an error when target folder is changed by only appending characters to the end [#3445](https://github.com/getgrav/grav/issues/3445)\n    * Fixed some phpstan issues (all code back to level 1, Framework level 3)\n    * Fixed form reset causing image uploads to fail when using Flex\n\n# v1.7.22\n## 09/16/2021\n\n1. [](#new)\n    * Register plugin autoloaders into plugin objects\n2. [](#improved)\n    * Improve Twig 2 compatibility\n    * Update to customized version of Twig DeferredExtension (Twig 1/2 compatible)\n3. [](#bugfix)\n    * Fixed conflicting `$_original` variable in `Flex Pages`\n\n# v1.7.21\n## 09/14/2021\n\n1. [](#new)\n    * Added `|yaml` filter to convert input to YAML\n    * Added `route` and `request` to `onPageNotFound` event\n    * Added file upload/remove support for `Flex Forms`\n    * Added support for `flex-required@: not exists` and `flex-required@: '!exists'` in blueprints\n    * Added `$object->getOriginalData()` to get flex objects data before it was modified with `update()`\n    * Throwing exceptions from Twig templates fires `onDisplayErrorPage.[code]` event allowing better error pages\n2. [](#improved)\n    * Use a simplified text-based `cron` field for scheduler\n    * Add timestamp to logging output of scheduler jobs to see when they ran\n3. [](#bugfix)\n    * Fixed escaping in PageIndex::getLevelListing()\n    * Fixed validation of `number` type [#3433](https://github.com/getgrav/grav/issues/3433)\n    * Fixed excessive `security.yaml` file creation [#3432](https://github.com/getgrav/grav/issues/3432)\n    * Fixed incorrect port :0 with nginx unix socket setup [#3439](https://github.com/getgrav/grav/issues/3439)\n    * Fixed `Session::setFlashCookieObject()` to use the same options as the main session cookie\n\n# v1.7.20\n## 09/01/2021\n\n2. [](#improved)\n    * Added support for `task` and `action` inside JSON request body\n\n# v1.7.19\n## 08/31/2021\n\n1. [](#new)\n    * Include active form and request in `onPageTask` and `onPageAction` events (defaults to `null`)\n    * Added `UserObject::$authorizeCallable` to allow `$user->authorize()` customization\n2. [](#improved)\n    * Added meta support for `UploadedFile` class\n    * Added support for multiple mime-types per file extension [#3422](https://github.com/getgrav/grav/issues/3422)\n    * Added `setCurrent()` method to Page Collection [#3398](https://github.com/getgrav/grav/pull/3398)\n    * Initialize `$grav['uri']` before session\n3. [](#bugfix)\n    * Fixed `Warning: Undefined array key \"SERVER_SOFTWARE\" in index.php` [#3408](https://github.com/getgrav/grav/issues/3408)\n    * Fixed error in `loadDirectoryConfig()` if configuration hasn't been saved [#3409](https://github.com/getgrav/grav/issues/3409)\n    * Fixed GPM not using non-standard cache path [#3410](https://github.com/getgrav/grav/issues/3410)\n    * Fixed broken `environment://` stream when it doesn't have configuration\n    * Fixed `Flex Object` missing key field value when using `FolderStorage`\n    * Fixed broken Twig try tag when catch has not been defined or is empty\n    * Fixed `FlexForm` serialization\n    * Fixed form validation for numeric values in PHP 8\n    * Fixed `flex-options@` in blueprints duplicating items in array\n    * Fixed wrong form issue with flex objects after cache clear\n    * Fixed Flex object types not implementing `MediaInterface`\n    * Fixed issue with `svgImageFunction()` that was causing broken output\n\n# v1.7.18\n## 07/19/2021\n\n1. [](#improved)\n    * Added support for loading Flex Directory configuration from main configuration\n    * Move SVGs that cannot be sanitized to quarantine folder under `log://quarantine`\n    * Added support for CloudFlare-forwarded client IP in the `URI::ip()` method\n1. [](#bugfix)\n    * Fixed error when using Flex `SimpleStorage` with no entries\n    * Fixed page search to include slug field [#3316](https://github.com/getgrav/grav/issues/3316)\n    * Fixed Admin becoming unusable when GPM cannot be reached [#3383](https://github.com/getgrav/grav/issues/3383)\n    * Fixed `Failed to save entry: Forbidden` when moving a page to a visible page [#3389](https://github.com/getgrav/grav/issues/3389)\n    * Better support for Symfony local server on linux [#3400](https://github.com/getgrav/grav/pull/3400)\n    * Fixed `open_basedir()` error with some forms\n\n# v1.7.17\n## 06/15/2021\n\n1. [](#new)\n    * Interface `FlexDirectoryInterface` now extends `FlexAuthorizeInterface`\n1. [](#improved)\n    * Allow to unset an asset attribute by specifying null (ie, `'defer': null`)\n    * Support specifying custom attributes to assets in a collection [Read more](https://learn.getgrav.org/17/themes/asset-manager#collections-with-attributes?target=_blank) [#3358](https://github.com/getgrav/grav/issues/3358)\n    * File `frontmatter.yaml` isn't part of media, ignore it\n    * Switched default `JQuery` collection to use 3.x rather than 2.x\n1. [](#bugfix)\n    * Fixed missing styles when CSS/JS Pipeline is used and `asset://` folder is missing\n    * Fixed permission check when moving a page [#3382](https://github.com/getgrav/grav/issues/3382)\n\n# v1.7.16\n## 06/02/2021\n\n1. [](#new)\n    * Added 'addFrame()' method to ImageMedium [#3323](https://github.com/getgrav/grav/pull/3323)\n1. [](#improved)\n    * Set `cache.clear_images_by_default` to `false` by default\n    * Improve error on bad nested form data [#3364](https://github.com/getgrav/grav/issues/3364)\n1. [](#bugfix)\n    * Improve Plugin and Theme initialization to fix PHP8 bug [#3368](https://github.com/getgrav/grav/issues/3368)\n    * Fixed `pathinfo()` twig filter in PHP7\n    * Fixed the first visible child page getting ordering number `999999.` [#3365](https://github.com/getgrav/grav/issues/3365)\n    * Fixed flex pages search using only folder name [#3316](https://github.com/getgrav/grav/issues/3316)\n    * Fixed flex pages using wrong type in `onBlueprintCreated` event [#3157](https://github.com/getgrav/grav/issues/3157)\n    * Fixed wrong SRI paths invoked when Grav instance as a sub folder [#3358](https://github.com/getgrav/grav/issues/3358)\n    * Fixed SRI trying to calculate remote assets, only ever set integrity for local files. Use the SRI provided by the remote source and manually add it in the `addJs/addCss` call for remote support. [#3358](https://github.com/getgrav/grav/issues/3358)\n    * Fix for weird regex issue with latest PHP versions on Intel Macs causing params to not parse properly in URI object\n\n# v1.7.15\n## 05/19/2021\n\n1. [](#improved)\n    * Allow optional start date in page collections [#3350](https://github.com/getgrav/grav/pull/3350)\n    * Added `page` and `output` properties to `onOutputGenerated` and `onOutputRendered` events\n1. [](#bugfix)\n    * Fixed twig deprecated TwigFilter messages [#3348](https://github.com/getgrav/grav/issues/3348)\n    * Fixed fatal error with some markdown links [getgrav/grav-premium-issues#95](https://github.com/getgrav/grav-premium-issues/issues/95)\n    * Fixed markdown media operations not working when using `image://` stream [#3333](https://github.com/getgrav/grav/issues/3333) [#3349](https://github.com/getgrav/grav/issues/3349)\n    * Fixed copying page without changing the slug [getgrav/grav-plugin-admin#2135](https://github.com/getgrav/grav-plugin-admin/issues/2139)\n    * Fixed missing and commonly used methods when using `system.twig.undefined_functions = false` [getgrav/grav-plugin-admin#2138](https://github.com/getgrav/grav-plugin-admin/issues/2138)\n    * Fixed uploading images into Flex Object if field destination is not set\n\n# v1.7.14\n## 04/29/2021\n\n1. [](#new)\n    * Added `MediaUploadTrait::checkFileMetadata()` method\n1. [](#improved)\n    * Updating a theme should always keep the custom files [getgrav/grav-plugin-admin#2135](https://github.com/getgrav/grav-plugin-admin/issues/2135)\n1. [](#bugfix)\n    * Fixed broken numeric language codes in Flex Pages [#3332](https://github.com/getgrav/grav/issues/3332)\n    * Fixed broken `exif_imagetype()` twig function\n\n# v1.7.13\n## 04/23/2021\n\n1. [](#new)\n    * Added support for getting translated collection of Flex Pages using `$collection->withTranslated('de')`\n1. [](#improved)\n    * Moved `gregwar/Image` and `gregwar/Cache` in-house to official `getgrav/Image` and `getgrav/Cache` packagist packages. This will help environments with very strict proxy setups that don't allow VCS setup. [#3289](https://github.com/getgrav/grav/issues/3289)\n    * Improved XSS Invalid Protocol detection regex [#3298](https://github.com/getgrav/grav/issues/3298)\n    * Added support for user provided folder in Flex `$page->copy()`\n1. [](#bugfix)\n    * Fixed `The \"Grav/Common/Twig/TwigExtension\" extension is not enabled` when using markdown twig tag [#3317](https://github.com/getgrav/grav/issues/3317)\n    * Fixed text field maxlength validation newline issue [#3324](https://github.com/getgrav/grav/issues/3324)\n    * Fixed a bug in Flex Object `refresh()` method\n\n# v1.7.12\n## 04/15/2021\n\n1. [](#improved)\n    * Improve JSON support for the request\n1. [](#bugfix)\n    * Fixed absolute path support for Windows [#3297](https://github.com/getgrav/grav/issues/3297)\n    * Fixed adding tags in admin after upgrading Grav [#3315](https://github.com/getgrav/grav/issues/3315)\n\n# v1.7.11\n## 04/13/2021\n\n1. [](#new)\n    * Added configuration options to allow PHP methods to be used in Twig functions (`system.twig.safe_functions`) and filters (`system.twig.safe_filters`)\n    * Deprecated using PHP methods in Twig without them being in the safe lists\n    * Prevent dangerous PHP methods from being used as Twig functions and filters\n    * Restrict filesystem Twig functions to accept only local filesystem and grav streams\n1. [](#improved)\n    * Better GPM detection of unauthorized installations\n1. [](#bugfix)\n  * **IMPORTANT** Fixed security vulnerability with Twig allowing dangerous PHP functions by default [GHSA-g8r4-p96j-xfxc](https://github.com/getgrav/grav/security/advisories/GHSA-g8r4-p96j-xfxc)\n    * Fixed nxinx appending repeating `?_url=` in some redirects\n    * Fixed deleting page with language code not removing the folder if it was the last language [#3305](https://github.com/getgrav/grav/issues/3305)\n    * Fixed fatal error when using markdown links with `image://` stream [#3285](https://github.com/getgrav/grav/issues/3285)\n    * Fixed `system.languages.session_store_active` not having any effect [#3269](https://github.com/getgrav/grav/issues/3269)\n    * Fixed fatal error if `system.pages.types` is not an array [#2984](https://github.com/getgrav/grav/issues/2984)\n\n# v1.7.10\n## 04/06/2021\n\n1. [](#new)\n    * Added initial support for running Grav library from outside the webroot [#3297](https://github.com/getgrav/grav/issues/3297)\n1. [](#improved)\n    * Improved password handling when saving a user\n1. [](#bugfix)\n    * Ignore errors when using `set_time_limit` in `Archiver` and `GPM\\Response` classes [#3023](https://github.com/getgrav/grav/issues/3023)\n    * Fixed `Folder::move()` deleting the folder if you move folder into itself, created empty file instead\n    * Fixed moving `Flex Page` to itself causing the page to be lost [#3227](https://github.com/getgrav/grav/issues/3227)\n    * Fixed `PageStorage` from detecting files as pages\n    * Fixed `UserIndex` not implementing `UserCollectionInterface`\n    * Fixed missing `onAdminAfterDelete` event call in `Flex Pages`\n    * Fixed system templates not getting scanned [#3296](https://github.com/getgrav/grav/issues/3296)\n    * Fixed incorrect routing if url path looks like a domain name [#2184](https://github.com/getgrav/grav/issues/2184)\n\n# v1.7.9\n## 03/19/2021\n\n1. [](#new)\n    * Added `Media::hide()` method to hide files from media\n    * Added `Utils::getPathFromToken()` method which works also with `Flex Objects`\n    * Added `FlexMediaTrait::getMediaField()`, which can be used to access custom media set in the blueprint fields\n    * Added `FlexMediaTrait::getFieldSettings()`, which can be used to get media field settings\n1. [](#improved)\n    * Method `Utils::getPagePathFromToken()` now calls the more generic `Utils::getPathFromToken()`\n    * Updated `SECURITY.md` to use security@getgrav.org\n1. [](#bugfix)\n    * Fixed broken media upload in `Flex` with `@self/path`, `@page` and `@theme` destinations [#3275](https://github.com/getgrav/grav/issues/3275)\n    * Fixed media fields excluding newly deleted files before saving the object\n    * Fixed method `$pages->find()` should never redirect [#3266](https://github.com/getgrav/grav/pull/3266)\n    * Fixed `Page::activeChild()` throwing an error [#3276](https://github.com/getgrav/grav/issues/3276)\n    * Fixed `Flex Page` CRUD ACL when creating a new page (needs Flex Objects plugin update) [grav-plugin-flex-objects#115](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/115)\n    * Fixed the list of pages not showing up in admin [#3280](https://github.com/getgrav/grav/issues/3280)\n    * Fixed text field min/max validation for UTF8 characters [#3281](https://github.com/getgrav/grav/issues/3281)\n    * Fixed redirects using wrong redirect code\n\n# v1.7.8\n## 03/17/2021\n\n1. [](#new)\n    * Added `ControllerResponseTrait::createDownloadResponse()` method\n    * Added full blueprint support to theme if you move existing files in `blueprints/` to `blueprints/pages/` folder [#3255](https://github.com/getgrav/grav/issues/3255)\n    * Added support for `Theme::getFormFieldTypes()` just like in plugins\n1. [](#improved)\n    * Optimized `Flex Pages` for speed\n    * Optimized saving visible/ordered pages when there are a lot of siblings [#3231](https://github.com/getgrav/grav/issues/3231)\n    * Clearing cache now deletes all clockwork files\n    * Improved `system.pages.redirect_default_route` and `system.pages.redirect_trailing_slash` configuration options to accept redirect code\n1. [](#bugfix)\n    * Fixed clockwork error when clearing cache\n    * Fixed missing method `translated()` in `Flex Pages`\n    * Fixed missing `Flex Pages` in site if multi-language support has been enabled\n    * Fixed Grav using blueprints and form fields from disabled plugins\n    * Fixed `FlexIndex::sortBy(['key' => 'ASC'])` having no effect\n    * Fixed default Flex Pages collection ordering to order by filesystem path\n    * Fixed disappearing pages on save if `pages://` stream resolves to multiple folders where the preferred folder doesn't exist\n    * Fixed Markdown image attribute `loading` [#3251](https://github.com/getgrav/grav/pull/3251)\n    * Fixed `Uri::isValidExtension()` returning false positives\n    * Fixed `page.html` returning duplicated content with `system.pages.redirect_default_route` turned on [#3130](https://github.com/getgrav/grav/issues/3130)\n    * Fixed site redirect with redirect code failing when redirecting to sub-pages [#3035](https://github.com/getgrav/grav/pull/3035/files)\n    * Fixed `Uncaught ValueError: Path cannot be empty` when failing to upload a file [#3265](https://github.com/getgrav/grav/issues/3265)\n    * Fixed `Path cannot be empty` when viewing non-existent log file [#3270](https://github.com/getgrav/grav/issues/3270)\n    * Fixed `onAdminSave` original page having empty header [#3259](https://github.com/getgrav/grav/issues/3259)\n\n# v1.7.7\n## 02/23/2021\n\n1. [](#new)\n    * Added `Utils::arrayToQueryParams()` to convert an array into query params\n1. [](#improved)\n    * Added original image support for all flex objects and media fields\n    * Improved `Pagination` class to allow custom pagination query parameter\n1. [](#bugfix)\n    * Fixed avatar of the user not being saved [grav-plugin-flex-objects#111](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/111)\n    * Replaced special space character with regular space in `system/blueprints/user/account_new.yaml`\n\n# v1.7.6\n## 02/17/2021\n\n1. [](#new)\n    * Added `Medium::attribute()` to pass arbitrary attributes [#3065](https://github.com/getgrav/grav/pull/3065)\n    * Added `Plugins::getPlugins()` and `Plugins::getPlugin($name)` to make it easier to access plugin instances [#2277](https://github.com/getgrav/grav/pull/2277)\n    * Added `regex_match` and `regex_split` twig functions [#2788](https://github.com/getgrav/grav/pull/2788)\n    * Updated all languages from [Crowdin](https://crowdin.com/project/grav-core) - Please update any translations here\n1. [](#improved)\n    * Added abstract `FlexObject`, `FlexCollection` and `FlexIndex` classes to `\\Grav\\Common\\Flex` namespace (extend those instead of Framework or Generic classes)\n    * Updated bundled `composer.phar` binary to latest version `2.0.9`\n    * Improved session fixation handling in PHP 7.4+ (cannot fix it in PHP 7.3 due to PHP bug)\n    * Added optional password/database attributes for redis in `system.yaml`\n    * Added ability to filter enabled or disabled with bin/gpm index [#3187](https://github.com/getgrav/grav/pull/3187)\n    * Added `$grav->getVersion()` or `grav.version` in twig to get the current Grav version [#3142](https://github.com/getgrav/grav/issues/3142)\n    * Added second parameter to `$blueprint->flattenData()` to include every field, including those which have no data\n    * Added support for setting session domain [#2040](https://github.com/getgrav/grav/pull/2040)\n    * Better support inheriting languages when using child themes [#3226](https://github.com/getgrav/grav/pull/3226)\n    * Added option for `FlexForm` constructor to reset the form\n1. [](#bugfix)\n    * Fixed issue with `content-security-policy` not being properly supported with `http-equiv` + support single quotes\n    * Fixed CLI progressbar in `backup` and `security` commands to use styled output [#3198](https://github.com/getgrav/grav/issues/3198)\n    * Fixed page save failing because of uploaded images [#3191](https://github.com/getgrav/grav/issues/3191)\n    * Fixed `Flex Pages` using only default language in frontend [#106](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/106)\n    * Fixed empty `route()` and `raw_route()` when getting translated pages [#3184](https://github.com/getgrav/grav/pull/3184)\n    * Fixed error on `bin/gpm plugin uninstall` [#3207](https://github.com/getgrav/grav/issues/3207)\n    * Fixed broken min/max validation for field `type: int`\n    * Fixed lowering uppercase characters in usernames when saving from frontend [#2565](https://github.com/getgrav/grav/pull/2565)\n    * Fixed save error when editing accounts that have been created with capital letters in their username [#3211](https://github.com/getgrav/grav/issues/3211)\n    * Fixed renaming flex objects key when using file storage\n    * Fixed wrong values in Admin pages list [#3214](https://github.com/getgrav/grav/issues/3214)\n    * Fixed pipelined asset using different hash when extra asset is added to before/after position [#2781](https://github.com/getgrav/grav/issues/2781)\n    * Fixed trailing slash redirect to only apply to GET/HEAD requests and use 301 status code [#3127](https://github.com/getgrav/grav/issues/3127)\n    * Fixed root page to always contain trailing slash [#3127](https://github.com/getgrav/grav/issues/3127)\n    * Fixed `<meta name=\"flattr:*\" content=\"*\">` to use name instead property [#3010](https://github.com/getgrav/grav/pull/3010)\n    * Fixed behavior of opposite filters in `Pages::getCollection()` to match Grav 1.6 [#3216](https://github.com/getgrav/grav/pull/3216)\n    * Fixed modular content with missing template file ending up using non-modular template [#3218](https://github.com/getgrav/grav/issues/3218)\n    * Fixed broken attachment image in Flex Objects Admin when `destination: self@` used [#3225](https://github.com/getgrav/grav/issues/3225)\n    * Fixed bug in page content with both markdown and twig enabled [#3223](https://github.com/getgrav/grav/issues/3223)\n\n# v1.7.5\n## 02/01/2021\n\n1. [](#bugfix)\n    * Revert: Fixed page save failing because of uploaded images [#3191](https://github.com/getgrav/grav/issues/3191) - breaking save\n\n# v1.7.4\n## 02/01/2021\n\n1. [](#new)\n    * Added `FlexForm::setSubmitMethod()` to customize form submit action\n1. [](#improved)\n    * Improved GPM error handling\n1. [](#bugfix)\n    * Fixed `bin/gpm uninstall` script not working because of bad typehint [#3172](https://github.com/getgrav/grav/issues/3172)\n    * Fixed `login: visibility_requires_access` not working in pages [#3176](https://github.com/getgrav/grav/issues/3176)\n    * Fixed cannot change image format [#3173](https://github.com/getgrav/grav/issues/3173)\n    * Fixed saving page in expert mode [#3174](https://github.com/getgrav/grav/issues/3174)\n    * Fixed exception in `$flexPage->frontmatter()` method when setting value\n    * Fixed `onBlueprintCreated` event being called multiple times in `Flex Pages` [grav-plugin-flex-objects#97](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/97)\n    * Fixed wrong ordering in page collections if `intl` extension has been enabled [#3167](https://github.com/getgrav/grav/issues/3167)\n    * Fixed page redirect to the first visible child page (needs to be routable and published, too)\n    * Fixed untranslated module pages showing up in the menu\n    * Fixed page save failing because of uploaded images [#3191](https://github.com/getgrav/grav/issues/3191)\n    * Fixed incorrect config lookup for loading in `ImageLoadingTrait` [#3192](https://github.com/getgrav/grav/issues/3192)\n\n# v1.7.3\n## 01/21/2021\n\n1. [](#improved)\n    * IMPORTANT - Please [checkout the process](https://getgrav.org/blog/grav-170-cli-self-upgrade-bug) to `self-upgrade` from CLI if you are on **Grav 1.7.0 or 1.7.1**\n    * Added support for symlinking individual plugins and themes by using `bin/grav install -p myplugin` or `-t mytheme`\n    * Added support for symlinking plugins and themes with `hebe.json` file to support custom folder structures\n    * Added support for running post-install scripts in `bin/gpm selfupgrade` if Grav was updated manually\n1. [](#bugfix)\n    * Fixed default GPM Channel back to 'stable' - this was inadvertently left as 'testing' [#3163](https://github.com/getgrav/grav/issues/3163)\n    * Fixed broken stream initialization if `environment://` paths aren't streams\n    * Fixed Clockwork debugger in sub-folder multi-site setups\n    * Fixed `Unsupported option \"curl\" passed to \"Symfony\\Component\\HttpClient\\CurlHttpClient\"` in `bin/gpm selfupgrade` [#3165](https://github.com/getgrav/grav/issues/3165)\n\n# v1.7.2\n## 01/21/2021\n\n1. [](#improved)\n    * This release was pulled due to a bug in the installer, 1.7.3 replaces it.\n\n# v1.7.1\n## 01/20/2021\n\n1. [](#bugfix)\n    * Fixed fatal error when `site.taxonomies` contains a bad value\n    * Sanitize valid Page extensions from `Page::template_format()`\n    * Fixed `bin/gpm index` erroring out [#3158](https://github.com/getgrav/grav/issues/3158)\n    * Fixed `bin/gpm selfupgrade` failing to report failed Grav update [#3116](https://github.com/getgrav/grav/issues/3116)\n    * Fixed `bin/gpm selfupgrade` error on `Call to undefined method` [#3160](https://github.com/getgrav/grav/issues/3160)\n    * Flex Pages: Fixed fatal error when trying to move a page to Root (/) [#3161](https://github.com/getgrav/grav/issues/3161)\n    * Fixed twig parsing errors in pages where twig is parsed after markdown [#3162](https://github.com/getgrav/grav/issues/3162)\n    * Fixed `lighttpd.conf` access-deny rule [#1876](https://github.com/getgrav/grav/issues/1876)\n    * Fixed page metadata being double-escaped [#3121](https://github.com/getgrav/grav/issues/3121)\n\n# v1.7.0\n## 01/19/2021\n\n1. [](#new)\n    * Requires **PHP 7.3.6**\n    * Read about this release in the [Grav 1.7 Released](https://getgrav.org/blog/grav-1.7-released) blog post\n    * Read the full list of all changes in the [Changelog on GitHub](https://github.com/getgrav/grav/blob/1.7.0/CHANGELOG.md)\n    * Please read [Grav 1.7 Upgrade Guide](https://learn.getgrav.org/17/advanced/grav-development/grav-17-upgrade-guide) before upgrading!\n    * Added support for overriding configuration by using environment variables\n    * Use PHP 7.4 serialization (the old `Serializable` methods are now final and cannot be overridden)\n    * Enabled `ETag` setting by default for 304 responses\n    * Added `FlexCollection::getDistinctValues()` to get all the assigned values from the field\n    * `Flex Pages` method `$page->header()` returns `\\Grav\\Common\\Page\\Header` object, old `Page` class still returns `stdClass`\n1. [](#improved)\n    * Make it possible to use an absolute path when loading a blueprint\n    * Make serialize methods final in `ContentBlock`, `AbstractFile`, `FormTrait`, `ObjectCollectionTrait` and `ObjectTrait`\n    * Added support for relative paths in `PageObject::getLevelListing()` [#3110](https://github.com/getgrav/grav/issues/3110)\n    * Better `--env` and `--lang` support for `bin/grav`, `bin/gpm` and `bin/plugin` console commands\n      * **BC BREAK** Shorthand for `--env`: `-e` will not work anymore as it conflicts with some plugins\n    * Added support for locking the `start` and `limit` in a Page Collection\n1. [](#bugfix)\n    * Fixed port issue with `system.custom_base_url`\n    * Hide errors with `exif_read_data` in `ImageFile`\n    * Fixed unserialize in `MarkdownFormatter` and `Framework\\File` classes\n    * Fixed pages with session messages should never be cached [#3108](https://github.com/getgrav/grav/issues/3108)\n    * Fixed `Filesystem::normalize()` with dot-dot paths\n    * Fixed Flex sorting issues [grav-plugin-flex-objects#92](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/92)\n    * Fixed Clockwork missing dumped arrays and objects\n    * Fixed fatal error in PHP 8 when trying to access root page\n    * Fixed Array->String conversion error when `languages:translations: false` [admin#1896](https://github.com/getgrav/grav-plugin-admin/issues/1896)\n    * Fixed `Inflector` methods when translation is missing `GRAV.INFLECTOR_*` translations\n    * Fixed exception when changing parent of new page [grav-plugin-admin#2018](https://github.com/getgrav/grav-plugin-admin/issues/2018)\n    * Fixed ordering issue with moving pages [grav-plugin-admin#2015](https://github.com/getgrav/grav-plugin-admin/issues/2015)\n    * Fixed Flex Pages cache not invalidating if saving an old `Page` object [#3152](https://github.com/getgrav/grav/issues/3152)\n    * Fixed multiple issues with `system.language.translations: false`\n    * Fixed page collections containing dummy items for untranslated default language [#2985](https://github.com/getgrav/grav/issues/2985)\n    * Fixed streams in `setup.php` being overridden by `system/streams.yaml` [#2450](https://github.com/getgrav/grav/issues/2450)\n    * Fixed `ERR_TOO_MANY_REDIRECTS` with HTTPS = 'On' [#3155](https://github.com/getgrav/grav/issues/3155)\n    * Fixed page collection pagination not behaving as it did in Grav 1.6\n\n# v1.7.0-rc.20\n## 12/15/2020\n\n1. [](#new)\n    * Update phpstan to version 0.12\n    * Auto-Escape enabled by default. Manually enable **Twig Compatibility** and disable **Auto-Escape** to use the old setting.\n    * Updated unit tests to use codeception 4.1\n    * Added support for setting `GRAV_ENVIRONMENT` by using environment variable or a constant\n    * Added support for setting `GRAV_SETUP_PATH` by using environment variable (constant already worked)\n    * Added support for setting `GRAV_ENVIRONMENTS_PATH` by using environment variable or a constant\n    * Added support for setting `GRAV_ENVIRONMENT_PATH` by using environment variable or a constant\n1. [](#improved)\n    * Improved `bin/grav install` command\n1. [](#bugfix)\n    * Fixed potential error when upgrading Grav\n    * Fixed broken list in `bin/gpm index` [#3092](https://github.com/getgrav/grav/issues/3092)\n    * Fixed CLI/GPM command failures returning 0 (success) value [#3017](https://github.com/getgrav/grav/issues/3017)\n    * Fixed unimplemented `PageObject::getOriginal()` call [#3098](https://github.com/getgrav/grav/issues/3098)\n    * Fixed `Argument 1 passed to Grav\\Common\\User\\DataUser\\User::filterUsername() must be of the type string` [#3101](https://github.com/getgrav/grav/issues/3101)\n    * Fixed broken check if php exif module is enabled in `ImageFile::fixOrientation()`\n    * Fixed `StaticResizeTrait::resize()` bad image height/width attributes if `null` values are passed to the method\n    * Fixed twig script/style tag `{% script 'file.js' at 'bottom' %}`, replaces broken `in` operator [#3084](https://github.com/getgrav/grav/issues/3084)\n    * Fixed dropped query params when `?` is preceded with `/` [#2964](https://github.com/getgrav/grav/issues/2964)\n\n# v1.7.0-rc.19\n## 12/02/2020\n\n1. [](#bugfix)\n    * Updated composer libraries with latest Toolbox v1.5.6 that contains critical fixes\n\n# v1.7.0-rc.18\n## 12/02/2020\n\n1. [](#new)\n    * Set minimum requirements to **PHP 7.3.6**\n    * Updated Clockwork to v5.0\n    * Added `FlexDirectoryInterface` interface\n    * Renamed `PageCollectionInterface::nonModular()` into `PageCollectionInterface::pages()` and deprecated the old method\n    * Renamed `PageCollectionInterface::modular()` into `PageCollectionInterface::modules()` and deprecated the old method'\n    * Upgraded `bin/composer.phar` to `2.0.2` which is all new and much faster\n    * Added search option `same_as` to Flex Objects\n    * Added PHP 8 compatible `function_exists()`: `Utils::functionExists()`\n    * New sites have `compatibility` features turned off by default, upgrading from older versions will keep the settings on\n1. [](#improved)\n    * Updated bundled JQuery to latest version `3.5.1`\n    * Forward a `sid` to GPM when downloading a premium package via CLI\n    * Allow `JsonFormatter` options to be passed as a string\n    * Hide Flex Pages frontend configuration (not ready for production use)\n    * Improve Flex configuration: gather views together in blueprint\n    * Added XSS detection to all forms. See [documentation](https://learn.getgrav.org/17/forms/forms/form-options#xss-checks)\n    * Better handling of missing repository index [grav-plugin-admin#1916](https://github.com/getgrav/grav-plugin-admin/issues/1916)\n    * Added support for having all sites / environments under `user/env` folder [#3072](https://github.com/getgrav/grav/issues/3072)\n    * Added `FlexObject::refresh()` method to make sure object is up to date\n1. [](#bugfix)\n    * *Menu Visibility Requires Access* Security option setting wrong frontmatter [login#265](https://github.com/getgrav/grav-plugin-login/issues/265)\n    * Accessing page with unsupported file extension (jpg, pdf, xsl) will use wrong mime type [#3031](https://github.com/getgrav/grav/issues/3031)\n    * Fixed media crashing on a bad image\n    * Fixed bug in collections where filter `type: false` did not work\n    * Fixed `print_r()` in twig\n    * Fixed sorting by groups in `Flex Users`\n    * Changing `Flex Page` template causes the other language versions of that page to lose their content [admin#1958](https://github.com/getgrav/grav-plugin-admin/issues/1958)\n    * Fixed plugins getting initialized multiple times (by CLI commands for example)\n    * Fixed `header.admin.children_display_order` in Flex Pages to work just like with regular pages\n    * Fixed `Utils::isFunctionDisabled()` method if there are spaces in `disable_functions` [#3023](https://github.com/getgrav/grav/issues/3023)\n    * Fixed potential fatal error when creating flex index using cache [#3062](https://github.com/getgrav/grav/issues/3062)\n    * Fixed fatal error in `CompiledFile` if the cached version is broken\n    * Fixed updated media missing from media when editing Flex Object after page reload\n    * Fixed issue with `config-default@` breaking on set [#1972](https://github.com/getgrav/grav-plugin-admin/issues/1971)\n    * Escape titles in Flex pages list [flex-objects#84](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/84)\n    * Fixed Purge successful message only working in Scheduler but broken in CLI and Admin [#1935](https://github.com/getgrav/grav-plugin-admin/issues/1935)\n    * Fixed `system://` stream is causing issues in Admin, making Media tab to disappear and possibly causing other issues [#3072](https://github.com/getgrav/grav/issues/3072)\n    * Fixed CLI self-upgrade from Grav 1.6 [#3079](https://github.com/getgrav/grav/issues/3079)\n    * Fixed `bin/grav yamllinter -a` and `-f` not following symlinks [#3080](https://github.com/getgrav/grav/issues/3080)\n    * Fixed `|safe_email` filter to return safe and escaped UTF-8 HTML [#3072](https://github.com/getgrav/grav/issues/3072)\n    * Fixed exception in CLI GPM and backup commands when `php-zip` is not enabled [#3075](https://github.com/getgrav/grav/issues/3075)\n    * Fix for XSS advisory [GHSA-cvmr-6428-87w9](https://github.com/getgrav/grav/security/advisories/GHSA-cvmr-6428-87w9)\n    * Fixed Flex and Page ordering to be natural and case insensitive [flex-objects#87](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/87)\n    * Fixed plugin/theme priority ordering to be numeric\n\n# v1.7.0-rc.17\n## 10/07/2020\n\n1. [](#new)\n    * Added a `Uri::getAllHeaders()` compatibility function\n1. [](#improved)\n    * Fall back through various templates scenarios if they don't exist in theme to avoid unhelpful error.\n    * Added default templates for `external.html.twig`, `default.html.twig`, and `modular.html.twig`\n    * Improve Media classes\n    * _POTENTIAL BREAKING CHANGE:_ Added reload argument to `FlexStorageInterface::getMetaData()`\n1. [](#bugfix)\n    * Fixed `Security::sanitizeSVG()` creating an empty file if SVG file cannot be parsed\n    * Fixed infinite loop in blueprints with `extend@` to a parent stream\n    * Added missing `Stream::create()` method\n    * Added missing `onBlueprintCreated` event for Flex Pages\n    * Fixed `onBlueprintCreated` firing multiple times recursively\n    * Fixed media upload failing with custom folders\n    * Fixed `unset()` in `ObjectProperty` class\n    * Fixed `FlexObject::freeMedia()` method causing media to become null\n    * Fixed bug in `Flex Form` making it impossible to set nested values\n    * Fixed `Flex User` avatar when using folder storage, also allow multiple images\n    * Fixed Referer reference during GPM calls.\n    * Fixed fatal error with toggled lists\n\n# v1.7.0-rc.16\n## 09/01/2020\n\n1. [](#new)\n    * Added a new `svg_image()` twig function to make it easier to 'include' SVG source in Twig\n    * Added a helper `Utils::fullPath()` to get the full path to a file be it stream, relative, etc.\n1. [](#improved)\n    * Added `themes` to cached blueprints and configuration\n1. [](#bugfix)\n    * Fixed `Flex Pages` issue with `getRoute()` returning path with language prefix for default language if set not to do that\n    * Fixed `Flex Pages` bug where reordering pages causes page content to disappear if default language uses wrong extension (`.md` vs `.en.md`)\n    * Fixed `Flex Pages` bug where `onAdminSave` passes page as `$event['page']` instead of `$event['object']` [#2995](https://github.com/getgrav/grav/issues/2995)\n    * Fixed `Flex Pages` bug where changing a modular page template added duplicate file [admin#1899](https://github.com/getgrav/grav-plugin-admin/issues/1899)\n    * Fixed `Flex Pages` bug where renaming slug causes bad ordering range after save [#2997](https://github.com/getgrav/grav/issues/2997)\n\n# v1.7.0-rc.15\n## 07/22/2020\n\n1. [](#bugfix)\n    * Fixed Flex index file caching [#2962](https://github.com/getgrav/grav/issues/2962)\n    * Fixed various issues with Exif data reading and images being incorrectly rotated [#1923](https://github.com/getgrav/grav-plugin-admin/issues/1923)\n\n# v1.7.0-rc.14\n## 07/09/2020\n\n1. [](#improved)\n    * Added ability to `noprocess` specific items only in Link/Image Excerpts, e.g. `http://foo.com/page?id=foo&target=_blank&noprocess=id` [#2954](https://github.com/getgrav/grav/pull/2954)\n1. [](#bugfix)\n    * Regression: Default language fix broke `Language::getLanguageURLPrefix()` and `Language::isIncludeDefaultLanguage()` methods when not using multi-language\n    * Reverted `Language::getDefault()` and `Language::getLanguage()` to return false again because of plugin compatibility (updated docblocks)\n    * Fixed UTF-8 issue in `Excerpts::getExcerptsFromHtml`\n    * Fixed some compatibility issues with recent Changes to `Assets` handling\n    * Fixed issue with `CSS_IMPORTS_REGEX` breaking with complex URLs [#2958](https://github.com/getgrav/grav/issues/2958)\n    * Moved duplicated `CSS_IMPORT_REGEX` to local variable in `AssetUtilsTrait::moveImports()`\n    * Fixed page media only accepting images [#2943](https://github.com/getgrav/grav/issues/2943)\n\n# v1.7.0-rc.13\n## 07/01/2020\n\n1. [](#new)\n    * Added support for uploading and deleting images directly in `Media`\n    * Added new `onAfterCacheClear` event\n1. [](#improved)\n    * Improved `CvsFormatter` to attempt to encode non-scalar variables into JSON before giving up\n    * Moved image loading into its own trait to be used by images+static images\n    * Adjusted asset types to enable extension of assets in class [#2937](https://github.com/getgrav/grav/pull/2937)\n    * Composer update for vendor library updates\n    * Updated bundled `composer.phar` to `2.0.0-dev`\n1. [](#bugfix)\n    * Fixed `MediaUploadTrait::copyUploadedFile()` not adding uploaded media to the collection\n    * Fixed regression in saving media to a new Flex Object [admin#1867](https://github.com/getgrav/grav-plugin-admin/issues/1867)\n    * Fixed `Trying to get property 'username' of non-object` error in Flex [flex-objects#62](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/62)\n    * Fixed retina images not working in Flex [flex-objects#64](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/64)\n    * Fixed plugin initialization in CLI\n    * Fixed broken logic in `Page::topParent()` when dealing with first-level pages\n    * Fixed broken `Flex Page` authorization for groups\n    * Fixed missing `onAdminSave` and `onAdminAfterSave` events when using `Flex Pages` and `Flex Users` [flex-objects#58](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/58)\n    * Fixed new `User Group` allowing bad group name to be saved [admin#1917](https://github.com/getgrav/grav-plugin-admin/issues/1917)\n    * Fixed `Language::getDefault()` returning false and not 'en'\n    * Fixed non-text links in `Excerpts::getExcerptFromHtml`\n    * Fixed CLI commands not properly intializing Plugins so events can fire\n\n# v1.7.0-rc.12\n## 06/08/2020\n\n1. [](#improved)\n    * Changed `Folder::hasChildren` to `Folder::countChildren`\n    * Added `Content Editor` option to user account blueprint\n1. [](#bugfix)\n    * Fixed new `Flex Page` not having correct form fields for the page type\n    * Fixed new `Flex User` erroring out on save (thanks @mikebi42)\n    * Fixed `Flex Object` request cache clear when saving object\n    * Fixed blueprint value filtering in lists [#2923](https://github.com/getgrav/grav/issues/2923)\n    * Fixed blueprint for `system.pages.hide_empty_folders` [#1925](https://github.com/getgrav/grav/issues/2925)\n    * Fixed file field in `Flex Objects` (use `Grav\\Common\\Flex\\Types\\GenericObject` instead of `FlexObject`) [flex-objects#37](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/37)\n    * Fixed saving nested file fields in `Flex Objects` [flex-objects#34](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/34)\n    * JSON Route of homepage with no ‘route’ set is valid [form#425](https://github.com/getgrav/grav-plugin-form/issues/425)\n\n# v1.7.0-rc.11\n## 05/14/2020\n\n1. [](#new)\n    * Added support for native `loading=lazy` attributes on images.  Can be set in `system.images.defaults` or per md image with `?loading=lazy` [#2910](https://github.com/getgrav/grav/issues/2910)\n1. [](#improved)\n    * Added `PageCollection::all()` to mimic Pages class\n    * Added system configuration support for `HTTP_X_Forwarded` headers (host disabled by default)\n    * Updated `PHPUserAgentParser` to 1.0.0\n    * Improved docblocks\n    * Fixed some phpstan issues\n    * Tighten vendor requirements\n1. [](#bugfix)\n    * Fix for uppercase image extensions\n    * Fix for `&` errors in HTML when passed to `Excerpts.php`\n\n# v1.7.0-rc.10\n## 04/30/2020\n\n1. [](#new)\n    * Changed `Response::get()` used by **GPM/Admin** to use [Symfony HttpClient v4.4](https://symfony.com/doc/current/components/http_client.html) (`composer install --nodev` required for Git installations)\n    * Added new `Excerpts::processLinkHtml()` method\n1. [](#bugfix)\n    * Fixed `Flex Pages` admin with PHP `intl` extension enabled when using custom page order\n    * Fixed saving non-numeric-prefix `Flex Page` changing to numeric-prefix [flex-objects#56](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/56)\n    * Copying `Flex Page` in admin does nothing [flex-objects#55](https://github.com/trilbymedia/grav-plugin-flex-objects/issues/55)\n    * Force GPM progress to be between 0-100%\n\n# v1.7.0-rc.9\n## 04/27/2020\n\n1. [](#new)\n    * Support for `webp` image format in Page Media [#1168](https://github.com/getgrav/grav/issues/1168)\n    * Added `Route::getBase()` method\n1. [](#improved)\n    * Support symlinks when saving `File`\n1. [](#bugfix)\n    * Fixed flex objects with integer keys not working [#2863](https://github.com/getgrav/grav/issues/2863)\n    * Fixed `Pages::instances()` returning null values when using `Flex Pages` [#2889](https://github.com/getgrav/grav/issues/2889)\n    * Fixed Flex Page parent `header.admin.children_display_order` setting being ignored in Admin [#2881](https://github.com/getgrav/grav/issues/2881)\n    * Implemented missing Flex `$pageCollection->batch()` and `$pageCollection->order()` methods\n    * Fixed user avatar creation for new `Flex Users` when using folder storage\n    * Fixed `Trying to access array offset on value of type null` PHP 7.4 error in `Plugin.php`\n    * Fixed Gregwar Image library using `.jpeg` for cached images, rather use `.jpg`\n    * Fixed `Flex Pages` with `00.home` page not having ordering set\n    * Fixed `Flex Pages` not updating empty content on save [#2890](https://github.com/getgrav/grav/issues/2890)\n    * Fixed creating new Flex User with file storage\n    * Fixed saving new `Flex Object` with custom key\n    * Fixed broken `Plugin::config()` method\n\n# v1.7.0-rc.8\n## 03/19/2020\n\n1. [](#new)\n    * Added `MediaTrait::freeMedia()` method to free media (and memory)\n    * Added `Folder::hasChildren()` method to determine if a folder has child folders\n1. [](#improved)\n    * Save memory when updating large flex indexes\n    * Better `Content-Encoding` handling in Apache when content compression is disabled [#2619](https://github.com/getgrav/grav/issues/2619)\n1. [](#bugfix)\n    * Fixed creating new `Flex User` when folder storage has been selected\n    * Fixed some bugs in Flex root page methods\n    * Fixed bad default redirect code in `ControllerResponseTrait::createRedirectResponse()`\n    * Fixed issue with PHP `HTTP_X_HTTP_METHOD_OVERRIDE` [#2847](https://github.com/getgrav/grav/issues/2847)\n    * Fixed numeric usernames not working in `Flex Users`\n    * Implemented missing Flex `$page->move()` method\n\n# v1.7.0-rc.7\n## 03/05/2020\n\n1. [](#new)\n    * Added `Session::regenerateId()` method to properly prevent session fixation issues\n    * Added configuration option `system.strict_mode.blueprint_compat` to maintain old `validation: strict` behavior [#1273](https://github.com/getgrav/grav/issues/1273)\n1. [](#improved)\n    * Improved Flex events\n    * Updated CLI commands to use the new methods to initialize Grav\n1. [](#bugfix)\n    * Fixed Flex Pages having broken `isFirst()`, `isLast()`, `prevSibling()`, `nextSibling()` and `adjacentSibling()`\n    * Fixed broken ordering sometimes when saving/moving visible `Flex Page` [#2837](https://github.com/getgrav/grav/issues/2837)\n    * Fixed ordering being lost when saving modular `Flex Page`\n    * Fixed `validation: strict` not working in blueprints (see `system.strict_mode.blueprint_compat` setting) [#1273](https://github.com/getgrav/grav/issues/1273)\n    * Fixed `Blueprint::extend()` and `Blueprint::embed()` not initializing dynamic properties\n    * Fixed fatal error on storing flex flash using new object without a key\n    * Regression: Fixed unchecking toggleable having no effect in Flex forms\n    * Fixed changing page template in Flex Pages [#2828](https://github.com/getgrav/grav/issues/2828)\n\n# v1.7.0-rc.6\n## 02/11/2020\n\n1. [](#new)\n    * Plugins & Themes: Call `$plugin->autoload()` and `$theme->autoload()` automatically when object gets initialized\n    * CLI: Added `$grav->initializeCli()` method\n    * Flex Directory: Implemented customizable configuration\n    * Flex Storages: Added support for renaming directory entries\n1. [](#improved)\n    * Vendor updates to latest\n1. [](#bugfix)\n    * Regression: Fixed fatal error in blueprints [#2811](https://github.com/getgrav/grav/issues/2811)\n    * Regression: Fixed bad method call in FlexDirectory::getAuthorizeRule()\n    * Regression: Fixed fatal error in admin if the site has custom permissions in `onAdminRegisterPermissions`\n    * Regression: Fixed flex user index with folder storage\n    * Regression: Fixed fatal error in `bin/plugin` command\n    * Fixed `FlexObject::triggerEvent()` not emitting events [#2816](https://github.com/getgrav/grav/issues/2816)\n    * Grav 1.7: Fixed saving Flex configuration with ignored values becoming null\n    * Grav 1.7: Fixed `bin/plugin` initialization\n    * Grav 1.7: Fixed Flex Page cache key not taking account active language\n\n# v1.7.0-rc.5\n## 02/03/2020\n\n1. [](#bugfix)\n    * Regression: Flex not working in PHP 7.2 or older\n    * Fixed creating first user from admin not clearing Flex User directory cache [#2809](https://github.com/getgrav/grav/issues/2809)\n    * Fixed Flex Pages allowing root page to be deleted\n\n# v1.7.0-rc.4\n## 02/03/2020\n\n1. [](#new)\n    * _POTENTIAL BREAKING CHANGE:_ Upgraded Parsedown to 1.7 for Parsedown-Extra 0.8. Plugins that extend Parsedown may need a fix to render as HTML\n    * Added `$grav['flex']` to access all registered Flex Directories\n    * Added `$grav->dispatchEvent()` method for PSR-14 events\n    * Added `FlexRegisterEvent` which triggers when `$grav['flex']` is being accessed the first time\n    * Added Flex cache configuration options\n    * Added `PluginsLoadedEvent` which triggers after plugins have been loaded but not yet initialized\n    * Added `SessionStartEvent` which triggers when session is started\n    * Added `PermissionsRegisterEvent` which triggers when `$grav['permissions']` is being accessed the first time\n    * Added support for Flex Directory specific configuration\n    * Added support for more advanced ACL\n    * Added `flatten_array` filter to form field validation\n    * Added support for `security@: or: [admin.super, admin.pages]` in blueprints (nested AND/OR mode support)\n1. [](#improved)\n    * Blueprint validation: Added `validate: value_type: bool|int|float|string|trim` to `array` to filter all the values inside the array\n    * Twig `url()` takes now third parameter (`true`) to return URL on non-existing file instead of returning false\n1. [](#bugfix)\n    * Grav 1.7: Fixed blueprint loading issues [#2782](https://github.com/getgrav/grav/issues/2782)\n    * Fixed PHP 7.4 compatibility issue with `Stream`\n    * Fixed new `Flex Users` being stored with wrong filename, login issues [#2785](https://github.com/getgrav/grav/issues/2785)\n    * Fixed `ignore_empty: true` not removing empty values in blueprint filtering\n    * Fixed `{{ false|string }}` twig to return '0' instead of ''\n    * Fixed twig `url()` failing if stream has extra slash in it (e.g. `user:///data`)\n    * Fixed `Blueprint::filter()` returning null instead of array if there is nothing to return\n    * Fixed `Cannot use a scalar value as an array` error in `Utils::arrayUnflattenDotNotation()`, ignore nested structure instead\n    * Fixed `Route` instance in multi-site setups\n    * Fixed `system.translations: false` breaking `Inflector` methods\n    * Fixed filtering ignored (eg. `security@: admin.super`) fields causing `Flex Objects` to lose data on save\n    * Grav 1.7: Fixed `Flex Pages` unserialize issues if Flex-Objects Plugin has not been installed\n    * Grav 1.7: Require Flex-Objects Plugin to edit Flex Accounts\n    * Grav 1.7: Fixed bad result on testing `isPage()` when using Flex Pages\n\n# v1.7.0-rc.3\n## 01/02/2020\n\n1. [](#new)\n    * Added root page support for `Flex Pages`\n1. [](#improved)\n    * Twig filter `|yaml_serialize`: added support for `JsonSerializable` objects and other array-like objects\n    * Added support for returning Flex Page specific permissions for admin and testing\n    * Updated copyright dates to `2020`\n    * Various vendor updates\n1. [](#bugfix)\n    * Grav 1.7: Fixed error on page initialization [#2753](https://github.com/getgrav/grav/issues/2753)\n    * Fixed checking ACL for another user (who is not currently logged in) in a Flex Object or Directory\n    * Fixed bug in Windows where `Filesystem::dirname()` returns backslashes\n    * Fixed Flex object issues in Windows [#2773](https://github.com/getgrav/grav/issues/2773)\n\n# v1.7.0-rc.2\n## 12/04/2019\n\n1. [](#new)\n    * Updated Symfony Components to 4.4\n    * Added support for page specific CRUD permissions (`Flex Pages` only)\n    * Added new `-r <job-id>` option for Scheduler CLI command to force-run a job [#2720](https://github.com/getgrav/grav/issues/2720)\n    * Added `Utils::isAssoc()` and `Utils::isNegative()` helper methods\n    * Changed `UserInterface::authorize()` to return `null` having the same meaning as `false` if access is denied because of no matching rule\n    * Changed `FlexAuthorizeInterface::isAuthorized()` to return `null` having the same meaning as `false` if access is denied because of no matching rule\n    * Moved all Flex type classes under `Grav\\Common\\Flex`\n    * DEPRECATED `Grav\\Common\\User\\Group` in favor of `$grav['user_groups']`, which contains Flex UserGroup collection\n    * DEPRECATED `$page->modular()` in favor of `$page->isModule()` for better readability\n    * Fixed phpstan issues in all code up to level 3\n1. [](#improved)\n    * Improved twig `|array` filter to work with iterators and objects with `toArray()` method\n    * Updated Flex `SimpleStorage` code to feature match the other storages\n    * Improved user and group ACL to support deny permissions (`Flex Users` only)\n    * Improved twig `authorize()` function to work better with nested rule parameters\n    * Output the current username that Scheduler is using if crontab not setup\n    * Translations: rename MODULAR to MODULE everywhere\n    * Optimized `Flex Pages` collection filtering\n    * Frontend optimizations for `Flex Pages`\n1. [](#bugfix)\n    * Regression: Fixed Grav update bug [#2722](https://github.com/getgrav/grav/issues/2722)\n    * Fixed fatal error when calling `{{ grav.undefined }}`\n    * Grav 1.7: Reverted `$object->getStorageKey()` interface as it was not a good idea, added `getMasterKey()` for pages\n    * Grav 1.7: Fixed logged in user being able to delete his own account from admin account manager\n\n# v1.7.0-rc.1\n## 11/06/2019\n\n1. [](#new)\n    * Added `Flex Pages` to Grav core and removed Flex Objects plugin dependency\n    * Added `Utils::simpleTemplate()` method for very simple variable templating\n    * Added `array_diff()` twig function\n    * Added `template_from_string()` twig function\n    * Updated Symfony Components to 4.3\n1. [](#improved)\n    * Improved `Scheduler` cron command check and more useful CLI information\n    * Improved `Flex Users`: obey blueprints and allow Flex to be used in admin only\n    * Improved `Flex` to support custom site template paths\n    * Changed Twig `{% cache %}` tag to not need unique key, and `lifetime` is now optional\n    * Added mime support for file formatters\n    * Updated built-in `composer.phar` to latest `1.9.0`\n    * Updated vendor libraries\n    * Use `Symfony EventDispatcher` directly and not rockettheme/toolbox wrapper\n1. [](#bugfix)\n    * Fixed exception caused by missing template type based on `Accept:` header [#2705](https://github.com/getgrav/grav/issues/2705)\n    * Fixed `Page::untranslatedLanguages()` not being symmetrical to `Page::translatedLanguages()`\n    * Fixed `Flex Pages` not calling `onPageProcessed` event when cached\n    * Fixed phpstan issues in Framework up to level 7\n    * Fixed issue with duplicate configuration settings in Flex Directory\n    * Fixed fatal error if there are numeric folders in `Flex Pages`\n    * Fixed error on missing `Flex` templates in if `Flex Objects` plugin isn't installed\n    * Fixed `PageTranslateTrait::getAllLanguages()` and `getAllLanguages()` to include default language\n    * Fixed multi-language saving issues with default language in `Flex Pages`\n    * Selfupgrade CLI: Fixed broken selfupgrade assets reference [#2681](https://github.com/getgrav/grav/issues/2681)\n    * Grav 1.7: Fixed PHP 7.1 compatibility issues\n    * Grav 1.7: Fixed fatal error in multi-site setups\n    * Grav 1.7: Fixed `Flex Pages` routing if using translated slugs or `system.hide_in_urls` setting\n    * Grav 1.7: Fixed bug where Flex index file couldn't be disabled\n\n# v1.7.0-beta.10\n## 10/03/2019\n\n1. [](#improved)\n    * Flex: Removed extra exists check when creating object (messes up \"non-existing\" pages)\n    * Support customizable null character replacement in `CSVFormatter::decode()`\n1. [](#bugfix)\n    * Fixed wrong Grav param separator when using `Route` class\n    * Fixed Flex User Avatar not fully backwards compatible with old user\n    * Grav 1.7: Fixed prev/next page missing pages if pagination was turned on in page header\n    * Grav 1.7: Reverted setting language for every page during initialization\n    * Grav 1.7: Fixed numeric language inconsistencies\n\n# v1.7.0-beta.9\n## 09/26/2019\n\n1. [](#new)\n    * Added a new `{% cache %}` Twig tag eliminating need for `twigcache` extension.\n1. [](#improved)\n    * Improved blueprint initialization in Flex Objects (fixes content aware fields)\n    * Improved Flex FolderStorage class to better hide storage specific logic\n    * Exception will output a badly formatted line in `CsvFormatter::decode()`\n1. [](#bugfix)\n    * Fixed error when activating Flex Accounts in GRAV system configuration (PHP 7.1)\n    * Fixed Grav parameter handling in `RouteFactory::createFromString()`\n\n# v1.7.0-beta.8\n## 09/19/2019\n\n1. [](#new)\n    * Added new `Security::sanitizeSVG()` function\n    * Backwards compatibility break: `FlexStorageInterface::getStoragePath()` and `getMediaPath()` can now return null\n1. [](#improved)\n    * Several FlexObject loading improvements\n    * Added `bin/grav page-system-validator [-r|--record] [-c|--check]` to test Flex Pages\n    * Improved language support for `Route` class\n1. [](#bugfix)\n    * Regression: Fixed language fallback\n    * Regression: Fixed translations when language code is used for non-language purposes\n    * Regression: Allow SVG avatar images for users\n    * Fixed error in `Session::getFlashObject()` if Flex Form is being used\n    * Fixed broken Twig `dump()`\n    * Fixed `Page::modular()` and `Page::modularTwig()` returning `null` for folders and other non-initialized pages\n    * Fixed 404 error when you click to non-routable menu item with children: redirect to the first child instead\n    * Fixed wrong `Pages::dispatch()` calls (with redirect) when we really meant to call `Pages::find()`\n    * Fixed avatars not being displayed with flex users [#2431](https://github.com/getgrav/grav/issues/2431)\n    * Fixed initial Flex Object state when creating a new objects in a form\n\n# v1.7.0-beta.7\n## 08/30/2019\n\n1. [](#improved)\n    * Improved language support\n1. [](#bugfix)\n    * `FlexForm`: Fixed some compatibility issues with Form plugin\n\n# v1.7.0-beta.6\n## 08/29/2019\n\n1. [](#new)\n    * Added experimental support for `Flex Pages` (**Flex Objects** plugin required)\n1. [](#improved)\n    * Improved `bin/grav yamllinter` CLI command by adding an option to find YAML Linting issues from the whole site or custom folder\n    * Added support for not instantiating pages, useful to speed up tasks\n    * Greatly improved speed of loading Flex collections\n1. [](#bugfix)\n    * Fixed `$page->summary()` always striping HTML tags if the summary was set by `$page->setSummary()`\n    * Fixed `Flex->getObject()` when using Flex Key\n    * Grav 1.7: Fixed enabling PHP Debug Bar causes fatal error in Gantry [#2634](https://github.com/getgrav/grav/issues/2634)\n    * Grav 1.7: Fixed broken taxonomies [#2633](https://github.com/getgrav/grav/issues/2633)\n    * Grav 1.7: Fixed unpublished blog posts being displayed on the front-end [#2650](https://github.com/getgrav/grav/issues/2650)\n\n# v1.7.0-beta.5\n## 08/11/2019\n\n1. [](#new)\n    * Added a new `bin/grav server` CLI command to easily run Symfony or PHP built-in webservers\n    * Added `hasFlexFeature()` method to test if `FlexObject` or `FlexCollection` implements a given feature\n    * Added `getFlexFeatures()` method to return all features that `FlexObject` or `FlexCollection` implements\n    * DEPRECATED `FlexDirectory::update()` and `FlexDirectory::remove()`\n    * Added `FlexStorage::getMetaData()` to get updated object meta information for listed keys\n    * Added `Language::getPageExtensions()` to get full list of supported page language extensions\n    * Added `$grav->close()` method to properly terminate the request with a response\n    * Added `Pages::getCollection()` method\n1. [](#improved)\n    * Better support for Symfony local server `symfony server:start`\n    * Make `Route` objects immutable\n    * `FlexDirectory::getObject()` can now be called without any parameters to create a new object\n    * Flex objects no longer return temporary key if they do not have one; empty key is returned instead\n    * Updated vendor libraries\n    * Moved `collection()` and `evaluate()` logic from `Page` class into `Pages` class\n1. [](#bugfix)\n    * Fixed `Form` not to use deleted flash object until the end of the request fixing issues with reset\n    * Fixed `FlexForm` to allow multiple form instances with non-existing objects\n    * Fixed `FlexObject` search by using `key`\n    * Grav 1.7: Fixed clockwork messages with arrays and objects\n\n# v1.7.0-beta.4\n## 07/01/2019\n\n1. [](#new)\n    * Updated with Grav 1.6.12 features, improvements & fixes\n    * Added new configuration option `system.debugger.censored` to hide potentially sensitive information\n    * Added new configuration option `system.languages.include_default_lang_file_extension` to keep default language in `.md` files if set to `false`\n    * Added configuration option to set fallback content languages individually for every language\n1. [](#improved)\n    * Updated Vendor libraries\n1. [](#bugfix)\n    * Fixed `.md` page to be assigned to the default language and to be listed in translated/untranslated page list\n    * Fixed `Language::getFallbackPageExtensions()` to fall back only to default language instead of going through all languages\n    * Fixed `Language::getFallbackPageExtensions()` returning wrong file extensions when passing custom page extension\n\n# v1.7.0-beta.3\n## 06/24/2019\n\n1. [](#bugfix)\n    * Fixed Clockwork on Windows machines\n    * Fixed parent field issues on Windows machines\n    * Fixed unreliable Clockwork calls in sub-folders\n\n# v1.7.0-beta.2\n## 06/21/2019\n\n1. [](#new)\n    * Updated with Grav 1.6.11 fixes\n1. [](#improved)\n    * Updated the Clockwork text\n\n# v1.7.0-beta.1\n## 06/14/2019\n\n1. [](#new)\n    * Added support for [Clockwork](https://underground.works/clockwork) developer tools (now default debugger)\n    * Added support for [Tideways XHProf](https://github.com/tideways/php-xhprof-extension) PHP Extension for profiling method calls\n    * Added Twig profiling for Clockwork debugger\n    * Added support for Twig 2.11 (compatible with Twig 1.40+)\n    * Optimization: Initialize debugbar only after the configuration has been loaded\n    * Optimization: Combine some early Grav processors into a single one\n\n# v1.6.31\n## 12/14/2020\n\n1. [](#improved)\n    * Allow all CSS and JS via `robots.txt` [#2006](https://github.com/getgrav/grav/issues/2006) [#3067](https://github.com/getgrav/grav/issues/3067)\n1. [](#bugfix)\n    * Fixed `pages` field escaping issues, needs admin update, too [admin#1990](https://github.com/getgrav/grav-plugin-admin/issues/1990)\n    * Fix `svg-image` issue with classes applied to all elements [#3068](https://github.com/getgrav/grav/issues/3068)\n\n# v1.6.30\n## 12/03/2020\n\n1. [](#bugfix)\n    * Rollback `samesite` cookie logic as it causes issues with PHP < 7.3 [#309](https://github.com/getgrav/grav/issues/3089)\n    * Fixed issue with `.travis.yml` due to GitHub API deprecated functionality\n\n# v1.6.29\n## 12/02/2020\n\n1. [](#new)\n    * Added basic support for `user/config/versions.yaml`\n1. [](#improved)\n    * Updated bundled JQuery to latest version `3.5.1`\n    * Forward a `sid` to GPM when downloading a premium package via CLI\n    * Better handling of missing repository index [grav-plugin-admin#1916](https://github.com/getgrav/grav-plugin-admin/issues/1916)\n    * Set `grav_cli` as referrer when using `Response` from CLI\n    * Add option for timeout in `self-upgrade` command [#3013](https://github.com/getgrav/grav/pull/3013)\n    * Allow to set SameSite from system.yaml [#3063](https://github.com/getgrav/grav/pull/3063)\n    * Update media.yaml with some MS Office mimetypes [#3070](https://github.com/getgrav/grav/pull/3070)\n1. [](#bugfix)\n    * Fixed hardcoded system folder in blueprints, config and language streams\n    * Added `.htaccess` rule to block attempts to use Twig in the request URL\n    * Fix compatibility with Symfony 4.2 and up. [#3048](https://github.com/getgrav/grav/pull/3048)\n    * Fix failing example custom shceduled job. [#3050](https://github.com/getgrav/grav/pull/3050)\n    * Fix for XSS advisory [GHSA-cvmr-6428-87w9](https://github.com/getgrav/grav/security/advisories/GHSA-cvmr-6428-87w9)\n    * Fix uploads_dangerous_extensions checking [#3060](https://github.com/getgrav/grav/pull/3060)\n    * Remove redundant prefixing of `.` to extension [#3060](https://github.com/getgrav/grav/pull/3060)\n    * Check exact extension in checkFilename utility [#3061](https://github.com/getgrav/grav/pull/3061)\n\n# v1.6.28\n## 10/07/2020\n\n1. [](#new)\n    * Back-ported twig `{% cache %}` tag from Grav 1.7\n    * Back-ported `Utils::fullPath()` helper function from Grav 1.7\n    * Back-ported `{{ svg_image() }}` Twig function from Grav 1.7\n    * Back-ported `Folder::countChildren()` function from Grav 1.7\n1. [](#improved)\n    * Use new `{{ theme_var() }}` enhanced logic from Grav 1.7\n    * Improved `Excerpts` class with fixes and functionality from Grav 1.7\n    * Ensure `onBlueprintCreated()` is initialized first\n    * Do not cache default `404` error page\n    * Composer update of vendor libraries\n    * Switched `Caddyfile` to use new Caddy2 syntax + improved usability\n1. [](#bugfix)\n    * Fixed Referer reference during GPM calls.\n    * Fixed fatal error with toggled lists\n\n# v1.6.27\n## 09/01/2020\n\n1. [](#improved)\n    * Right trim route for safety\n    * Use the proper ellipsis for summary [#2939](https://github.com/getgrav/grav/pull/2939)\n    * Left pad schedule times with zeros [#2921](https://github.com/getgrav/grav/pull/2921)\n\n# v1.6.26\n## 06/08/2020\n\n1. [](#improved)\n    * Added new configuration option to control the supported attributes in markdown links [#2882](https://github.com/getgrav/grav/issues/2882)\n1. [](#bugfix)\n    * Fixed blueprint for `system.pages.hide_empty_folders` [#1925](https://github.com/getgrav/grav/issues/2925)\n    * JSON Route of homepage with no ‘route’ set is valid\n    * Fix case-insensitive search of location header [form#425](https://github.com/getgrav/grav-plugin-form/issues/425)\n\n# v1.6.25\n## 05/14/2020\n\n1. [](#improved)\n    * Added system configuration support for `HTTP_X_Forwarded` headers (host disabled by default)\n    * Updated `PHPUserAgentParser` to 1.0.0\n    * Bump `Go` to version 1.13 in `travis.yaml`\n\n# v1.6.24\n## 04/27/2020\n\n1. [](#improved)\n    * Added support for `X-Forwarded-Host` [#2891](https://github.com/getgrav/grav/pull/2891)\n    * Disable XDebug in Travis builds\n\n# v1.6.23\n## 03/19/2020\n\n1. [](#new)\n    * Moved `Parsedown` 1.6 and `ParsedownExtra` 0.7 into `Grav\\Framework\\Parsedown` to allow fixes\n    * Added `aliases.php` with references to direct `\\Parsedown` and `\\ParsedownExtra` references\n1. [](#improved)\n    * Upgraded `jQuery` to latest 3.4.1 version [#2859](https://github.com/getgrav/grav/issues/2859)\n1. [](#bugfix)\n    * Fixed PHP 7.4 issue in ParsedownExtra [#2832](https://github.com/getgrav/grav/issues/2832)\n    * Fix for [user reported](https://twitter.com/OriginalSicksec) CVE path-based open redirect\n    * Fix for `stream_set_option` error with PHP 7.4 via Toolbox#28 [#2850](https://github.com/getgrav/grav/issues/2850)\n\n# v1.6.22\n## 03/05/2020\n\n1. [](#new)\n    * Added `Pages::reset()` method\n1. [](#improved)\n    * Updated Negotiation library to address issues [#2513](https://github.com/getgrav/grav/issues/2513)\n1. [](#bugfix)\n    * Fixed issue with search plugins not being able to switch between page translations\n    * Fixed issues with `Pages::baseRoute()` not picking up active language reliably\n    * Reverted `validation: strict` fix as it breaks sites, see [#1273](https://github.com/getgrav/grav/issues/1273)\n\n# v1.6.21\n## 02/11/2020\n\n1. [](#new)\n    * Added `ConsoleCommand::setLanguage()` method to set language to be used from CLI\n    * Added `ConsoleCommand::initializeGrav()` method to properly set up Grav instance to be used from CLI\n    * Added `ConsoleCommand::initializePlugins()`method to properly set up all plugins to be used from CLI\n    * Added `ConsoleCommand::initializeThemes()`method to properly set up current theme to be used from CLI\n    * Added `ConsoleCommand::initializePages()` method to properly set up pages to be used from CLI\n1. [](#improved)\n    * Vendor updates\n1. [](#bugfix)\n    * Fixed `bin/plugin` CLI calling `$themes->init()` way too early (removed it, use above methods instead)\n    * Fixed call to `$grav['page']` crashing CLI\n    * Fixed encoding problems when PHP INI setting `default_charset` is not `utf-8` [#2154](https://github.com/getgrav/grav/issues/2154)\n\n# v1.6.20\n## 02/03/2020\n\n1. [](#bugfix)\n    * Fixed incorrect routing caused by `str_replace()` in `Uri::init()` [#2754](https://github.com/getgrav/grav/issues/2754)\n    * Fixed session cookie is being set twice in the HTTP header [#2745](https://github.com/getgrav/grav/issues/2745)\n    * Fixed session not restarting if user was invalid (downgrading from Grav 1.7)\n    * Fixed filesystem iterator calls with non-existing folders\n    * Fixed `checkbox` field not being saved, requires also Form v4.0.2 [#1225](https://github.com/getgrav/grav/issues/1225)\n    * Fixed `validation: strict` not working in blueprints [#1273](https://github.com/getgrav/grav/issues/1273)\n    * Fixed `Data::filter()` removing empty fields (such as empty list) by default [#2805](https://github.com/getgrav/grav/issues/2805)\n    * Fixed fatal error with non-integer page param value [#2803](https://github.com/getgrav/grav/issues/2803)\n    * Fixed `Assets::addInlineJs()` parameter type mismatch between v1.5 and v1.6 [#2659](https://github.com/getgrav/grav/issues/2659)\n    * Fixed `site.metadata` saving issues [#2615](https://github.com/getgrav/grav/issues/2615)\n\n# v1.6.19\n## 12/04/2019\n\n1. [](#new)\n    * Catch PHP 7.4 deprecation messages and report them in debugbar instead of throwing fatal error\n1. [](#bugfix)\n    * Fixed fatal error when calling `{{ grav.undefined }}`\n    * Fixed multiple issues when there are no pages in the site\n    * PHP 7.4 fix for [#2750](https://github.com/getgrav/grav/issues/2750)\n\n# v1.6.18\n## 12/02/2019\n\n1. [](#bugfix)\n    * PHP 7.4 fix in `Pages::buildSort()`\n    * Updated vendor libraries for PHP 7.4 fixes in Twig and other libraries\n    * Fixed fatal error when `$page->id()` is null [#2731](https://github.com/getgrav/grav/pull/2731)\n    * Fixed cache conflicts on pages with no set id\n    * Fix rewrite rule for for `lighttpd` default config [#721](https://github.com/getgrav/grav/pull/2721)\n\n# v1.6.17\n## 11/06/2019\n\n1. [](#new)\n    * Added working ETag (304 Not Modified) support based on the final rendered HTML\n1. [](#improved)\n    * Safer file handling + customizable null char replacement in `CsvFormatter::decode()`\n    * Change of Behavior: `Inflector::hyphenize` will now automatically trim dashes at beginning and end of a string.\n    * Change in Behavior for `Folder::all()` so no longer fails if trying to copy non-existent dot file [#2581](https://github.com/getgrav/grav/pull/2581)\n    * renamed composer `test-plugins` script to `phpstan-plugins` to be more explicit [#2637](https://github.com/getgrav/grav/pull/2637)\n1. [](#bugfix)\n    * Fixed PHP 7.1 bug in FlexMedia\n    * Fix cache image generation when using cropResize [#2639](https://github.com/getgrav/grav/pull/2639)\n    * Fix `array_merge()` exception with non-array page header metadata [#2701](https://github.com/getgrav/grav/pull/2701)\n\n# v1.6.16\n## 09/19/2019\n\n1. [](#bugfix)\n    * Fixed Flex user creation if file storage is being used [#2444](https://github.com/getgrav/grav/issues/2444)\n    * Fixed `Badly encoded JSON data` warning when uploading files [#2663](https://github.com/getgrav/grav/issues/2663)\n\n# v1.6.15\n## 08/20/2019\n\n1. [](#improved)\n    * Improved robots.txt [#2632](https://github.com/getgrav/grav/issues/2632)\n1. [](#bugfix)\n    * Fixed broken markdown Twig tag [#2635](https://github.com/getgrav/grav/issues/2635)\n    * Force Symfony 4.2 in Grav 1.6 to remove a bunch of deprecated messages\n\n# v1.6.14\n## 08/18/2019\n\n1. [](#bugfix)\n    * Actually include fix for `system\\router.php` [#2627](https://github.com/getgrav/grav/issues/2627)\n\n# v1.6.13\n## 08/16/2019\n\n1. [](#bugfix)\n    * Regression fix for `system\\router.php` [#2627](https://github.com/getgrav/grav/issues/2627)\n\n# v1.6.12\n## 08/14/2019\n\n1. [](#new)\n    * Added support for custom `FormFlash` save locations\n    * Added a new `Utils::arrayLower()` method for lowercasing arrays\n    * Support new GRAV_BASEDIR environment variable [#2541](https://github.com/getgrav/grav/pull/2541)\n    * Allow users to override plugin handler priorities [#2165](https://github.com/getgrav/grav/pull/2165)\n1. [](#improved)\n    * Use new `Utils::getSupportedPageTypes()` to enforce `html,htm` at the front of the list [#2531](https://github.com/getgrav/grav/issues/2531)\n    * Updated vendor libraries\n    * Markdown filter is now page-aware so that it works with modular references [admin#1731](https://github.com/getgrav/grav-plugin-admin/issues/1731)\n    * Check of `GRAV_USER_INSTANCE` constant is already defined [#2621](https://github.com/getgrav/grav/pull/2621)\n1. [](#bugfix)\n    * Fixed some potential issues when `$grav['user']` is not set\n    * Fixed error when calling `Media::add($name, null)`\n    * Fixed `url()` returning wrong path if using stream with grav root path in it, eg: `user-data://shop` when Grav is in `/shop`\n    * Fixed `url()` not returning a path to non-existing file (`user-data://shop` => `/user/data/shop`) if it is set to fail gracefully\n    * Fixed `url()` returning false on unknown streams, such as `ftp://domain.com`, they should be treated as external URL\n    * Fixed Flex User to have permissions to save and delete his own user\n    * Fixed new Flex User creation not being possible because of username could not be given\n    * Fixed fatal error 'Expiration date must be an integer, a DateInterval or null, \"double\" given' [#2529](https://github.com/getgrav/grav/issues/2529)\n    * Fixed non-existing Flex object having a bad media folder\n    * Fixed collections using `page@.self:` should allow modular pages if requested\n    * Fixed an error when trying to delete a file from non-existing Flex Object\n    * Fixed `FlexObject::exists()` failing sometimes just after the object has been saved\n    * Fixed CSV formatter not encoding strings with `\"` and `,` properly\n    * Fixed var order in `Validation.php` [#2610](https://github.com/getgrav/grav/issues/2610)\n\n# v1.6.11\n## 06/21/2019\n\n1. [](#new)\n    * Added `FormTrait::getAllFlashes()` method to get all the available form flash objects for the form\n    * Added creation and update timestamps to `FormFlash` objects\n1. [](#improved)\n    * Added `FormFlashInterface`, changed constructor to take `$config` array\n1. [](#bugfix)\n    * Fixed error in `ImageMedium::url()` if the image cache folder does not exist\n    * Fixed empty form flash name after file upload or form state update\n    * Fixed a bug in `Route::withParam()` method\n    * Fixed issue with `FormFlash` objects when there is no session initialized\n\n# v1.6.10\n## 06/14/2019\n\n1. [](#improved)\n    * Added **page blueprints** to `YamlLinter` CLI and Admin reports\n    * Removed `Gitter` and `Slack` [#2502](https://github.com/getgrav/grav/issues/2502)\n    * Optimizations for Plugin/Theme loading\n    * Generalized markdown classes so they can be used outside of `Page` scope with a custom `Excerpts` class instance\n    * Change minimal port number to 0 (unix socket) [#2452](https://github.com/getgrav/grav/issues/2452)\n1. [](#bugfix)\n    * Force question to install demo content in theme update [#2493](https://github.com/getgrav/grav/issues/2493)\n    * Fixed GPM errors from blueprints not being logged [#2505](https://github.com/getgrav/grav/issues/2505)\n    * Don't error when IP is invalid [#2507](https://github.com/getgrav/grav/issues/2507)\n    * Fixed regression with `bin/plugin` not listing the plugins available (1c725c0)\n    * Fixed bitwise operator in `TwigExtension::exifFunc()` [#2518](https://github.com/getgrav/grav/issues/2518)\n    * Fixed issue with lang prefix incorrectly identifying as admin [#2511](https://github.com/getgrav/grav/issues/2511)\n    * Fixed issue with `U0ils::pathPrefixedBYLanguageCode()` and trailing slash [#2510](https://github.com/getgrav/grav/issues/2511)\n    * 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)\n\n# v1.6.9\n## 05/09/2019\n\n1. [](#new)\n    * Added `Route::withoutParams()` methods\n    * Added `Pages::setCheckMethod()` method to override page configuration in Admin Plugin\n    * Added `Cache::clearCache('invalidate')` parameter for just invalidating the cache without deleting any cached files\n    * Made `UserCollectionInderface` to extend `Countable` to get the count of existing users\n1. [](#improved)\n    * Flex admin: added default search options for flex objects\n    * Flex collection and object now fall back to the default template if template file doesn't exist\n    * Updated Vendor libraries including Twig 1.40.1\n    * Updated language files from `https://crowdin.com/project/grav-core`\n1. [](#bugfix)\n    * Fixed `$grav['route']` from being modified when the route instance gets modified\n    * Fixed Assets options array mixed with standalone priority [#2477](https://github.com/getgrav/grav/issues/2477)\n    * Fix for `avatar_url` provided by 3rd party providers\n    * Fixed non standard `lang` code lengths in `Utils` and `Session` detection\n    * Fixed saving a new object in Flex `SimpleStorage`\n    * Fixed exception in `Flex::getDirectories()` if the first parameter is set\n    * Output correct \"Last Updated\" in `bin/gpm info` command\n    * Checkbox getting interpreted as string, so created new `Validation::filterCheckbox()`\n    * Fixed backwards compatibility to `select` field with `selectize.create` set to true [git-sync#141](https://github.com/trilbymedia/grav-plugin-git-sync/issues/141)\n    * Fixed `YamlFormatter::decode()` to always return array [#2494](https://github.com/getgrav/grav/pull/2494)\n    * Fixed empty `$grav['request']->getAttribute('route')->getExtension()`\n\n# v1.6.8\n## 04/23/2019\n\n1. [](#new)\n    * Added `FlexCollection::filterBy()` method\n1. [](#bugfix)\n    * Revert `Use Null Coalesce Operator` [#2466](https://github.com/getgrav/grav/pull/2466)\n    * Fixed `FormTrait::render()` not providing config variable\n    * Updated `bin/grav clean` to clear `cache/compiled` and `user/config/security.yaml`\n\n# v1.6.7\n## 04/22/2019\n\n1. [](#new)\n    * Added a new `bin/grav yamllinter` CLI command to find YAML Linting issues [#2468](https://github.com/getgrav/grav/issues/2468#issuecomment-485151681)\n1. [](#improved)\n    * Improve `FormTrait` backwards compatibility with existing forms\n    * Added a new `Utils::getSubnet()` function for IPv4/IPv6 parsing [#2465](https://github.com/getgrav/grav/pull/2465)\n1. [](#bugfix)\n    * Remove disabled fields from the form schema\n    * Fix issue when excluding `inlineJs` and `inlineCss` from Assets pipeline [#2468](https://github.com/getgrav/grav/issues/2468)\n    * Fix for manually set position on external URLs [#2470](https://github.com/getgrav/grav/issues/2470)\n\n# v1.6.6\n## 04/17/2019\n\n1. [](#new)\n    * `FormInterface` now implements `RenderInterface`\n    * Added new `FormInterface::getTask()` method which reads the task from `form.task` in the blueprint\n1. [](#improved)\n    * Updated vendor libraries to latest\n1. [](#bugfix)\n    * Rollback `redirect_default_route` logic as it has issues with multi-lang [#2459](https://github.com/getgrav/grav/issues/2459)\n    * Fix potential issue with `|contains` Twig filter on PHP 7.3\n    * Fixed bug in text field filtering: return empty string if value isn't a string or number [#2460](https://github.com/getgrav/grav/issues/2460)\n    * Force Asset `priority` to be an integer and not throw error if invalid string passed [#2461](https://github.com/getgrav/grav/issues/2461)\n    * Fixed bug in text field filtering: return empty string if value isn't a string or number\n    * Fixed `FlexForm` missing getter methods for defining form variables\n\n# v1.6.5\n## 04/15/2019\n\n1. [](#bugfix)\n    * Backwards compatiblity with old `Uri::__toString()` output\n\n# v1.6.4\n## 04/15/2019\n\n1. [](#bugfix)\n    * Improved `redirect_default_route` logic as well as `Uri::toArray()` to take into account `root_path` and `extension`\n    * Rework logic to pull out excluded files from pipeline more reliably [#2445](https://github.com/getgrav/grav/issues/2445)\n    * Better logic in `Utils::normalizePath` to handle externals properly [#2216](https://github.com/getgrav/grav/issues/2216)\n    * Fixed to force all `Page::taxonomy` to be treated as strings [#2446](https://github.com/getgrav/grav/issues/2446)\n    * Fixed issue with `Grav['user']` not being available [form#332](https://github.com/getgrav/grav-plugin-form/issues/332)\n    * Updated rounding logic for `Utils::parseSize()` [#2394](https://github.com/getgrav/grav/issues/2394)\n    * Fixed Flex simple storage not being properly initialized if used with caching\n\n# v1.6.3\n## 04/12/2019\n\n1. [](#new)\n    * Added `Blueprint::addDynamicHandler()` method to allow custom dynamic handlers, for example `custom-options@: getCustomOptions`\n1. [](#bugfix)\n    * Missed a `CacheCommand` reference in `bin/grav` [#2442](https://github.com/getgrav/grav/issues/2442)\n    * Fixed issue with `Utils::normalizePath` messing with external URLs [#2216](https://github.com/getgrav/grav/issues/2216)\n    * Fix for `vUndefined` versions when upgrading\n\n# v1.6.2\n## 04/11/2019\n\n1. [](#bugfix)\n    * Revert renaming of `ClearCacheCommand` to ensure CLI GPM upgrades go smoothly\n\n# v1.6.1\n## 04/11/2019\n\n1. [](#improved)\n    * Improved CSS for the bottom filter bar of DebugBar\n1. [](#bugfix)\n    * Fixed issue with `@import` not being added to top of pipelined css [#2440](https://github.com/getgrav/grav/issues/2440)\n\n# v1.6.0\n## 04/11/2019\n\n1. [](#new)\n    * Set minimum requirements to [PHP 7.1.3](https://getgrav.org/blog/raising-php-requirements-2018)\n    * New `Scheduler` functionality for periodic jobs\n    * New `Backup` functionality with multiple backup profiles and scheduler integration\n    * Refactored `Assets Manager` to be more powerful and flexible\n    * Updated Doctrine Collections to 1.6\n    * Updated Doctrine Cache to 1.8\n    * Updated Symfony Components to 4.2\n    * Added new Cache purge functionality old cache manually via CLI/Admin as well as scheduler integration\n    * Added new `{% throw 404 'Not Found' %}` twig tag (with custom code/message)\n    * Added `Grav\\Framework\\File` classes for handling YAML, Markdown, JSON, INI and PHP serialized files\n    * Added `Grav\\Framework\\Collection\\AbstractIndexCollection` class\n    * Added `Grav\\Framework\\Object\\ObjectIndex` class\n    * Added `Grav\\Framework\\Flex` classes\n    * Added support for hiding form fields in blueprints by using dynamic property like `security@: admin.foobar`, `scope@: object` or `scope-ignore@: object` to any field\n    * New experimental **FlexObjects** powered `Users` for increased performance and capability (**disabled** by default)\n    * Added PSR-7 and PSR-15 classes\n    * Added `Grav\\Framework\\DI\\Container` class\n    * Added `Grav\\Framework\\RequestHandler\\RequestHandler` class\n    * Added `Page::httpResponseCode()` and `Page::httpHeaders()` methods\n    * Added `Grav\\Framework\\Form\\Interfaces\\FormInterface`\n    * Added `Grav\\Framework\\Form\\Interfaces\\FormFactoryInterface`\n    * Added `Grav\\Framework\\Form\\FormTrait`\n    * Added `Page::forms()` method to get normalized list of all form headers defined in the page\n    * Added `onPageAction`, `onPageTask`, `onPageAction.{$action}` and `onPageTask.{$task}` events\n    * Added `Blueprint::processForm()` method to filter form inputs\n    * Move `processMarkdown()` method from `TwigExtension` to more general `Utils` class\n    * Added support to include extra files into `Media` (such as uploaded files)\n    * Added form preview support for `FlexObject`, including a way to render newly uploaded files before saving them\n    * Added `FlexObject::getChanges()` to determine what fields change during an update\n    * Added `arrayDiffMultidimensional`, `arrayIsAssociative`, `arrayCombine` Util functions\n    * New `$grav['users']` service to allow custom user classes implementing `UserInterface`\n    * Added `LogViewer` helper class and CLI command: `bin/grav logviewer`\n    * Added `select()` and `unselect()` methods to `CollectionInterface` and its base classes\n    * Added `orderBy()` and `limit()` methods to `ObjectCollectionInterface` and its base classes\n    * Added `user-data://` which is a writable stream (`user://data` is not and should be avoided)\n    * Added support for `/action:{$action}` (like task but used without nonce when only receiving data)\n    * Added `onAction.{$action}` event\n    * Added `Grav\\Framework\\Form\\FormFlash` class to contain AJAX uploaded files in more reliable way\n    * Added `Grav\\Framework\\Form\\FormFlashFile` class which implements `UploadedFileInterface` from PSR-7\n    * Added `Grav\\Framework\\Filesystem\\Filesystem` class with methods to manipulate stream URLs\n    * Added new `$grav['filesystem']` service using an instance of the new `Filesystem` object\n    * Added `{% render object layout: 'default' with { variable: true } %}` for Flex objects and collections\n    * Added `$grav->setup()` to simplify CLI and custom access points\n    * Added `CsvFormatter` and `CsvFile` classes\n    * Added new system config option to `pages.hide_empty_folders` if a folder has no valid `.md` file available. Default behavior is `false` for compatibility.\n    * Added new system config option for `languages.pages_fallback_only` forcing only 'fallback' to find page content through supported languages, default behavior is to display any language found if active language is missing\n    * Added `Utils::arrayFlattenDotNotation()` and `Utils::arrayUnflattenDotNotation()` helper methods\n1. [](#improved)\n    * Add the page to onMarkdownInitialized event [#2412](https://github.com/getgrav/grav/issues/2412)\n    * Doctrine filecache is now namespaced with prefix to support purging\n    * Register all page types into `blueprint://pages` stream\n    * Removed `apc` and `xcache` support, made `apc` alias of `apcu`\n    * Support admin and regular translations via the `|t` twig filter and `t()` twig function\n    * Improved Grav Core installer/updater to run installer script\n    * Updated vendor libraries including Symfony `4.2.3`\n    * Renamed old `User` class to `Grav\\Common\\User\\DataUser\\User` with multiple improvements and small fixes\n    * `User` class now acts as a compatibility layer to older versions of Grav\n    * Deprecated `new User()`, `User::load()`, `User::find()` and `User::delete()` in favor of `$grav['users']` service\n    * `Media` constructor has now support to not to initialize the media objects\n    * Cleanly handle session corruption due to changing Flex object types\n    * Added `FlexObjectInterface::getDefaultValue()` and `FormInterface::getDefaultValue()`\n    * Added new `onPageContent()` event for every call to `Page::content()`\n    * Added phpstan: PHP Static Analysis Tool [#2393](https://github.com/getgrav/grav/pull/2393)\n    * Added `composer test-plugins` to test plugin issues with the current version of Grav\n    * Added `Flex::getObjects()` and `Flex::getMixedCollection()` methods for co-mingled collections\n    * Added support to use single Flex key parameter in `Flex::getObject()` method\n    * Added `FlexObjectInterface::search()` and `FlexCollectionInterface::search()` methods\n    * Override `system.media.upload_limit` with PHP's `post_max_size` or `upload_max_filesize`\n    * Class `Grav\\Common\\Page\\Medium\\AbstractMedia` now use array traits instead of extending `Grav\\Common\\Getters`\n    * Implemented `Grav\\Framework\\Psr7` classes as `Nyholm/psr7` decorators\n    * Added a new `cache-clear` scheduled job to go along with `cache-purge`\n    * Renamed `Grav\\Framework\\File\\Formatter\\FormatterInterface` to `Grav\\Framework\\File\\Interfaces\\FileFormatterInterface`\n    * Improved `File::save()` to use a temporary file if file isn't locked\n    * Improved `|t` filter to better support admin `|tu` style filter if in admin\n    * Update all classes to rely on `PageInterface` instead of `Page` class\n    * Better error checking in `bin/plugin` for existence and enabled\n    * Removed `media.upload_limit` references\n    * Twig `nicenumber`: do not use 0 + string casting hack\n    * Converted Twig tags to use namespaced Twig classes\n    * Site shows error on page rather than hard-crash when page has invalid frontmatter [#2343](https://github.com/getgrav/grav/issues/2343)\n    * Added `languages.default_lang` option to override the default lang (usually first supported language)\n    * Added `Content-Type: application/json` body support for PSR-7 `ServerRequest`\n    * Remove PHP time limit in `ZipArchive`\n    * DebugBar: Resolve twig templates in deprecated backtraces in order to help locating Twig issues\n    * Added `$grav['cache']->getSimpleCache()` method for getting PSR-16 compatible cache\n    * MediaTrait: Use PSR-16 cache\n    * Improved `Utils::normalizePath()` to support non-protocol URLs\n    * Added ability to reset `Page::metadata` to allow rebuilding from automatically generated values\n    * Added back missing `page.types` field in system content configuration [admin#1612](https://github.com/getgrav/grav-plugin-admin/issues/1612)\n    * Console commands: add method for invalidating cache\n    * Updated languages\n    * Improved `$page->forms()` call, added `$page->addForms()`\n    * Updated languages from crowdin\n    * Fixed `ImageMedium` constructor warning when file does not exist\n    * Improved `Grav\\Common\\User` class; added `$user->update()` method\n    * Added trim support for text input fields `validate: trim: true`\n    * Improved `Grav\\Framework\\File\\Formatter` classes to have abstract parent class and some useful methods\n    * Support negotiated content types set via the Request `Accept:` header\n    * Support negotiated language types set via the Request `Accept-Language:` header\n    * Cleaned up and sorted the Service `idMap`\n    * Updated `Grav` container object to implement PSR-11 `ContainerInterface`\n    * Updated Grav `Processor` classes to implement PSR-15 `MiddlewareInterface`\n    * Make `Data` class to extend `JsonSerializable`\n    * Modified debugger icon to use retina space-dude version\n    * Added missing `Video::preload()` method\n    * Set session name based on `security.salt` rather than `GRAV_ROOT` [#2242](https://github.com/getgrav/grav/issues/2242)\n    * Added option to configure list of `xss_invalid_protocols` in `Security` config [#2250](https://github.com/getgrav/grav/issues/2250)\n    * Smarter `security.salt` checking now we use `security.yaml` for other options\n    * Added apcu autoloader optimization\n    * Additional helper methods in `Language`, `Languages`, and `LanguageCodes` classes\n    * Call `onFatalException` event also on internal PHP errors\n    * Built-in PHP Webserver: log requests before handling them\n    * Added support for syslog and syslog facility logging (default: 'file')\n    * Improved usability of `System` configuration blueprint with side-tabs\n 1. [](#bugfix)\n    * Fixed issue with `Truncator::truncateWords` and `Truncator::truncateLetters` when string not wrapped in tags [#2432](https://github.com/getgrav/grav/issues/2432)\n    * Fixed `Undefined method closure::fields()` when getting avatar for user, thanks @Romarain [#2422](https://github.com/getgrav/grav/issues/2422)\n    * Fixed cached images not being updated when source image is modified\n    * Fixed deleting last list item in the form\n    * Fixed issue with `Utils::url()` method would append extra `base_url` if URL already included it\n    * Fixed `mkdir(...)` race condition\n    * Fixed `Obtaining write lock failed on file...`\n    * Fixed potential undefined property in `onPageNotFound` event handling\n    * Fixed some potential issues/bugs found by phpstan\n    * Fixed regression in GPM packages casted to Array (ref, getgrav/grav-plugin-admin@e3fc4ce)\n    * Fixed session_start(): Setting option 'session.name' failed [#2408](https://github.com/getgrav/grav/issues/2408)\n    * Fixed validation for select field type with selectize\n    * Fixed validation for boolean toggles\n    * Fixed non-namespaced exceptions in scheduler\n    * Fixed trailing slash redirect in multlang environment [#2350](https://github.com/getgrav/grav/issues/2350)\n    * Fixed some issues related to Medium objects losing query string attributes\n    * Broke out Medium timestamp so it's not cleared on `reset()`s\n    * Fixed issue with `redirect_trailing_slash` losing query string [#2269](https://github.com/getgrav/grav/issues/2269)\n    * Fixed failed login if user attempts to log in with upper case non-english letters\n    * Removed extra authenticated/authorized fields when saving existing user from a form\n    * Fixed `Grav\\Framework\\Route::__toString()` returning relative URL, not relative route\n    * Fixed handling of `append_url_extension` inside of `Page::templateFormat()` [#2264](https://github.com/getgrav/grav/issues/2264)\n    * Fixed a broken language string [#2261](https://github.com/getgrav/grav/issues/2261)\n    * Fixed clearing cache having no effect on Doctrine cache\n    * Fixed `Medium::relativePath()` for streams\n    * Fixed `Object` serialization breaking if overriding `jsonSerialize()` method\n    * Fixed `YamlFormatter::decode()` when calling `init_set()` with integer\n    * Fixed session throwing error in CLI if initialized\n    * Fixed `Uri::hasStandardPort()` to support reverse proxy configurations [#1786](https://github.com/getgrav/grav/issues/1786)\n    * Use `append_url_extension` from page header to set template format if set [#2604](https://github.com/getgrav/grav/pull/2064)\n    * Fixed some bugs in Grav environment selection logic\n    * Use login provider User avatar if set\n    * Fixed `Folder::doDelete($folder, false)` removing symlink when it should not\n    * Fixed asset manager to not add empty assets when they don't exist in the filesystem\n    * Update `script` and `style` Twig tags to use the new `Assets` classes\n    * Fixed asset pipeline to rewrite remote URLs as well as local [#2216](https://github.com/getgrav/grav/issues/2216)\n\n# v1.5.10\n## 03/21/2019\n\n1. [](#new)\n    * Added new `deferred` Twig extension\n\n# v1.5.9\n## 03/20/2019\n\n1. [](#new)\n    * Added new `onPageContent()` event for every call to `Page::content()`\n1. [](#improved)\n    * Fixed phpdoc generation\n    * Updated vendor libraries\n    * Force Toolbox v1.4.2\n1. [](#bugfix)\n    * EXIF fix for streams\n    * Fix for User avatar not working due to uppercase or spaces in email [#2403](https://github.com/getgrav/grav/pull/2403)\n\n# v1.5.8\n## 02/07/2019\n\n1. [](#improved)\n    * Improved `User` unserialize to not to break the object if serialized data is not what expected\n    * Removed unused parameter [#2357](https://github.com/getgrav/grav/pull/2357)\n\n# v1.5.7\n## 01/25/2019\n\n1. [](#new)\n    * Support for AWS Cloudfront forwarded scheme header [#2297](https://github.com/getgrav/grav/pull/2297)\n1. [](#improved)\n    * Set homepage with `https://` protocol [#2299](https://github.com/getgrav/grav/pull/2299)\n    * Preserve accents in fields containing Twig expr. using unicode [#2279](https://github.com/getgrav/grav/pull/2279)\n    * Updated vendor libraries\n1. [](#bugfix)\n    * Support spaces with filenames in responsive images [#2300](https://github.com/getgrav/grav/pull/2300)\n\n# v1.5.6\n## 12/14/2018\n\n1. [](#improved)\n    * Updated InitializeProcessor.php to use lang-safe redirect [#2268](https://github.com/getgrav/grav/pull/2268)\n    * Improved user serialization to use less memory in the session\n\n# v1.5.5\n## 11/12/2018\n\n1. [](#new)\n    * Register theme prefixes as namespaces in Twig [#2210](https://github.com/getgrav/grav/pull/2210)\n1. [](#improved)\n    * Propogate error code between 400 and 600 for production sites [#2181](https://github.com/getgrav/grav/pull/2181)\n1. [](#bugfix)\n    * Remove hardcoded `302` when redirecting trailing slash [#2155](https://github.com/getgrav/grav/pull/2155)\n\n# v1.5.4\n## 11/05/2018\n\n1. [](#improved)\n    * Updated default page `index.md` with some consistency fixes [#2245](https://github.com/getgrav/grav/pull/2245)\n1. [](#bugfix)\n    * Fixed fatal error if calling `$session->invalidate()` when there's no active session\n    * Fixed typo in media.yaml for `webm` extension [#2220](https://github.com/getgrav/grav/pull/2220)\n    * Fixed markdown processing for telephone links [#2235](https://github.com/getgrav/grav/pull/2235)\n\n# v1.5.3\n## 10/08/2018\n\n1. [](#new)\n    * Added `Utils::getMimeByFilename()`, `Utils::getMimeByLocalFile()` and `Utils::checkFilename()` methods\n    * Added configurable dangerous upload extensions in `security.yaml`\n1. [](#improved)\n    * Updated vendor libraries to latest\n\n# v1.5.2\n## 10/01/2018\n\n1. [](#new)\n    * Added new `Security` class for Grav security functionality including XSS checks\n    * Added new `bin/grav security` command to scan for security issues\n    * Added new `xss()` Twig function to allow for XSS checks on strings and arrays\n    * Added `onHttpPostFilter` event to allow plugins to globally clean up XSS in the forms and tasks\n    * Added `Deprecated` tab to DebugBar to catch future incompatibilities with later Grav versions\n    * Added deprecation notices for features which will be removed in Grav 2.0\n1. [](#improved)\n    * Updated vendor libraries to latest\n1. [](#bugfix)\n    * Allow `$page->slug()` to be called before `$page->init()` without breaking the page\n    * Fix for `Page::translatedLanguages()` to use routes always [#2163](https://github.com/getgrav/grav/issues/2163)\n    * Fixed `nicetime()` twig function\n    * Allow twig tags `{% script %}`, `{% style %}` and `{% switch %}` to be placed outside of blocks\n    * Session expires in 30 mins independent from config settings [login#178](https://github.com/getgrav/grav-plugin-login/issues/178)\n\n# v1.5.1\n## 08/23/2018\n\n1. [](#new)\n    * Added static `Grav\\Common\\Yaml` class which should be used instead of `Symfony\\Component\\Yaml\\Yaml`\n1. [](#improved)\n    * Updated deprecated Twig code so it works in both in Twig 1.34+ and Twig 2.4+\n    * Switched to new Grav Yaml class to support Native + Fallback YAML libraries\n1. [](#bugfix)\n    * Broken handling of user folder in Grav URI object [#2151](https://github.com/getgrav/grav/issues/2151)\n\n# v1.5.0\n## 08/17/2018\n\n1. [](#new)\n    * Set minimum requirements to [PHP 5.6.4](https://getgrav.org/blog/raising-php-requirements-2018)\n    * Updated Doctrine Collections to 1.4\n    * Updated Symfony Components to 3.4 (with compatibility mode to fall back to Symfony YAML 2.8)\n    * Added `Uri::method()` to get current HTTP method (GET/POST etc)\n    * `FormatterInterface`: Added `getSupportedFileExtensions()` and `getDefaultFileExtension()` methods\n    * Added option to disable `SimpleCache` key validation\n    * Added support for multiple repo locations for `bin/grav install` command\n    * Added twig filters for casting values: `|string`, `|int`, `|bool`, `|float`, `|array`\n    * Made `ObjectCollection::matching()` criteria expressions to behave more like in Twig\n    * Criteria: Added support for `LENGTH()`, `LOWER()`, `UPPER()`, `LTRIM()`, `RTRIM()` and `TRIM()`\n    * Added `Grav\\Framework\\File\\Formatter` classes for encoding/decoding YAML, Markdown, JSON, INI and PHP serialized strings\n    * Added `Grav\\Framework\\Session` class to replace `RocketTheme\\Toolbox\\Session\\Session`\n    * Added `Grav\\Common\\Media` interfaces and trait; use those in `Page` and `Media` classes\n    * Added `Grav\\Common\\Page` interface to allow custom page types in the future\n    * Added setting to disable sessions from the site [#2013](https://github.com/getgrav/grav/issues/2013)\n    * Added new `strict_mode` settings in `system.yaml` for compatibility\n1. [](#improved)\n    * Improved `Utils::url()` to support query strings\n    * Display better exception message if Grav fails to initialize\n    * Added `muted` and `playsinline` support to videos [#2124](https://github.com/getgrav/grav/pull/2124)\n    * Added `MediaTrait::clearMediaCache()` to allow cache to be cleared\n    * Added `MediaTrait::getMediaCache()` to allow custom caching\n    * Improved session handling, allow all session configuration options in `system.session.options`\n1. [](#bugfix)\n    * Fix broken form nonce logic [#2121](https://github.com/getgrav/grav/pull/2121)\n    * Fixed issue with uppercase extensions and fallback media URLs [#2133](https://github.com/getgrav/grav/issues/2133)\n    * Fixed theme inheritance issue with `camel-case` that includes numbers [#2134](https://github.com/getgrav/grav/issues/2134)\n    * Typo in demo typography page [#2136](https://github.com/getgrav/grav/pull/2136)\n    * Fix for incorrect plugin order in debugger panel\n    * Made `|markdown` filter HTML safe\n    * Fixed bug in `ContentBlock` serialization\n    * Fixed `Route::withQueryParam()` to accept array values\n    * Fixed typo in truncate function [#1943](https://github.com/getgrav/grav/issues/1943)\n    * Fixed blueprint field validation: Allow numeric inputs in text fields\n\n# v1.4.8\n## 07/31/2018\n\n1. [](#improved)\n    * Add Grav version to debug bar messages tab [#2106](https://github.com/getgrav/grav/pull/2106)\n    * Add Nginx config for ddev project to `webserver-configs` [#2117](https://github.com/getgrav/grav/pull/2117)\n    * Vendor library updates\n1. [](#bugfix)\n    * Don't allow `null` to be set as Page content\n\n# v1.4.7\n## 07/13/2018\n\n1. [](#improved)\n    * Use `getFilename` instead of `getBasename` [#2087](https://github.com/getgrav/grav/issues/2087)\n1. [](#bugfix)\n    * Fix for modular page preview [#2066](https://github.com/getgrav/grav/issues/2066)\n    * `Page::routeCanonical()` should be string not array [#2069](https://github.com/getgrav/grav/issues/2069)\n\n# v1.4.6\n## 06/20/2018\n\n1. [](#improved)\n    * Manually re-added the improved SSL off-loading that was lost with Grav v1.4.0 merge [#1888](https://github.com/getgrav/grav/pull/1888)\n    * Handle multibyte strings in `truncateLetters()` [#2007](https://github.com/getgrav/grav/pull/2007)\n    * Updated robots.txt to include `/user/images/` folder [#2043](https://github.com/getgrav/grav/pull/2043)\n    * Add getter methods for original and action to the Page object [#2005](https://github.com/getgrav/grav/pull/2005)\n    * Modular template extension follows the master page extension [#2044](https://github.com/getgrav/grav/pull/2044)\n    * Vendor library updates\n1. [](#bugfix)\n    * Handle `errors.display` system property better in admin plugin [admin#1452](https://github.com/getgrav/grav-plugin-admin/issues/1452)\n    * Fix classes on non-http based protocol links [#2034](https://github.com/getgrav/grav/issues/2034)\n    * Fixed crash on IIS (Windows) with open_basedir in effect [#2053](https://github.com/getgrav/grav/issues/2053)\n    * Fixed incorrect routing with setup.php based base [#1892](https://github.com/getgrav/grav/issues/1892)\n    * Fixed image resource memory deallocation [#2045](https://github.com/getgrav/grav/pull/2045)\n    * Fixed issue with Errors `display:` option not handling integers properly [admin#1452](https://github.com/getgrav/grav-plugin-admin/issues/1452)\n\n# v1.4.5\n## 05/15/2018\n\n1. [](#bugfix)\n    * Fixed an issue with some users getting **2FA** prompt after upgrade [admin#1442](https://github.com/getgrav/grav-plugin-admin/issues/1442)\n    * Do not crash when generating URLs with arrays as parameters [#2018](https://github.com/getgrav/grav/pull/2018)\n    * Utils::truncateHTML removes whitespace when generating summaries [#2004](https://github.com/getgrav/grav/pull/2004)\n\n# v1.4.4\n## 05/11/2018\n\n1. [](#new)\n    * Added support for `Uri::post()` and `Uri::getConentType()`\n    * Added a new `Medium:thumbnailExists()` function [#1966](https://github.com/getgrav/grav/issues/1966)\n    * Added `authorized` support for 2FA\n1. [](#improved)\n    * Added default configuration for images [#1979](https://github.com/getgrav/grav/pull/1979)\n    * Added dedicated PHPUnit assertions [#1990](https://github.com/getgrav/grav/pull/1990)\n1. [](#bugfix)\n    * Use `array_key_exists` instead of `in_array + array_keys` [#1991](https://github.com/getgrav/grav/pull/1991)\n    * Fixed an issue with `custom_base_url` always causing 404 errors\n    * Improve support for regex redirects with query and params [#1983](https://github.com/getgrav/grav/issues/1983)\n    * Changed collection-based date sorting to `SORT_REGULAR` for better server compatibility [#1910](https://github.com/getgrav/grav/issues/1910)\n    * Fix hardcoded string in modular blueprint [#1933](https://github.com/getgrav/grav/pull/1993)\n\n# v1.4.3\n## 04/12/2018\n\n1. [](#new)\n    * moved Twig `sortArrayByKey` logic into `Utils::` class\n1. [](#improved)\n    * Rolled back Parsedown library to stable `1.6.4` until a better solution for `1.8.0` compatibility can fe found\n    * Updated vendor libraries to latest versions\n1. [](#bugfix)\n    * Fix for bad reference to `ZipArchive` in `GPM::Installer`\n\n# v1.4.2\n## 03/21/2018\n\n1. [](#new)\n    * Added new `|nicefilesize` Twig filter for pretty file (auto converts to bytes, kB, MB, GB, etc)\n    * Added new `regex_filter()` Twig function to values in arrays\n1. [](#improved)\n    * Added bosnian to lang codes [#1917](﻿https://github.com/getgrav/grav/issues/1917)\n    * Improved Zip extraction error codes [#1922](﻿https://github.com/getgrav/grav/issues/1922)\n1. [](#bugfix)\n    * Fixed an issue with Markdown Video and Audio that broke after Parsedown 1.7.0 Security updates [#1924](﻿https://github.com/getgrav/grav/issues/1924)\n    * Fix for case-sensitive page metadata [admin#1370](https://github.com/getgrav/grav-plugin-admin/issues/1370)\n    * Fixed missing composer requirements for the new `Grav\\Framework\\Uri` classes\n    * Added missing PSR-7 vendor library required for URI additions in Grav 1.4.0\n\n# v1.4.1\n## 03/11/2018\n\n1. [](#bugfix)\n    * Fixed session timing out because of session cookie was not being sent\n\n# v1.4.0\n## 03/09/2018\n\n1. [](#new)\n    * Added `Grav\\Framework\\Uri` classes extending PSR-7 `HTTP message UriInterface` implementation\n    * Added `Grav\\Framework\\Route` classes to allow route/link manipulation\n    * Added `$grav['uri]->getCurrentUri()` method to get `Grav\\Framework\\Uri\\Uri` instance for the current URL\n    * Added `$grav['uri]->getCurrentRoute()` method to get `Grav\\Framework\\Route\\Route` instance for the current URL\n    * Added ability to have `php` version dependencies in GPM assets\n    * Added new `{% switch %}` twig tag for more elegant if statements\n    * Added new `{% markdown %}` twig tag\n    * Added **Route Overrides** to the default page blueprint\n    * Added new `Collection::toExtendedArray()` method that's particularly useful for Json output of data\n    * Added new `|yaml_encode` and `|yaml_decode` Twig filter to convert to and from YAML\n    * Added new `read_file()` Twig function to allow you to load and display a file in Twig (Supports streams and regular paths)\n    * Added a new `Medium::exists()` method to check for file existence\n    * Moved Twig `urlFunc()` to `Utils::url()` as its so darn handy\n    * Transferred overall copyright from RocketTheme, LLC, to Trilby Media LLC\n    * Added `theme_var`, `header_var` and `body_class` Twig functions for themes\n    * Added `Grav\\Framework\\Cache` classes providing PSR-16 `Simple Cache` implementation\n    * Added `Grav\\Framework\\ContentBlock` classes for nested HTML blocks with CSS/JS assets\n    * Added `Grav\\Framework\\Object` classes for creating collections of objects\n    * Added `|nicenumber` Twig filter\n    * Added `{% try %} ... {% catch %} Error: {{ e.message }} {% endcatch %}` tag to allow basic exception handling inside Twig\n    * Added `{% script %}` and `{% style %}` tags for Twig templates\n    * Deprecated GravTrait\n1. [](#improved)\n    * Improved `Session` initialization\n    * Added ability to set a `theme_var()` option in page frontmatter\n    * Force clearing PHP `clearstatcache` and `opcache-reset` on `Cache::clear()`\n    * Better `Page.collection()` filtering support including ability to have non-published pages in collections\n    * Stopped Chrome from auto-completing admin user profile form [#1847](https://github.com/getgrav/grav/issues/1847)\n    * Support for empty `switch` field like a `checkbox`\n    * Made `modular` blueprint more flexible\n    * Code optimizations to `Utils` class [#1830](https://github.com/getgrav/grav/pull/1830)\n    * Objects: Add protected function `getElement()` to get serialized value for a single property\n    * `ObjectPropertyTrait`: Added protected functions `isPropertyLoaded()`, `offsetLoad()`, `offsetPrepare()` and `offsetSerialize()`\n    * `Grav\\Framework\\Cache`: Allow unlimited TTL\n    * Optimizations & refactoring to the test suite [#1779](https://github.com/getgrav/grav/pull/1779)\n    * Slight modification of Whoops error colors\n    * Added new configuration option `system.session.initialize` to delay session initialization if needed by a plugin\n    * Updated vendor libraries to latest versions\n    * Removed constructor from `ObjectInterface`\n    * Make it possible to include debug bar also into non-HTML responses\n    * Updated built-in JQuery to latest 3.3.1\n1. [](#bugfix)\n    * Fixed issue with image alt tag always getting empted out unless set in markdown\n    * Fixed issue with remote PHP version determination for Grav updates [#1883](https://github.com/getgrav/grav/issues/1883)\n    * Fixed issue with _illegal scheme offset_ in `Uri::convertUrl()` [page-inject#8](https://github.com/getgrav/grav-plugin-page-inject/issues/8)\n    * Properly validate YAML blueprint fields so admin can save as proper YAML now  [addresses many issues]\n    * Fixed OpenGraph metatags so only Twitter uses `name=`, and all others use `property=` [#1849](https://github.com/getgrav/grav/issues/1849)\n    * Fixed an issue with `evaluate()` and `evaluate_twig()` Twig functions that throws invalid template error\n    * Fixed issue with `|sort_by_key` twig filter if the input was null or not an array\n    * Date ordering should always be numeric [#1810](https://github.com/getgrav/grav/issues/1810)\n    * Fix for base paths containing special characters [#1799](https://github.com/getgrav/grav/issues/1799)\n    * Fix for session cookies in paths containing special characters\n    * Fix for `vundefined` error for version numbers in GPM [form#222](https://github.com/getgrav/grav-plugin-form/issues/222)\n    * Fixed `BadMethodCallException` thrown in GPM updates [#1784](https://github.com/getgrav/grav/issues/1784)\n    * NOTE: Parsedown security release now escapes `&` to `&amp;` in Markdown links\n\n# v1.3.10\n## 12/06/2017\n\n1. [](#bugfix)\n    * Reverted GPM Local pull request as it broken admin [#1742](https://github.com/getgrav/grav/issues/1742)\n\n# v1.3.9\n## 12/05/2017\n\n1. [](#new)\n    * Added new core Twig templates for `partials/metadata.html.twig` and `partials/messages.html.twig`\n    * Added ability to work with GPM locally [#1742](https://github.com/getgrav/grav/issues/1742)\n    * Added new HTML5 audio controls [#1756](https://github.com/getgrav/grav/issues/1756)\n    * Added `Medium::copy()` method to create a copy of a medium object\n    * Added new `force_lowercase_urls` functionality on routes and slugs\n    * Added new `item-list` filter type to remove empty items\n    * Added new `setFlashCookieObject()` and `getFlashCookieObject()` methods to `Session` object\n    * Added new `intl_enabled` option to disable PHP intl module collation when not needed\n1. [](#bugfix)\n    * Fixed an issue with checkbox field validation [form#216](https://github.com/getgrav/grav-plugin-form/issues/216)\n    * Fixed issue with multibyte Markdown link URLs [#1749](https://github.com/getgrav/grav/issues/1749)\n    * Fixed issue with multibyte folder names [#1751](https://github.com/getgrav/grav/issues/1751)\n    * Fixed several issues related to `system.custom_base_url` that were broken [#1736](https://github.com/getgrav/grav/issues/1736)\n    * Dynamically added pages via `Pages::addPage()` were not firing `onPageProcessed()` event causing forms not to be processed\n    * Fixed `Page::active()` and `Page::activeChild()` to work with UTF-8 characters in the URL [#1727](https://github.com/getgrav/grav/issues/1727)\n    * Fixed typo in `modular.yaml` causing media to be ignored [#1725](https://github.com/getgrav/grav/issues/1725)\n    * Reverted `case_insensitive_urls` option as it was causing issues with taxonomy [#1733](https://github.com/getgrav/grav/pull/1733)\n    * Removed an extra `/` in `CompileFile.php` [#1693](https://github.com/getgrav/grav/pull/1693)\n    * Uri::Encode user and password to prevent issues in browsers\n    * Fixed \"Invalid AJAX response\" When using Built-in PHP Webserver in Windows [#1258](https://github.com/getgrav/grav-plugin-admin/issues/1258)\n    * Remove support for `config.user`, it was broken and bad practise\n    * Make sure that `clean cache` uses valid path [#1745](https://github.com/getgrav/grav/pull/1745)\n    * Fixed token creation issue with `Uri` params like `/id:3`\n    * Fixed CSS Pipeline failing with Google remote fonts if the file was minified [#1261](https://github.com/getgrav/grav-plugin-admin/issues/1261)\n    * Forced `field.multiple: true` to allow use of min/max options in `checkboxes.validate`\n\n# v1.3.8\n## 10/26/2017\n\n1. [](#new)\n    * Added Page `media_order` capability to manually order page media via a page header\n1. [](#bugfix)\n    * Fixed GPM update issue with filtered slugs [#1711](https://github.com/getgrav/grav/issues/1711)\n    * Fixed issue with missing image file not throwing 404 properly [#1713](https://github.com/getgrav/grav/issues/1713)\n\n# v1.3.7\n## 10/18/2017\n\n1. [](#bugfix)\n    * Regression Uri: `base_url_absolute` always has the port number [#1690](https://github.com/getgrav/grav-plugin-admin/issues/1690)\n    * Uri: Prefer using REQUEST_SCHEME instead of HTTPS [#1698](https://github.com/getgrav/grav-plugin-admin/issues/1698)\n    * Fixed routing paths with urlencoded spaces and non-latin letters [#1688](https://github.com/getgrav/grav-plugin-admin/issues/1688)\n\n# v1.3.6\n## 10/12/2017\n\n1. [](#bugfix)\n    * Regression: Ajax error in Nginx [admin#1244](https://github.com/getgrav/grav-plugin-admin/issues/1244)\n    * Remove the `_url=$uri` portion of the the Nginx `try_files` command [admin#1244](https://github.com/getgrav/grav-plugin-admin/issues/1244)\n\n# v1.3.5\n## 10/11/2017\n\n1. [](#improved)\n    * Refactored `URI` class with numerous bug fixes, and optimizations\n    * Override `system.media.upload_limit` with PHP's `post_max_size` or `upload_max_filesize`\n    * Updated `bin/grav clean` command to remove unnecessary vendor files (save some bytes)\n    * Added a `http_status_code` Twig function to allow setting HTTP status codes from Twig directly.\n    * Deter XSS attacks via URI path/uri methods (credit:newbthenewbd)\n    * Added support for `$uri->toArray()` and `(string)$uri`\n    * Added support for `type` on `Asstes::addInlineJs()` [#1683](https://github.com/getgrav/grav/pull/1683)\n1. [](#bugfix)\n    * Fixed method signature error with `GPM\\InstallCommand::processPackage()` [#1682](https://github.com/getgrav/grav/pull/1682)\n\n# v1.3.4\n## 09/29/2017\n\n1. [](#new)\n    * Added filter support for Page collections (routable/visible/type/access/etc.)\n1. [](#improved)\n    * Implemented `Composer\\CaBundle` for SSL Certs [#1241](https://github.com/getgrav/grav/issues/1241)\n    * Refactored the Assets sorting logic\n    * Improved language overrides to merge only 'extra' translations [#1514](https://github.com/getgrav/grav/issues/1514)\n    * Improved support for Assets with query strings [#1451](https://github.com/getgrav/grav/issues/1451)\n    * Twig extension cleanup\n1. [](#bugfix)\n    * Fixed an issue where fallback was not supporting dynamic page generation\n    * Fixed issue with Image query string not being fully URL encoded [#1622](https://github.com/getgrav/grav/issues/1622)\n    * Fixed `Page::summary()` when using delimiter and multibyte UTF8 Characters [#1644](https://github.com/getgrav/grav/issues/1644)\n    * Fixed missing `.json` thumbnail throwing error when adding media [grav-plugin-admin#1156](https://github.com/getgrav/grav-plugin-admin/issues/1156)\n    * Fixed insecure session cookie initialization [#1656](https://github.com/getgrav/grav/pull/1656)\n\n# v1.3.3\n## 09/07/2017\n\n1. [](#new)\n    * Added support for 2-Factor Authentication in admin profile\n    * Added `gaussianBlur` media method [#1623](https://github.com/getgrav/grav/pull/1623)\n    * Added new `|chunk_split()`, `|basename`, and `|dirname` Twig filter\n    * Added new `tl` Twig filter/function to support specific translations [#1618](https://github.com/getgrav/grav/issues/1618)\n1. [](#improved)\n    * User `authorization` now requires a check for `authenticated` - REQUIRED: `Login v2.4.0`\n    * Added options to `Page::summary()` to support size without HTML tags [#1554](https://github.com/getgrav/grav/issues/1554)\n    * Forced `natsort` on plugins to ensure consistent plugin load ordering across platforms [#1614](https://github.com/getgrav/grav/issues/1614)\n    * Use new `multilevel` field to handle Asset Collections [#1201](https://github.com/getgrav/grav-plugin-admin/issues/1201)\n    * Added support for redis `password` option [#1620](https://github.com/getgrav/grav/issues/1620)\n    * Use 302 rather than 301 redirects by default [#1619](https://github.com/getgrav/grav/issues/1619)\n    * GPM Installer will try to load alphanumeric version of the class if no standard class found [#1630](https://github.com/getgrav/grav/issues/1630)\n    * Add current page position to `User` class [#1632](https://github.com/getgrav/grav/issues/1632)\n    * Added option to enable case insensitive URLs [#1638](https://github.com/getgrav/grav/issues/1638)\n    * Updated vendor libraries\n    * Updated `travis.yml` to add support for PHP 7.1 as well as 7.0.21 for test suite\n1. [](#bugfix)\n    * Fixed UTF8 multibyte UTF8 character support in `Page::summary()` [#1554](https://github.com/getgrav/grav/issues/1554)\n\n# v1.3.2\n## 08/16/2017\n\n1. [](#new)\n    * Added a new `cache_control` system and page level property [#1591](https://github.com/getgrav/grav/issues/1591)\n    * Added a new `clear_images_by_default` system property to stop cache clear events from removing processed images [#1481](https://github.com/getgrav/grav/pull/1481)\n    * Added new `onTwigLoader()` event to enable utilization of loader methods\n    * Added new `Twig::addPath()` and `Twig::prependPath()` methods to wrap loader methods and support namespacing [#1604](https://github.com/getgrav/grav/issues/1604)\n    * Added new `array_key_exists()` Twig function wrapper\n    * Added a new `Collection::intersect()` method [#1605](https://github.com/getgrav/grav/issues/1605)\n1. [](#bugfix)\n    * Allow `session.timeout` field to be set to `0` via blueprints [#1598](https://github.com/getgrav/grav/issues/1598)\n    * Fixed `Data::exists()` and `Data::raw()` functions breaking if `Data::file()` hasn't been called with non-null value\n    * Fixed parent theme auto-loading in child themes of Gantry 5\n\n# v1.3.1\n## 07/19/2017\n\n1. [](#bugfix)\n    * Fix ordering for Linux + International environments [#1574](https://github.com/getgrav/grav/issues/1574)\n    * Check if medium thumbnail exists before resetting\n    * Update Travis' auth token\n\n# v1.3.0\n## 07/16/2017\n\n1. [](#bugfix)\n    * Fixed an undefined variable `$difference` [#1563](https://github.com/getgrav/grav/pull/1563)\n    * Fix broken range slider [grav-plugin-admin#1153](https://github.com/getgrav/grav-plugin-admin/issues/1153)\n    * Fix natural sort when > 100 pages [#1564](https://github.com/getgrav/grav/pull/1564)\n\n# v1.3.0-rc.5\n## 07/05/2017\n\n1. [](#new)\n    * Setting `system.session.timeout` to 0 clears the session when the browser session ends [#1538](https://github.com/getgrav/grav/pull/1538)\n    * Created a `CODE_OF_CONDUCT.md` so everyone knows how to behave :)\n1. [](#improved)\n    * Renamed new `media()` Twig function to `media_directory()` to avoid conflict with Page's `media` object\n1. [](#bugfix)\n    * Fixed global media files disappearing after a reload [#1545](https://github.com/getgrav/grav/issues/1545)\n    * Fix for broken regex redirects/routes via `site.yaml`\n    * Sanitize the error message in the error handler page\n\n# v1.3.0-rc.4\n## 06/22/2017\n\n1. [](#new)\n    * Added `lower` and `upper` Twig filters\n    * Added `pathinfo()` Twig function\n    * Added 165 new thumbnail images for use in `media.yaml`\n1. [](#improved)\n    * Improved error message when running `bin/grav install` instead of `bin/gpm install`, and also when running on a non-skeleton site [#1027](https://github.com/getgrav/grav/issues/1027)\n    * Updated vendor libraries\n1. [](#bugfix)\n    * Don't rebuild metadata every time, only when file does not exist\n    * Restore GravTrait in ConsoleTrait [grav-plugin-login#119](https://github.com/getgrav/grav-plugin-login/issues/119)\n    * Fix Windows routing with built-in server [#1502](https://github.com/getgrav/grav/issues/1502)\n    * Fix [#1504](https://github.com/getgrav/grav/issues/1504) `process_twig` and `frontmatter.yaml`\n    * Nicetime fix: 0 seconds from now -> just now [#1509](https://github.com/getgrav/grav/issues/1509)\n\n# v1.3.0-rc.3\n## 05/22/2017\n\n1. [](#new)\n    * Added new unified `Utils::getPagePathFromToken()` method which is used by various plugins (Admin, Forms, Downloads, etc.)\n1. [](#improved)\n    * Optionally remove unpublished pages from the translated languages, move into untranslated list [#1482](https://github.com/getgrav/grav/pull/1482)\n    * Improved reliability of `hash` file-check method\n1. [](#bugfix)\n    * Updated to latest Toolbox library to fix issue with some blueprints rendering in admin plugin [#1117](https://github.com/getgrav/grav-plugin-admin/issues/1117)\n    * Fix output handling in RenderProcessor [#1483](https://github.com/getgrav/grav/pull/1483)\n\n# v1.3.0-rc.2\n## 05/17/2017\n\n1. [](#new)\n    * Added new `media` and `vardump` Twig functions\n1. [](#improved)\n    * Put in various checks to ensure Exif is available before trying to use it\n    * Add timestamp to configuration settings [#1445](https://github.com/getgrav/grav/pull/1445)\n1. [](#bugfix)\n    * Fix an issue saving YAML textarea fields in expert mode [#1480](https://github.com/getgrav/grav/pull/1480)\n    * Moved `onOutputRendered()` back into Grav core\n\n# v1.3.0-rc.1\n## 05/16/2017\n\n1. [](#new)\n    * Added support for a single array field in the forms\n    * Added EXIF support with automatic generation of Page Media metafiles\n    * Added Twig function to get EXIF data on any image file\n    * Added `Pages::baseUrl()`, `Pages::homeUrl()` and `Pages::url()` functions\n    * Added `base32_encode`, `base32_decode`, `base64_encode`, `base64_decode` Twig filters\n    * Added `Debugger::getCaller()` to figure out where the method was called from\n    * Added support for custom output providers like Slim Framework\n    * Added `Grav\\Framework\\Collection` classes for creating collections\n1. [](#improved)\n    * Add more controls over HTML5 video attributes (autoplay, poster, loop controls) [#1442](https://github.com/getgrav/grav/pull/1442)\n    * Removed logging statement for invalid slug [#1459](https://github.com/getgrav/grav/issues/1459)\n    * Groups selection pre-filled in user form\n    * Improve error handling in `Folder::move()`\n    * Added extra parameter for `Twig::processSite()` to include custom context\n    * Updated RocketTheme Toolbox vendor library\n1. [](#bugfix)\n    * Fix to force route/redirect matching from the start of the route by default [#1446](https://github.com/getgrav/grav/issues/1446)\n    * Edit check for valid slug [#1459](https://github.com/getgrav/grav/issues/1459)\n\n# v1.2.4\n## 04/24/2017\n\n1. [](#improved)\n    * Added optional ignores for `Installer::sophisticatedInstall()` [#1447](https://github.com/getgrav/grav/issues/1447)\n1. [](#bugfix)\n    * Allow multiple calls to `Themes::initTheme()` without throwing errors\n    * Fixed querystrings in root pages with multi-lang enabled [#1436](https://github.com/getgrav/grav/issues/1436)\n    * Allow support for `Pages::getList()` with `show_modular` option [#1080](https://github.com/getgrav/grav-plugin-admin/issues/1080)\n\n# v1.2.3\n## 04/19/2017\n\n1. [](#improved)\n    * Added new `pwd_regex` and `username_regex` system configuration options to allow format modifications\n    * Allow `user/accounts.yaml` overrides and implemented more robust theme initialization\n    * improved `getList()` method to do more powerful things\n    * Fix Typo in GPM [#1427](https://github.com/getgrav/grav/issues/1427)\n\n# v1.2.2\n## 04/11/2017\n\n1. [](#bugfix)\n    * Fix for redirects breaking [#1420](https://github.com/getgrav/grav/issues/1420)\n    * Fix issue in direct-install with github-style dependencies [#1405](https://github.com/getgrav/grav/issues/1405)\n\n# v1.2.1\n## 04/10/2017\n\n1. [](#improved)\n    * Added various `ancestor` helper methods in Page and Pages classes [#1362](https://github.com/getgrav/grav/pull/1362)\n    * Added new `parents` field and switched Page blueprints to use this\n    * Added `isajaxrequest()` Twig function [#1400](https://github.com/getgrav/grav/issues/1400)\n    * Added ability to inline CSS and JS code via Asset manager [#1377](https://github.com/getgrav/grav/pull/1377)\n    * Add query string in lighttpd default config [#1393](https://github.com/getgrav/grav/issues/1393)\n    * Add `--all-yes` and `--destination` options for `bin/gpm direct-install` [#1397](https://github.com/getgrav/grav/pull/1397)\n1. [](#bugfix)\n    * Fix for direct-install of plugins with `languages.yaml` [#1396](https://github.com/getgrav/grav/issues/1396)\n    * When determining language from HTTP_ACCEPT_LANGUAGE, also try base language only [#1402](https://github.com/getgrav/grav/issues/1402)\n    * Fixed a bad method signature causing warning when running tests on `GPMTest` object\n\n# v1.2.0\n## 03/31/2017\n\n1. [](#new)\n    * Added file upload for user avatar in user/admin blueprint\n1. [](#improved)\n    * Analysis fixes\n    * Switched to stable composer lib versions\n\n# v1.2.0-rc.3\n## 03/22/2017\n\n1. [](#new)\n    * Refactored Page re-ordering to handle all siblings at once\n    * Added `language_codes` to Twig init to allow for easy language name/code/native-name lookup\n1. [](#improved)\n    * Added an _Admin Overrides_ section with option to choose the order of children in Pages Management\n1. [](#bugfix)\n    * Fixed loading issues with improperly named themes (use old broken method first) [#1373](https://github.com/getgrav/grav/issues/1373)\n    * Simplified modular/twig processing logic and fixed an issue with system process config [#1351](https://github.com/getgrav/grav/issues/1351)\n    * Cleanup package files via GPM install to make them more windows-friendly [#1361](https://github.com/getgrav/grav/pull/1361)\n    * Fix for page-level debugger override changing the option site-wide\n    * Allow `url()` twig function to pass-through external links\n\n# v1.2.0-rc.2\n## 03/17/2017\n\n1. [](#improved)\n    * Updated vendor libraries to latest\n    * Added the ability to disable debugger on a per-page basis with `debugger: false` in page frontmatter\n1. [](#bugfix)\n    * Fixed an issue with theme inheritance and hyphenated base themes [#1353](https://github.com/getgrav/grav/issues/1353)\n    * Fixed an issue when trying to use an `@2x` derivative on a non-image media file [#1341](https://github.com/getgrav/grav/issues/1341)\n\n# v1.2.0-rc.1\n## 03/13/2017\n\n1. [](#new)\n    * Added default setting to only allow `direct-installs` from official GPM.  Can be configured in `system.yaml`\n    * Added a new `Utils::isValidUrl()` method\n    * Added optional parameter to `|markdown(false)` filter to toggle block/line processing (default|true = `block`)\n    * Added new `Page::folderExists()` method\n1. [](#improved)\n    * `Twig::evaluate()` now takes current environment and context into account\n    * Genericized `direct-install` so it can be called via Admin plugin\n1. [](#bugfix)\n    * Fixed a minor bug in Number validation [#1329](https://github.com/getgrav/grav/issues/1329)\n    * Fixed exception when trying to find user account and there is no `user://accounts` folder\n    * Fixed issue when setting `Page::expires(0)` [Admin #1009](https://github.com/getgrav/grav-plugin-admin/issues/1009)\n    * Removed ID from `nonce_field()` Twig function causing validation errors [Form #115](https://github.com/getgrav/grav-plugin-form/issues/115)\n\n# v1.1.17\n## 02/17/2017\n\n1. [](#bugfix)\n    * Fix for double extensions getting added during some redirects [#1307](https://github.com/getgrav/grav/issues/1307)\n    * Fix syntax error in PHP 5.3. Move the version check before requiring the autoloaded deps\n    * Fix Whoops displaying error page if there is PHP core warning or error [Admin #980](https://github.com/getgrav/grav-plugin-admin/issues/980)\n\n# v1.1.16\n## 02/10/2017\n\n1. [](#new)\n    * Exposed the Pages cache ID for use by plugins (e.g. Form) via `Pages::getPagesCacheId()`\n    * Added `Languages::resetFallbackPageExtensions()` regarding [#1276](https://github.com/getgrav/grav/pull/1276)\n1. [](#improved)\n    * Allowed CLI to use non-volatile cache drivers for better integration with CLI and Web caches\n    * Added Gantry5-compatible query information to Caddy configuration\n    * Added some missing docblocks and type-hints\n    * Various code cleanups (return types, missing variables in doclbocks, etc.)\n1. [](#bugfix)\n    * Fix blueprints slug validation [https://github.com/getgrav/grav-plugin-admin/issues/955](https://github.com/getgrav/grav-plugin-admin/issues/955)\n\n# v1.1.15\n## 01/30/2017\n\n1. [](#new)\n    * Added a new `Collection::merge()` method to allow merging of multiple collections [#1258](https://github.com/getgrav/grav/pull/1258)\n    * Added [OpenCollective](https://opencollective.com/grav) backer/sponsor info to `README.md`\n1. [](#improved)\n    * Add an additional parameter to GPM::findPackage to avoid throwing an exception, for use in Twig [#1008](https://github.com/getgrav/grav/issues/1008)\n    * Skip symlinks if found while clearing cache [#1269](https://github.com/getgrav/grav/issues/1269)\n1. [](#bugfix)\n    * Fixed an issue when page collection with header-based `sort.by` returns an array [#1264](https://github.com/getgrav/grav/issues/1264)\n    * Fix `Response` object to handle `303` redirects when `open_basedir` in effect [#1267](https://github.com/getgrav/grav/issues/1267)\n    * Silence `E_WARNING: Zend OPcache API is restricted by \"restrict_api\" configuration directive`\n\n# v1.1.14\n## 01/18/2017\n\n1. [](#bugfix)\n    * Fixed `Page::collection()` returning array and not Collection object when header variable did not exist\n    * Revert `Content-Encoding: identity` fix, and let you set `cache: allow_webserver_gzip:` option to switch to `identity` [#548](https://github.com/getgrav/grav/issues/548)\n\n# v1.1.13\n## 01/17/2017\n\n1. [](#new)\n    * Added new `never_cache_twig` page option in `system.yaml` and frontmatter. Allows dynamic Twig logic in regular and modular Twig templates [#1244](https://github.com/getgrav/grav/pull/1244)\n1. [](#improved)\n    * Several improvements to aid theme development [#232](https://github.com/getgrav/grav/pull/1232)\n    * Added `hash` cache check option and made dropdown more descriptive [Admin #923](https://github.com/getgrav/grav-plugin-admin/issues/923)\n1. [](#bugfix)\n    * Fixed cross volume file system operations [#635](https://github.com/getgrav/grav/issues/635)\n    * Fix issue with pages folders validation not accepting uppercase letters\n    * Fix renaming the folder name if the page, in the default language, had a custom slug set in its header\n    * Fixed issue with `Content-Encoding: none`. It should really be `Content-Encoding: identity` instead\n    * Fixed broken `hash` method on page modifications detection\n    * Fixed issue with multi-lang pages not caching independently without unique `.md` file [#1211](https://github.com/getgrav/grav/issues/1211)\n    * Fixed all `$_GET` parameters missing in Nginx (please update your nginx.conf) [#1245](https://github.com/getgrav/grav/issues/1245)\n    * Fixed issue in trying to process broken symlink [#1254](https://github.com/getgrav/grav/issues/1254)\n\n# v1.1.12\n## 12/26/2016\n\n1. [](#bugfix)\n    * Fixed issue with JSON calls throwing errors due to debugger enabled [#1227](https://github.com/getgrav/grav/issues/1227)\n\n# v1.1.11\n## 12/22/2016\n\n1. [](#improved)\n    * Fall back properly to HTML if template type not found\n1. [](#bugfix)\n    * Fix issue with modular pages folders validation [#900](https://github.com/getgrav/grav-plugin-admin/issues/900)\n\n# v1.1.10\n## 12/21/2016\n\n1. [](#improved)\n    * Improve detection of home path. Also allow `~/.grav` on Windows, drop `ConsoleTrait::isWindows()` method, used only for that [#1204](https://github.com/getgrav/grav/pull/1204)\n    * Reworked PHP CLI router [#1219](https://github.com/getgrav/grav/pull/1219)\n    * More robust theme/plugin logic in `bin/gpm direct-install`\n1. [](#bugfix)\n    * Fixed case where extracting a package would cause an error during rename\n    * Fix issue with using `Yaml::parse` direcly on a filename, now deprecated\n    * Add pattern for frontend validation of folder slugs [#891](https://github.com/getgrav/grav-plugin-admin/issues/891)\n    * Fix issue with Inflector when translation is disabled [SimpleSearch #87](https://github.com/getgrav/grav-plugin-simplesearch/issues/87)\n    * Explicitly expose `array_unique` Twig filter [Admin #897](https://github.com/getgrav/grav-plugin-admin/issues/897)\n\n# v1.1.9\n## 12/13/2016\n\n1. [](#new)\n    * RC released as stable\n1. [](#improved)\n    * Better error handling in cache clear\n    * YAML syntax fixes for the future compatibility\n    * Added new parameter `remove` for `onBeforeCacheClear` event\n    * Add support for calling Media object as function to get medium by filename\n1. [](#bugfix)\n    * Added checks before accessing admin reference during `Page::blueprints()` call. Allows to access `page.blueprints` from Twig in the frontend\n\n# v1.1.9-rc.3\n## 12/07/2016\n\n1. [](#new)\n    * Add `ignore_empty` property to be used on array fields, if positive only save options with a value\n    * Use new `permissions` field in user account\n    * Add `range(int start, int end, int step)` twig function to generate an array of numbers between start and end, inclusive\n    * New retina Media image derivatives array support (`![](image.jpg?derivatives=[640,1024,1440])`) [#1147](https://github.com/getgrav/grav/pull/1147)\n    * Added stream support for images (`![Sepia Image](image://image.jpg?sepia)`)\n    * Added stream support for links (`[Download PDF](user://data/pdf/my.pdf)`)\n    * Added new `onBeforeCacheClear` event to add custom paths to cache clearing process\n1. [](#improved)\n    * Added alias `selfupdate` to the `self-upgrade` `bin/gpm` CLI command\n    * Synced `webserver-configs/htaccess.txt` with `.htaccess`\n    * Use permissions field in group details.\n    * Updated vendor libraries\n    * Added a warning on GPM update to update Grav first if needed [#1194](https://github.com/getgrav/grav/pull/1194)\n 1. [](#bugfix)\n    * Fix page collections problem with `@page.modular` [#1178](https://github.com/getgrav/grav/pull/1178)\n    * Fix issue with using a multiple taxonomy filter of which one had no results, thanks to @hughbris [#1184](https://github.com/getgrav/grav/issues/1184)\n    * Fix saving permissions in group\n    * Fixed issue with redirect of a page getting moved to a different location\n\n# v1.1.9-rc.2\n## 11/26/2016\n\n1. [](#new)\n    * Added two new sort order options for pages: `publish_date` and `unpublish_date` [#1173](https://github.com/getgrav/grav/pull/1173))\n1. [](#improved)\n    * Multisite: Create image cache folder if it doesn't exist\n    * Add 2 new language values for French [#1174](https://github.com/getgrav/grav/issues/1174)\n1. [](#bugfix)\n    * Fixed issue when we have a meta file without corresponding media [#1179](https://github.com/getgrav/grav/issues/1179)\n    * Update class namespace for Admin class [Admin #874](https://github.com/getgrav/grav-plugin-admin/issues/874)\n\n# v1.1.9-rc.1\n## 11/09/2016\n\n1. [](#new)\n    * Added a `CompiledJsonFile` object to better handle Json files.\n    * Added Base32 encode/decode class\n    * Added a new `User::find()` method\n1. [](#improved)\n    * Moved `messages` object into core Grav from login plugin\n    * Added `getTaxonomyItemKeys` to the Taxonomy object [#1124](https://github.com/getgrav/grav/issues/1124)\n    * Added a `redirect_me` Twig function [#1124](https://github.com/getgrav/grav/issues/1124)\n    * Added a Caddyfile for newer Caddy versions [#1115](https://github.com/getgrav/grav/issues/1115)\n    * Allow to override sorting flags for page header-based or default ordering. If the `intl` PHP extension is loaded, only these flags are available: https://secure.php.net/manual/en/collator.asort.php. Otherwise, you can use the PHP standard sorting flags (https://secure.php.net/manual/en/array.constants.php) [#1169](https://github.com/getgrav/grav/issues/1169)\n1. [](#bugfix)\n    * Fixed an issue with site redirects/routes, not processing with extension (.html, .json, etc.)\n    * Don't truncate HTML if content length is less than summary size [#1125](https://github.com/getgrav/grav/issues/1125)\n    * Return max available number when calling random() on a collection passing an int > available items [#1135](https://github.com/getgrav/grav/issues/1135)\n    * Use correct ratio when applying image filters to image alternatives [#1147](https://github.com/getgrav/grav/issues/1147)\n    * Fixed URI path in multi-site when query parameters were used in front page\n\n# v1.1.8\n## 10/22/2016\n\n1. [](#bugfix)\n    * Fixed warning with unset `ssl` option when using GPM [#1132](https://github.com/getgrav/grav/issues/1132)\n\n# v1.1.7\n## 10/22/2016\n\n1. [](#improved)\n    * Improved the capabilities of Image derivatives [#1107](https://github.com/getgrav/grav/pull/1107)\n1. [](#bugfix)\n    * Only pass verify_peer settings to cURL and fopen if the setting is disabled [#1120](https://github.com/getgrav/grav/issues/1120)\n\n# v1.1.6\n## 10/19/2016\n\n1. [](#new)\n    * Added ability for Page to override the output format (`html`, `xml`, etc..) [#1067](https://github.com/getgrav/grav/issues/1067)\n    * Added `Utils::getExtensionByMime()` and cleaned up `Utils::getMimeByExtension` + tests\n    * Added a `cache.check.method: 'hash'` option in `system.yaml` that checks all files + dates inclusively\n    * Include jQuery 3.x in the Grav assets\n    * Added the option to automatically fix orientation on images based on their Exif data, by enabling `system.images.auto_fix_orientation`.\n1. [](#improved)\n    * Add `batch()` function to Page Collection class\n    * Added new `cache.redis.socket` setting that allow to pass a UNIX socket as redis server\n    * It is now possible to opt-out of the SSL verification via the new `system.gpm.verify_peer` setting. This is sometimes necessary when receiving a \"GPM Unable to Connect\" error. More details in ([#1053](https://github.com/getgrav/grav/issues/1053))\n    * It is now possible to force the use of either `curl` or `fopen` as `Response` connection method, via the new `system.gpm.method` setting. By default this is set to 'auto' and gives priority to 'fopen' first, curl otherwise.\n    * InstallCommand can now handle Licenses\n    * Uses more helpful `1x`, `2x`, `3x`, etc names in the Retina derivatives cache files.\n    * Added new method `Plugins::isPluginActiveAdmin()` to check if plugin route is active in Admin plugin\n    * Added new `Cache::setEnabled` and `Cache::getEnabled` to enable outside control of cache\n    * Updated vendor libs including Twig `1.25.0`\n    * Avoid git ignoring any vendor folder in a Grav site subfolder (but still ignore the main `vendor/` folder)\n    * Added an option to get just a route back from `Uri::convertUrl()` function\n    * Added option to control split session [#1096](https://github.com/getgrav/grav/pull/1096)\n    * Added new `verbosity` levels to `system.error.display` to allow for system error messages [#1091](https://github.com/getgrav/grav/pull/1091)\n    * Improved the API for Grav plugins to access the Parsedown parser directly [#1062](https://github.com/getgrav/grav/pull/1062)\n1. [](#bugfix)\n    * Fixed missing `progress` method in the DirectInstall Command\n    * `Response` class now handles better unsuccessful requests such as 404 and 401\n    * Fixed saving of `external` page types [Admin #789](https://github.com/getgrav/grav-plugin-admin/issues/789)\n    * Fixed issue deleting parent folder of folder with `param_sep` in the folder name [admin #796](https://github.com/getgrav/grav-plugin-admin/issues/796)\n    * Fixed an issue with streams in `bin/plugin`\n    * Fixed `jpeg` file format support in Media\n\n# v1.1.5\n## 09/09/2016\n\n1. [](#new)\n    * Added new `bin/gpm direct-install` command to install local and remote zip archives\n1. [](#improved)\n    * Refactored `onPageNotFound` event to fire after `onPageInitialized`\n    * Follow symlinks in `Folder::all()`\n    * Twig variable `base_url` now supports multi-site by path feature\n    * Improved `bin/plugin` to list plugins with commands faster by limiting the depth of recursion\n1. [](#bugfix)\n    * Quietly skip missing streams in `Cache::clearCache()`\n    * Fix issue in calling page.summary when no content is present in a page\n    * Fix for HUGE session timeouts [#1050](https://github.com/getgrav/grav/issues/1050)\n\n# v1.1.4\n## 09/07/2016\n\n1. [](#new)\n    * Added new `tmp` folder at root. Accessible via stream `tmp://`. Can be cleared with `bin/grav clear --tmp-only` as well as `--all`.\n    * Added support for RTL in `LanguageCodes` so you can determine if a language is RTL or not\n    * Ability to set `custom_base_url` in system configuration\n    * Added `override` and `force` options for Streams setup\n1. [](#improved)\n    * Important vendor updates to provide PHP 7.1 beta support!\n    * Added a `Util::arrayFlatten()` static function\n    * Added support for 'external_url' page header to enable easier external URL based menu items\n    * Improved the UI for CLI GPM Index view to use a table\n    * Added `@page.modular` Collection type [#988](https://github.com/getgrav/grav/issues/988)\n    * Added support for `self@`, `page@`, `taxonomy@`, `root@` Collection syntax for cleaner YAML compatibility\n    * Improved GPM commands to allow for `-y` to automate **yes** responses and `-o` for **update** and **selfupgrade** to overwrite installations [#985](https://github.com/getgrav/grav/issues/985)\n    * Added randomization to `safe_email` Twig filter for greater security [#998](https://github.com/getgrav/grav/issues/998)\n    * Allow `Utils::setDotNotation` to merge data, rather than just set\n    * Moved default `Image::filter()` to the `save` action to ensure they are applied last [#984](https://github.com/getgrav/grav/issues/984)\n    * Improved the `Truncator` code to be more reliable [#1019](https://github.com/getgrav/grav/issues/1019)\n    * Moved media blueprints out of core (now in Admin plugin)\n1. [](#bugfix)\n    * Removed 307 redirect code option as it is not well supported [#743](https://github.com/getgrav/grav-plugin-admin/issues/743)\n    * Fixed issue with folders with name `*.md` are not confused with pages [#995](https://github.com/getgrav/grav/issues/995)\n    * Fixed an issue when filtering collections causing null key\n    * Fix for invalid HTML when rendering GIF and Vector media [#1001](https://github.com/getgrav/grav/issues/1001)\n    * Use pages.markdown.extra in the user's system.yaml [#1007](https://github.com/getgrav/grav/issues/1007)\n    * Fix for `Memcached` connection [#1020](https://github.com/getgrav/grav/issues/1020)\n\n# v1.1.3\n## 08/14/2016\n\n1. [](#bugfix)\n    * Fix for lightbox media function throwing error [#981](https://github.com/getgrav/grav/issues/981)\n\n# v1.1.2\n## 08/10/2016\n\n1. [](#new)\n    * Allow forcing SSL by setting `system.force_ssl` (Force SSL in the Admin System Config) [#899](https://github.com/getgrav/grav/pull/899)\n1. [](#improved)\n    * Improved `authorize` Twig extension to accept a nested array of authorizations  [#948](https://github.com/getgrav/grav/issues/948)\n    * Don't add timestamps on remote assets as it can cause conflicts\n    * Grav now looks at types from `media.yaml` when retrieving page mime types [#966](https://github.com/getgrav/grav/issues/966)\n    * Added support for dumping exceptions in the Debugger\n1. [](#bugfix)\n    * Fixed `Folder::delete` method to recursively remove files and folders and causing Upgrade to fail.\n    * Fix [#952](https://github.com/getgrav/grav/issues/952) hyphenize the session name.\n    * If no parent is set and siblings collection is called, return a new and empty collection [grav-plugin-sitemap/issues/22](https://github.com/getgrav/grav-plugin-sitemap/issues/22)\n    * Prevent exception being thrown when calling the Collator constructor failed in a Windows environment with the Intl PHP Extension enabled [#961](https://github.com/getgrav/grav/issues/961)\n    * Fix for markdown images not properly rendering `id` attribute [#956](https://github.com/getgrav/grav/issues/956)\n\n# v1.1.1\n## 07/16/2016\n\n1. [](#improved)\n    * Made `paramsRegex()` static to allow it to be called statically\n1. [](#bugfix)\n    * Fixed backup when using very long site titles with invalid characters [grav-plugin-admin#701](https://github.com/getgrav/grav-plugin-admin/issues/701)\n    * Fixed a typo in the `webserver-configs/nginx.conf` example\n\n# v1.1.0\n## 07/14/2016\n\n1. [](#improved)\n    * Added support for validation of multiple email in the `type: email` field [grav-plugin-email#31](https://github.com/getgrav/grav-plugin-email/issues/31)\n    * Unified PHP code header styling\n    * Added 6 more languages and updated language codes\n    * set default \"releases\" option to `stable`\n1. [](#bugfix)\n    * Fix backend validation for file fields marked as required [grav-plugin-form#78](https://github.com/getgrav/grav-plugin-form/issues/78)\n\n# v1.1.0-rc.3\n## 06/21/2016\n\n1. [](#new)\n    * Add a onPageFallBackUrl event when starting the fallbackUrl() method to allow the Login plugin to protect the page media\n    * Conveniently allow ability to retrieve user information via config object [#913](https://github.com/getgrav/grav/pull/913) - @Vivalldi\n    * Grav served images can now use header caching [#905](https://github.com/getgrav/grav/pull/905)\n1. [](#improved)\n    * Take asset modification timestamp into consideration in pipelining [#917](https://github.com/getgrav/grav/pull/917) - @Sommerregen\n1. [](#bugfix)\n    * Respect `enable_asset_timestamp` settings for pipelined Assets [#906](https://github.com/getgrav/grav/issues/906)\n    * Fixed collections end dates for 32-bit systems [#902](https://github.com/getgrav/grav/issues/902)\n    * Fixed a recent regression (1.1.0-rc1) with parameter separator different than `:`\n\n# v1.1.0-rc.2\n## 06/14/2016\n\n1. [](#new)\n    * Added getters and setters for Assets to allow manipulation of CSS/JS/Collection based assets via plugins [#876](https://github.com/getgrav/grav/issues/876)\n1. [](#improved)\n    * Pass the exception to the `onFatalException()` event\n    * Updated to latest jQuery 2.2.4 release\n    * Moved list items in `system/config/media.yaml` config into a `types:` key which allows you delete default items.\n    * Updated `webserver-configs/nginx.conf` with `try_files` fix from @mrhein and @rondlite [#743](https://github.com/getgrav/grav/pull/743)\n    * Updated cache references to include `memecache` and `redis` [#887](https://github.com/getgrav/grav/issues/887)\n    * Updated composer libraries\n1. [](#bugfix)\n    * Fixed `Utils::normalizePath()` that was truncating 0's [#882](https://github.com/getgrav/grav/issues/882)\n\n# v1.1.0-rc.1\n## 06/01/2016\n\n1. [](#new)\n    * Added `Utils::getDotNotation()` and `Utils::setDotNotation()` methods + tests\n    * Added support for `xx-XX` locale language lookups in `LanguageCodes` class [#854](https://github.com/getgrav/grav/issues/854)\n    * New CSS/JS Minify library that does a more reliable job [#864](https://github.com/getgrav/grav/issues/864)\n1. [](#improved)\n    * GPM installation of plugins and themes into correct multisite folders [#841](https://github.com/getgrav/grav/issues/841)\n    * Use `Page::rawRoute()` in blueprints for more reliable mulit-language support\n1. [](#bugfix)\n    * Fixes for `zlib.output_compression` as well as `mod_deflate` GZIP compression\n    * Fix for corner-case redirect logic causing infinite loops and out-of-memory errors\n    * Fix for saving fields in expert mode that have no `Validation::typeX()` methods [#626](https://github.com/getgrav/grav-plugin-admin/issues/626)\n    * Detect if user really meant to extend parent blueprint, not another one (fixes old page type blueprints)\n    * Fixed a bug in `Page::relativePagePath()` when `Page::$name` is not defined\n    * Fix for poor handling of params + query element in `Uri::processParams()` [#859](https://github.com/getgrav/grav/issues/859)\n    * Fix for double encoding in markdown links [#860](https://github.com/getgrav/grav/issues/860)\n    * Correctly handle language strings to determine if it's in admin or not [#627](https://github.com/getgrav/grav-plugin-admin/issues/627)\n\n# v1.1.0-beta.5\n## 05/23/2016\n\n1. [](#improved)\n    * Updated jQuery from 2.2.0 to 2.2.3\n    * Set `Uri::ip()` to static by default so it can be used in form fields\n    * Improved `Session` class with flash storage\n    * `Page::getContentMeta()` now supports an optional key.\n1. [](#bugfix)\n    * Fixed \"Invalid slug set in YAML frontmatter\" when setting `Page::slug()` with empty string [#580](https://github.com/getgrav/grav-plugin-admin/issues/580)\n    * Only `.gitignore` Grav's vendor folder\n    * Fix trying to remove Grav with `GPM uninstall` of a plugin with Grav dependency\n    * Fix Page Type blueprints not being able to extend their parents\n    * `filterFile` validation method always returns an array of files, behaving like `multiple=\"multiple\"`\n    * Fixed [#835](https://github.com/getgrav/grav-plugin-admin/issues/835) check for empty image file first to prevent getimagesize() fatal error\n    * Avoid throwing an error when Grav's Gzip and mod_deflate are enabled at the same time on a non php-fpm setup\n\n# v1.1.0-beta.4\n## 05/09/2016\n\n1. [](#bugfix)\n    * Drop dependencies calculations if plugin is installed via symlink\n    * Drop Grav from dependencies calculations\n    * Send slug name as part of installed packages\n    * Fix for summary entities not being properly decoded [#825](https://github.com/getgrav/grav/issues/825)\n\n\n# v1.1.0-beta.3\n## 05/04/2016\n\n1. [](#improved)\n    * Pass the Page type when calling `onBlueprintCreated`\n    * Changed `Page::cachePageContent()` form **private** to **public** so a page can be recached via plugin\n1. [](#bugfix)\n    * Fixed handling of `{'loading':'async'}` with Assets Pipeline\n    * Fix for new modular page modal `Page` field requiring a value [#529](https://github.com/getgrav/grav-plugin-admin/issues/529)\n    * Fix for broken `bin/gpm version` command\n    * Fix handling \"grav\" as a dependency\n    * Fix when installing multiple packages and one is the dependency of another, don't try to install it twice\n    * Fix using name instead of the slug to determine a package folder. Broke for packages whose name was 2+ words\n\n# v1.1.0-beta.2\n## 04/27/2016\n\n1. [](#new)\n    * Added new `Plugin::getBlueprint()` and `Theme::getBlueprint()` method\n    * Allow **page blueprints** to be added via Plugins.\n1. [](#improved)\n    * Moved to new `data-*@` format in blueprints\n    * Updated composer-based libraries\n    * Moved some hard-coded `CACHE_DIR` references to use locator\n    * Set `twig.debug: true` by default\n1. [](#bugfix)\n    * Fixed issue with link rewrites and local assets pipeline with `absolute_urls: true`\n    * Allow Cyrillic slugs [#520](https://github.com/getgrav/grav-plugin-admin/issues/520)\n    * Fix ordering issue with accented letters [#784](https://github.com/getgrav/grav/issues/784)\n    * Fix issue with Assets pipeline and missing newlines causing invalid JavaScript\n\n# v1.1.0-beta.1\n## 04/20/2016\n\n1. [](#new)\n    * **Blueprint Improvements**: The main improvements to Grav take the form of a major rewrite of our blueprint functionality. Blueprints are an essential piece of functionality within Grav that helps define configuration fields. These allow us to create a definition of a form field that can be rendered in the administrator plugin and allow the input, validation, and storage of values into the various configuration and page files that power Grav. Grav 1.0 had extensive support for building and extending blueprints, but Grav 1.1 takes this even further and adds improvements to our existing system.\n    * **Extending Blueprints**: You could extend forms in Grav 1.0, but now you can use a newer `extends@:` default syntax rather than the previous `'@extends'` string that needed to be quoted in YAML. Also this new format allows for the defining of a `context` which lets you define where to look for the base blueprint. Another new feature is the ability to extend from multiple blueprints.\n    * **Embedding/Importing Blueprints**: One feature that has been requested is the ability to embed or import one blueprint into another blueprint. This allows you to share fields or sub-form between multiple forms. This is accomplished via the `import@` syntax.\n    * **Removing Existing Fields and Properties**: Another new feature is the ability to remove completely existing fields or properties from an extended blueprint. This allows the user a lot more flexibility when creating custom forms by simply using the new `unset@: true` syntax. To remove a field property you would use `unset-<property>@: true` in your extended field definition, for example: `unset-options@: true`.\n    * **Replacing Existing Fields and Properties**: Similar to removing, you can now replace an existing field or property with the `replace@: true` syntax for the whole field, and `replace-<property>@: true` for a specific property.\n    * **Field Ordering**: Probably the most frequently requested blueprint functionality that we have added is the ability to change field ordering. Imagine that you want to extend the default page blueprint but add a new tab. Previously, this meant your tab would be added at the end of the form, but now you can define that you wish the new tab to be added right after the `content` tab. This works for any field too, so you can extend a blueprint and add your own custom fields anywhere you wish! This is accomplished by using the new `ordering@:` syntax with either an existing property name or an integer.\n    * **Configuration Properties**: Another useful new feature is the ability to directly access Grav configuration in blueprints with `config-<property>@` syntax. For example you can set a default for a field via `config-default@: site.author.name` which will use the author.name value from the `site.yaml` file as the `default` value for this field.\n    * **Function Calls**: The ability to call PHP functions for values has been improved in Grav 1.1 to be more powerful. You can use the `data-<property>@` syntax to call static methods to obtain values. For example: `data-default@: '\\Grav\\Plugin\\Admin::route'`. You can now even pass parameters to these methods.\n    * **Validation Rules**: You can now define a custom blueprint-level validation rule and assign this rule to a form field.\n    * **Custom Form Field Types**: This advanced new functionality allows you to create a custom field type via a new plugin event called getFormFieldTypes(). This allows you to provide extra functionality or instructions on how to handle the form form field.\n    * **GPM Versioning**: A new feature that we have wanted to add to our GPM package management system is the ability to control dependencies by version. We have opted to use a syntax very similar to the Composer Package Manager that is already familiar to most PHP developers. This new versioning system allows you to define specific minimum version requirements of dependent packages within Grav. This should ensure that we have less (hopefully none!) issues when you update one package that also requires a specific minimum version of another package. The admin plugin for example may have an update that requires a specific version of Grav itself.\n    * **GPM Testing Channel**: GPM repository now comes with both a `stable` and `testing` channel. A new setting in `system.gpm.releases` allow to switch between the two channels. Developers will be able to decide whether their resource is going to be in a pre-release state or stable. Only users who switch to the **testing** channel will be able to install a pre-release version.\n    * **GPM Events**: Packages (plugins and themes) can now add event handlers to hook in the package GPM events: install, update, uninstall. A package can listen for events before and after each of these events, and can execute any PHP code, and optionally halt the procedure or return a message.\n    * Refactor of the process chain breaking out `Processors` into individual classes to allow for easier modification and addition. Thanks to toovy for this work. - [#745](https://github.com/getgrav/grav/pull/745)\n    * Added multipart downloads, resumable downloads, download throttling, and video streaming in the `Utils::download()` method.\n    * Added optional config to allow Twig processing in page frontmatter - [#788](https://github.com/getgrav/grav/pull/788)\n    * Added the ability to provide blueprints via a plugin (previously limited to Themes only).\n    * Added Developer CLI Tools to easily create a new theme or plugin\n    * Allow authentication for proxies - [#698](https://github.com/getgrav/grav/pull/698)\n    * Allow to override the default Parsedown behavior - [#747](https://github.com/getgrav/grav/pull/747)\n    * Added an option to allow to exclude external files from the pipeline, and to render the pipeline before/after excluded files\n    * Added the possibility to store translations of themes in separate files inside the `languages` folder\n    * Added a method to the Uri class to return the base relative URL including the language prefix, or the base relative url if multilanguage is not enabled\n    * Added a shortcut for pages.find() alias\n1. [](#improved)\n    * Now supporting hostnames with localhost environments for better vhost support/development\n    * Refactor hard-coded paths to use PHP Streams that allow a setup file to configure where certain parts of Grav are stored in the physical filesystem.\n    * If multilanguage is active, include the Intl Twig Extension to allow translating dates automatically (http://twig.sensiolabs.org/doc/extensions/intl.html)\n    * Allow having local themes with the same name as GPM themes, by adding `gpm: false` to the theme blueprint - [#767](https://github.com/getgrav/grav/pull/767)\n    * Caddyfile and Lighttpd config files updated\n    * Removed `node_modules` folder from backups to make them faster\n    * Display error when `bin/grav install` hasn't been run instead of throwing exception. Prevents \"white page\" errors if error display is off\n    * Improved command line flow when installing multiple packages: don't reinstall packages if already installed, ask once if should use symlinks if symlinks are found\n    * Added more tests to our testing suite\n    * Added x-ua-compatible to http_equiv metadata processing\n    * Added ability to have a per-page `frontmatter.yaml` file to set header frontmatter defaults. Especially useful for multilang scenarios - [#775](https://github.com/getgrav/grav/pull/775)\n    * Removed deprecated `bin/grav newuser` CLI command.  use `bin/plugin login newuser` instead.\n    * Added `webm` and `ogv` video types to the default media types list.\n1. [](#bugfix)\n    * Fix Zend Opcache `opcache.validate_timestamps=0` not detecting changes in compiled yaml and twig files\n    * Avoid losing params, query and fragment from the URL when auto-redirecting to a language-specific route - [#759](https://github.com/getgrav/grav/pull/759)\n    * Fix for non-pipeline assets getting lost when pipeline is cached to filesystem\n    * Fix for double encoding resulting from Markdown Extra\n    * Fix for a remote link breaking all CSS rewrites for pipeline\n    * Fix an issue with Retina alternatives not clearing properly between repeat uses\n    * Fix for non standard http/s external markdown links - [#738](https://github.com/getgrav/grav/issues/738)\n    * Fix for `find()` calling redirects via `dispatch()` causing infinite loops - [#781](https://github.com/getgrav/grav/issues/781)\n\n# v1.0.10\n## 02/11/2016\n\n1. [](#new)\n    * Added new `Page::contentMeta()` mechanism to store content-level meta data alongside content\n    * Added Japanese language translation\n1. [](#improved)\n    * Updated some vendor libraries\n1. [](#bugfix)\n    * Hide `streams` blueprint from Admin plugin\n    * Fix translations of languages with `---` in YAML files\n\n# v1.0.9\n## 02/05/2016\n\n1. [](#new)\n    * New **Unit Testing** via Codeception http://codeception.com/\n    * New **page-level SSL** functionality when using `absolute_urls`\n    * Added `reverse_proxy` config option for issues with non-standard ports\n    * Added `proxy_url` config option to support GPM behind proxy servers #639\n    * New `Pages::parentsRawRoutes()` method\n    * Enhanced `bin/gpm info` CLI command with Changelog support #559\n    * Ability to add empty *Folder* via admin plugin\n    * Added latest `jQuery 2.2.0` library to core\n    * Added translations from Crowdin\n1. [](#improved)\n    * [BC] Metadata now supports only flat arrays. To use open graph metas and the likes (ie, 'og:title'), simply specify it in the key.\n    * Refactored `Uri::convertUrl()` method to be more reliable + tests created\n    * Date for last update of a modular sub-page sets modified date of modular page itself\n    * Split configuration up into two steps\n    * Moved Grav-based `base_uri` variables into `Uri::init()`\n    * Refactored init in `URI` to better support testing\n    * Allow `twig_vars` to be exposed earlier and merged later\n    * Avoid setting empty metadata\n    * Accept single group access as a string rather than requiring an array\n    * Return `$this` in Page constructor and init to allow chaining\n    * Added `ext-*` PHP requirements to `composer.json`\n    * Use Whoops 2.0 library while supporting old style\n    * Removed redundant old default-hash fallback mechanisms\n    * Commented out default redirects and routes in `site.yaml`\n    * Added `/tests` folder to deny's of all `webserver-configs/*` files\n    * Various PS and code style fixes\n1. [](#bugfix)\n    * Fix default generator metadata\n    * Fix for broken image processing caused by `Uri::convertUrl()` bugs\n    * Fix loading JS and CSS from collections #623\n    * Fix stream overriding\n    * Remove the URL extension for home link\n    * Fix permissions when the user has no access level set at all\n    * Fix issue with user with multiple groups getting denied on first group\n    * Fixed an issue with `Pages()` internal cache lookup not being unique enough\n    * Fix for bug with `site.redirects` and `site.routes` being an empty list\n    * [Markdown] Don't process links for **special protocols**\n    * [Whoops] serve JSON errors when request is JSON\n\n\n# v1.0.8\n## 01/08/2016\n\n1. [](#new)\n    * Added `rotate`, `flip` and `fixOrientation` image medium methods\n1. [](#bugfix)\n    * Removed IP from Nonce generation. Should be more reliable in a variety of scenarios\n\n# v1.0.7\n## 01/07/2016\n\n1. [](#new)\n    * Added `composer create-project` as an additional installation method #585\n    * New optional system config setting to strip home from page routs and urls #561\n    * Added Greek, Finnish, Norwegian, Polish, Portuguese, and Romanian languages\n    * Added new `Page->topParent()` method to return top most parent of a page\n    * Added plugins configuration tab to debugger\n    * Added support for APCu and PHP7.0 via new Doctrine Cache release\n    * Added global setting for `twig_first` processing (false by default)\n    * New configuration options for Session settings #553\n1. [](#improved)\n    * Switched to SSL for GPM calls\n    * Use `URI->host()` for session domain\n    * Add support for `open_basedir` when installing packages via GPM\n    * Improved `Utils::generateNonceString()` method to handle reverse proxies\n    * Optimized core thumbnails saving 38% in file size\n    * Added new `bin/gpm index --installed-only` option\n    * Improved GPM errors to provider more helpful diagnostic of issues\n    * Removed old hardcoded PHP version references\n    * Moved `onPageContentProcessed()` event so it's fired more reliably\n    * Maintain md5 keys during sorting of Assets #566\n    * Update to Caddyfile for Caddy web server\n1. [](#bugfix)\n    * Fixed an issue with cache/config checksum not being set on cache load\n    * Fix for page blueprint and theme inheritance issue #534\n    * Set `ZipBackup` timeout to 10 minutes if possible\n    * Fix case where we only have inline data for CSS or JS  #565\n    * Fix `bin/grav sandbox` command to work with new `webserver-config` folder\n    * Fix for markdown attributes on external URLs\n    * Fixed issue where `data:` page header was acting as `publish_date:`\n    * Fix for special characters in URL parameters (e.g. /tag:c++) #541\n    * Safety check for an array of nonces to only use the first one\n\n# v1.0.6\n## 12/22/2015\n\n1. [](#new)\n    * Set minimum requirements to [PHP 5.5.9](http://bit.ly/1Jt9OXO)\n    * Added `saveConfig` to Themes\n1. [](#improved)\n    * Updated Whoops to new 2.0 version (PHP 7.0 compatible)\n    * Moved sample web server configs into dedicated directory\n    * FastCGI will use Apache's `mod_deflate` if gzip turned off\n1. [](#bugfix)\n    * Fix broken media image operators\n    * Only call extra method of blueprints if blueprints exist\n    * Fix lang prefix in url twig variables #523\n    * Fix case insensitive HTTPS check #535\n    * Field field validation handles case `multiple` missing\n\n# v1.0.5\n## 12/18/2015\n\n1. [](#new)\n    * Add ability to extend markdown with plugins\n    * Added support for plugins to have individual language files\n    * Added `7z` to media formats\n    * Use Grav's fork of Parsedown until PR is merged\n    * New function to persist plugin configuration to disk\n    * GPM `selfupgrade` will now check PHP version requirements\n1. [](#improved)\n    * If the field allows multiple files, return array\n    * Handle non-array values in file validation\n1. [](#bugfix)\n    * Fix when looping `fields` param in a `list` field\n    * Properly convert commas to spaces for media attributes\n    * Forcing Travis VM to HI timezone to address future files in zip file\n\n# v1.0.4\n## 12/12/2015\n\n1. [](#bugfix)\n    * Needed to put default image folder permissions for YAML compatibility\n\n# v1.0.3\n## 12/11/2015\n\n1. [](#bugfix)\n    * Fixed issue when saving config causing incorrect image cache folder perms\n\n# v1.0.2\n## 12/11/2015\n\n1. [](#bugfix)\n    * Fix for timing display in debugbar\n\n# v1.0.1\n## 12/11/2015\n\n1. [](#improved)\n    * Reduced package sizes by removing extra vendor dev bits\n1. [](#bugfix)\n    * Fix issue when you enable debugger from admin plugin\n\n# v1.0.0\n## 12/11/2015\n\n1. [](#new)\n    * Add new link attributes via markdown media\n    * Added setters to set state of CSS/JS pipelining\n    * Added `user/accounts` to `.gitignore`\n    * Added configurable permissions option for Image cache\n1. [](#improved)\n    * Hungarian translation updated\n    * Refactored Theme initialization for improved flexibility\n    * Wrapped security section of account blueprints in an 'super user' authorize check\n    * Minor performance optimizations\n    * Updated core page blueprints with markdown preview option\n    * Added useful cache info output to Debugbar\n    * Added `iconv` polyfill library used by Symfony 2.8\n    * Force lowercase of username in a few places for case sensitive filesystems\n1. [](#bugfix)\n    * Fix for GPM problems \"Call to a member function set() on null\"\n    * Fix for individual asset pipeline values not functioning\n    * Fix `Page::copy()` and `Page::move()` to support multiple moves at once\n    * Fixed page moving of a page with no content\n    * Fix for wrong ordering when moving many pages\n    * Escape root path in page medium files to work with special characters\n    * Add missing parent constructor to Themes class\n    * Fix missing file error in `bin/grav sandbox` command\n    * Fixed changelog differ when upgrading Grav\n    * Fixed a logic error in `Validation->validate()`\n    * Make `$container` available in `setup.php` to fix multi-site\n\n# v1.0.0-rc.6\n## 12/01/2015\n\n1. [](#new)\n    * Refactor Config classes for improved performance!\n    * Refactor Data classes to use `NestedArrayAccess` instead of `DataMutatorTrait`\n    * Added support for `classes` and `id` on medium objects to set CSS values\n    * Data objects: Allow function call chaining\n    * Data objects: Lazy load blueprints only if needed\n    * Automatically create unique security salt for each configuration\n    * Added Hungarian translation\n    * Added support for User groups\n1. [](#improved)\n    * Improved robots.txt to disallow crawling of non-user folders\n    * Nonces only generated once per action and process\n    * Added IP into Nonce string calculation\n    * Nonces now use random string with random salt to improve performance\n    * Improved list form handling #475\n    * Vendor library updates\n1. [](#bugfix)\n    * Fixed help output for `bin/plugin`\n    * Fix for nested logic for lists and form parsing #273\n    * Fix for array form fields and last entry not getting deleted\n    * Should not be able to set parent to self #308\n\n# v1.0.0-rc.5\n## 11/20/2015\n\n1. [](#new)\n    * Added **nonce** functionality for all admin forms for improved security\n    * Implemented the ability for Plugins to provide their own CLI commands through `bin/plugin`\n    * Added Croatian translation\n    * Added missing `umask_fix` property to `system.yaml`\n    * Added current theme's config to global config. E.g. `config.theme.dropdown_enabled`\n    * Added `append_url_extension` option to system config & page headers\n    * Users have a new `state` property to allow disabling/banning\n    * Added new `Page.relativePagePath()` helper method\n    * Added new `|pad` Twig filter for strings (uses `str_pad()`)\n    * Added `lighttpd.conf` for Lightly web server\n1. [](#improved)\n    * Clear previously applied operations when doing a reset on image media\n    * Password no longer required when editing user\n    * Improved support for trailing `/` URLs\n    * Improved `.nginx.conf` configuration file\n    * Improved `.htaccess` security\n    * Updated vendor libs\n    * Updated `composer.phar`\n    * Use streams instead of paths for `clearCache()`\n    * Use PCRE_UTF8 so unicode strings can be regexed in Truncator\n    * Handle case when login plugin is disabled\n    * Improved `quality` functionality in media handling\n    * Added some missing translation strings\n    * Deprecated `bin/grav newuser` in favor of `bin/plugin login new-user`\n    * Moved fallback types to use any valid media type\n    * Renamed `system.pages.fallback_types` to `system.media.allowed_fallback_types`\n    * Removed version number in default `generator` meta tag\n    * Disable time limit in case of slow downloads\n    * Removed default hash in `system.yaml`\n1. [](#bugfix)\n    * Fix for media using absolute URLs causing broken links\n    * Fix theme auto-loading #432\n    * Don't create empty `<style>` or `<script>` scripts if no data\n    * Code cleanups\n    * Fix undefined variable in Config class\n    * Fix exception message when label is not set\n    * Check in `Plugins::get()` to ensure plugins exists\n    * Fixed GZip compression making output buffering work correctly with all servers and browsers\n    * Fixed date representation in system config\n\n# v1.0.0-rc.4\n## 10/29/2015\n\n1. [](#bugfix)\n    * Fixed a fatal error if you have a collection with missing or invalid `@page: /route`\n\n# v1.0.0-rc.3\n## 10/29/2015\n\n1. [](#new)\n    * New Page collection options! `@self.parent, @self.siblings, @self.descendants` + more\n    * White list of file types for fallback route functionality (images by default)\n1. [](#improved)\n    * Assets switched from defines to streams\n1. [](#bugfix)\n    * README.md typos fixed\n    * Fixed issue with routes that have lang string in them (`/en/english`)\n    * Trim strings before validation so whitespace is not satisfy 'required'\n\n# v1.0.0-rc.2\n## 10/27/2015\n\n1. [](#new)\n    * Added support for CSS Asset groups\n    * Added a `wrapped_site` system option for themes/plugins to use\n    * Pass `Page` object as event to `onTwigPageVariables()` event hook\n    * New `Data.items()` method to get all items\n1. [](#improved)\n    * Missing pipelined remote asset will now fail quietly\n    * More reliably handle inline JS and CSS to remove only surrounding HTML tags\n    * `Medium.meta` returns new Data object so null checks are possible\n    * Improved Medium metadata merging to allow for automatic title/alt/class attributes\n    * Moved Grav object to global variable rather than template variable (useful for macros)\n    * German language improvements\n    * Updated bundled composer\n1. [](#bugfix)\n    * Accept variety of `true` values in `User.authorize()` method\n    * Fix for `Validation` throwing an error if no label set\n\n# v1.0.0-rc.1\n## 10/23/2015\n\n1. [](#new)\n    * Use native PECL YAML parser if installed for 4X speed boost in parsing YAML files\n    * Support for inherited theme class\n    * Added new default language prepend system configuration option\n    * New `|evaluate` Twig filter to evaluate a string as twig\n    * New system option to ignore all **hidden** files and folders\n    * New system option for default redirect code\n    * Added ability to append specific `[30x]` codes to redirect URLs\n    * Added `url_taxonomy_filters` for page collections\n    * Added `@root` page and `recurse` flag for page collections\n    * Support for **multiple** page collection types as an array\n    * Added Dutch language file\n    * Added Russian language file\n    * Added `remove` method to User object\n1. [](#improved)\n    * Moved hardcoded mimetypes to `media.yaml` to be treated as Page media files\n    * Set `errors: display: false` by default in `system.yaml`\n    * Strip out extra slashes in the URI\n    * Validate hostname to ensure it is valid\n    * Ignore more SCM folders in Backups\n    * Removed `home_redirect` settings from `system.yaml`\n    * Added Page `media` as root twig object for consistency\n    * Updated to latest vendor libraries\n    * Optimizations to Asset pipeline logic for minor speed increase\n    * Block direct access to a variety of files in `.htaccess` for increased security\n    * Debugbar vendor library update\n    * Always fallback to english if other translations are not available\n1. [](#bugfix)\n    * Fix for redirecting external URL with multi-language\n    * Fix for Asset pipeline not respecting asset groups\n    * Fix language files with child/parent theme relationships\n    * Fixed a regression issue resulting in incorrect default language\n    * Ensure error handler is initialized before URI is processed\n    * Use default language in Twig if active language is not set\n    * Fixed issue with `safeEmailFilter()` Twig filter not separating with `;` properly\n    * Fixed empty YAML file causing error with native PECL YAML parser\n    * Fixed `SVG` mimetype\n    * Fixed incorrect `Cache-control: max-age` value format\n\n# v0.9.45\n## 10/08/2015\n\n1. [](#bugfix)\n    * Fixed a regression issue resulting in incorrect default language\n\n# v0.9.44\n## 10/07/2015\n\n1. [](#new)\n    * Added Redis back as a supported cache mechanism\n    * Allow Twig `nicetime` translations\n    * Added `-y` option for 'Yes to all' in `bin/gpm update`\n    * Added CSS `media` attribute to the Assets manager\n    * New German language support\n    * New Czech language support\n    * New French language support\n    * Added `modulus` twig filter\n1. [](#improved)\n    * URL decode in medium actions to allow complex syntax\n    * Take into account `HTTP_HOST` before `SERVER_NAME` (helpful with Nginx)\n    * More friendly cache naming to ease manual management of cache systems\n    * Added default Apache resource for `DirectoryIndex`\n1. [](#bugfix)\n    * Fix GPM failure when offline\n    * Fix `open_basedir` error in `bin/gpm install`\n    * Fix an HHVM error in Truncator\n    * Fix for XSS vulnerability with params\n    * Fix chaining for responsive size derivatives\n    * Fix for saving pages when removing the page title and all other header elements\n    * Fix when saving array fields\n    * Fix for ports being included in `HTTP_HOST`\n    * Fix for Truncator to handle PHP tags gracefully\n    * Fix for locate style lang codes in `getNativeName()`\n    * Urldecode image basenames in markdown\n\n# v0.9.43\n## 09/16/2015\n\n1. [](#new)\n    * Added new `AudioMedium` for HTML5 audio\n    * Added ability for Assets to be added and displayed in separate *groups*\n    * New support for responsive image derivative sizes\n1. [](#improved)\n    * GPM theme install now uses a `copy` method so new files are not lost (e.g. `/css/custom.css`)\n    * Code analysis improvements and cleanup\n    * Removed Twig panel from debugger (no longer supported in Twig 1.20)\n    * Updated composer packages\n    * Prepend active language to `convertUrl()` when used in markdown links\n    * Added some pre/post flight options for installer via blueprints\n    * Hyphenize the site name in the backup filename\n1. [](#bugfix)\n    * Fix broken routable logic\n    * Check for `phpinfo()` method in case it is restricted by hosting provider\n    * Fixes for windows when running GPM\n    * Fix for ampersand (`&`) causing error in `truncateHtml()` via `Page.summary()`\n\n# v0.9.42\n## 09/11/2015\n\n1. [](#bugfix)\n    * Fixed `User.authorise()` to be backwards compabile\n\n# v0.9.41\n## 09/11/2015\n\n1. [](#new)\n    * New and improved multibyte-safe TruncateHTML function and filter\n    * Added support for custom page date format\n    * Added a `string` Twig filter to render as json_encoded string\n    * Added `authorize` Twig filter\n    * Added support for theme inheritance in the admin\n    * Support for multiple content collections on a page\n    * Added configurable files/folders ignores for pages\n    * Added the ability to set the default PHP locale and override via multi-lang configuration\n    * Added ability to save as YAML via admin\n    * Added check for `mbstring` support\n    * Added new `redirect` header for pages\n1. [](#improved)\n    * Changed dependencies from `develop` to `master`\n    * Updated logging to log everything from `debug` level on (was `warning`)\n    * Added missing `accounts/` folder\n    * Default to performing a 301 redirect for URIs with trailing slashes\n    * Improved Twig error messages\n    * Allow validating of forms from anywhere such as plugins\n    * Added logic so modular pages are by default non-routable\n    * Hide password input in `bin/grav newuser` command\n1. [](#bugfix)\n    * Fixed `Pages.all()` not returning modular pages\n    * Fix for modular template types not getting found\n    * Fix for `markdown_extra:` overriding `markdown:extra:` setting\n    * Fix for multi-site routing\n    * Fix for multi-lang page name error\n    * Fixed a redirect loop in `URI` class\n    * Fixed a potential error when `unsupported_inline_types` is empty\n    * Correctly generate 2x retina image\n    * Typo fixes in page publish/unpublish blueprint\n\n# v0.9.40\n## 08/31/2015\n\n1. [](#new)\n    * Added some new Twig filters: `defined`, `rtrim`, `ltrim`\n    * Admin support for customizable page file name + template override\n1. [](#improved)\n    * Better message for incompatible/unsupported Twig template\n    * Improved User blueprints with better help\n    * Switched to composer **install** rather than **update** by default\n    * Admin autofocus on page title\n    * `.htaccess` hardening (`.htaccess` & `htaccess.txt`)\n    * Cache safety checks for missing folders\n1. [](#bugfix)\n    * Fixed issue with unescaped `o` character in date formats\n\n# v0.9.39\n## 08/25/2015\n\n1. [](#bugfix)\n    * `Page.active()` not triggering on **homepage**\n    * Fix for invalid session name in Opera browser\n\n# v0.9.38\n## 08/24/2015\n\n1. [](#new)\n    * Added `language` to **user** blueprint\n    * Added translations to blueprints\n    * New extending logic for blueprints\n    * Blueprints are now loaded with Streams to allow for better overrides\n    * Added new Symfony `dump()` method\n1. [](#improved)\n    * Catch YAML header parse exception so site doesn't die\n    * Better `Page.parent()` logic\n    * Improved GPM display layout\n    * Tweaked default page layout\n    * Unset route and slug for improved reliability of route changes\n    * Added requirements to README.md\n    * Updated various libraries\n    * Allow use of custom page date field for dateRange collections\n1. [](#bugfix)\n    * Slug fixes with GPM\n    * Unset plaintext password on save\n    * Fix for trailing `/` not matching active children\n\n# v0.9.37\n## 08/12/2015\n\n3. [](#bugfix)\n    * Fixed issue when saving `header.process` in page forms via the **admin plugin**\n    * Fixed error due to use of `set_time_limit` that might be disabled on some hosts\n\n# v0.9.36\n## 08/11/2015\n\n1. [](#new)\n    * Added a new `newuser` CLI command to create user accounts\n    * Added `default` blueprint for all templates\n    * Support `user` and `system` language translation merging\n1. [](#improved)\n    * Added isSymlink method in GPM to determine if Grav is symbolically linked or not\n    * Refactored page recursing\n    * Updated blueprints to use new toggles\n    * Updated blueprints to use current date for date format fields\n    * Updated composer.phar\n    * Use sessions for admin even when disabled for site\n    * Use `GRAV_ROOT` in session identifier\n\n# v0.9.35\n## 08/06/2015\n\n1. [](#new)\n    * Added `body_classes` field\n    * Added `visiblity` toggle and help tooltips on new page form\n    * Added new `Page.unsetRoute()` method to allow admin to regenerate the route\n2. [](#improved)\n    * User save no longer stores username each time\n    * Page list form field now shows all pages except root\n    * Removed required option from page title\n    * Added configuration settings for running Nginx in sub directory\n3. [](#bugfix)\n    * Fixed deep translation merging\n    * Fixed broken **metadata** merging with site defaults\n    * Fixed broken **summary** field\n    * Fixed broken robots field\n    * Fixed GPM issue when using cURL, throwing an `Undefined offset: 1` exception\n    * Removed duplicate hidden page `type` field\n\n# v0.9.34\n## 08/04/2015\n\n1. [](#new)\n    * Added new `cache_all` system setting + media `cache()` method\n    * Added base languages configuration\n    * Added property language to page to help plugins identify page language\n    * New `Utils::arrayFilterRecursive()` method\n2. [](#improved)\n    * Improved Session handling to support site and admin independently\n    * Allow Twig variables to be modified in other events\n    * Blueprint updates in preparation for Admin plugin\n    * Changed `Inflector` from static to object and added multi-language support\n    * Support for admin override of a page's blueprints\n3. [](#bugfix)\n    * Removed unused `use` in `VideoMedium` that was causing error\n    * Array fix in `User.authorise()` method\n    * Fix for typo in `translations_fallback`\n    * Fixed moving page to the root\n\n# v0.9.33\n## 07/21/2015\n\n1. [](#new)\n    * Added new `onImageMediumSaved()` event (useful for post-image processing)\n    * Added `Vary: Accept-Encoding` option\n2. [](#improved)\n    * Multilang-safe delimiter position\n    * Refactored Twig classes and added optional umask setting\n    * Removed `pageinit()` timing\n    * `Page->routable()` now takes `published()` state into account\n    * Improved how page extension is set\n    * Support `Language->translate()` method taking string and array\n3. [](#bugfix)\n    * Fixed `backup` command to include empty folders\n\n# v0.9.32\n## 07/14/2015\n\n1. [](#new)\n    * Detect users preferred language via `http_accept_language` setting\n    * Added new `translateArray()` language method\n2. [](#improved)\n    * Support `en` translations by default for plugins & themes\n    * Improved default generator tag\n    * Minor language tweaks and fixes\n3. [](#bugfix)\n    * Fix for session active language and homepage redirects\n    * Ignore root-level page rather than throwing error\n\n# v0.9.31\n## 07/09/2015\n\n1. [](#new)\n    * Added xml, json, css and js to valid media file types\n2. [](#improved)\n    * Better handling of unsupported media type downloads\n    * Improved `bin/grav backup` command to mimic admin plugin location/name\n3. [](#bugfix)\n    * Critical fix for broken language translations\n    * Fix for Twig markdown filter error\n    * Safety check for download extension\n\n# v0.9.30\n## 07/08/2015\n\n1. [](#new)\n    * BIG NEWS! Extensive Multi-Language support is all new in 0.9.30!\n    * Translation support via Twig filter/function and PHP method\n    * Page specific default route\n    * Page specific route aliases\n    * Canonical URL route support\n    * Added built-in session support\n    * New `Page.rawRoute()` to get a consistent folder-based route to a page\n    * Added option to always redirect to default page on alias URL\n    * Added language safe redirect function for use in core and plugins\n2. [](#improved)\n    * Improved `Page.active()` and `Page.activeChild()` methods to support route aliases\n    * Various spelling corrections in `.php` comments, `.md` and `.yaml` files\n    * `Utils::startsWith()` and `Utils::endsWith()` now support needle arrays\n    * Added a new timer around `pageInitialized` event\n    * Updated jQuery library to v2.1.4\n3. [](#bugfix)\n    * In-page CSS and JS files are now handled properly\n    * Fix for `enable_media_timestamp` not working properly\n\n# v0.9.29\n## 06/22/2015\n\n1. [](#new)\n    * New and improved Regex-powered redirect and route alias logic\n    * Added new `onBuildPagesInitialized` event for memory critical or time-consuming plugins\n    * Added a `setSummary()` method for pages\n2. [](#improved)\n    * Improved `MergeConfig()` logic for more control\n    * Travis skeleton build trigger implemented\n    * Set composer.json versions to stable versions where possible\n    * Disabled `last_modified` and `etag` page headers by default (causing too much page caching)\n3. [](#bugfix)\n    * Preload classes during `bin/gpm selfupgrade` to avoid issues with updated classes\n    * Fix for directory relative _down_ links\n\n# v0.9.28\n## 06/16/2015\n\n1. [](#new)\n    * Added method to set raw markdown on a page\n    * Added ability to enabled system and page level `etag` and `last_modified` headers\n2. [](#improved)\n    * Improved image path processing\n    * Improved query string handling\n    * Optimization to image handling supporting URL encoded filenames\n    * Use global `composer` when available rather than Grv provided one\n    * Use `PHP_BINARY` constant rather than `php` executable\n    * Updated Doctrine Cache library\n    * Updated Symfony libraries\n    * Moved `convertUrl()` method to Uri object\n3. [](#bugfix)\n    * Fix incorrect slug causing problems with CLI `uninstall`\n    * Fix Twig runtime error with assets pipeline in sufolder installations\n    * Fix for `+` in image filenames\n    * Fix for dot files causing issues with page processing\n    * Fix for Uri path detection on Windows platform\n    * Fix for alternative media resolutions\n    * Fix for modularTypes key properties\n\n# v0.9.27\n## 05/09/2015\n\n1. [](#new)\n    * Added new composer CLI command\n    * Added page-level summary header overrides\n    * Added `size` back for Media objects\n    * Refactored Backup command in preparation for admin plugin\n    * Added a new `parseLinks` method to Plugins class\n    * Added `starts_with` and `ends_with` Twig filters\n2. [](#improved)\n    * Optimized install of vendor libraries for speed improvement\n    * Improved configuration handling in preparation for admin plugin\n    * Cache optimization: Don't cache Twig templates when you pass dynamic params\n    * Moved `Utils::rcopy` to `Folder::rcopy`\n    * Improved `Folder::doDelete`\n    * Added check for required Curl in GPM\n    * Updated included composer.phar to latest version\n    * Various blueprint fixes for admin plugin\n    * Various PSR and code cleanup tasks\n3. [](#bugfix)\n    * Fix issue with Gzip not working with `onShutDown()` event\n    * Fix for URLs with trailing slashes\n    * Handle condition where certain errors resulted in blank page\n    * Fix for issue with theme name equal to base_url and asset pipeline\n    * Fix to properly normalize font rewrite path\n    * Fix for absolute URLs below the current page\n    * Fix for `..` page references\n\n# v0.9.26\n## 04/24/2015\n\n3. [](#bugfix)\n    * Fixed issue with homepage routes failing with 'dirname' error\n\n# v0.9.25\n## 04/24/2015\n\n1. [](#new)\n    * Added support for E-Tag, Last-Modified, Cache-Control and Page-based expires headers\n2. [](#improved)\n    * Refactored media image handling to make it more flexible and support absolute paths\n    * Refactored page modification check process to make it faster\n    * User account improvements in preparation for admin plugin\n    * Protect against timing attacks\n    * Reset default system expires time to 0 seconds (can override if you need to)\n3. [](#bugfix)\n    * Fix issues with spaces in webroot when using `bin/grav install`\n    * Fix for spaces in relative directory\n    * Bug fix in collection filtering\n\n# v0.9.24\n## 04/15/2015\n\n1. [](#new)\n    * Added support for chunked downloads of Assets\n    * Added new `onBeforeDownload()` event\n    * Added new `download()` and `getMimeType()` methods to Utils class\n    * Added configuration option for supported page types\n    * Added assets and media timestamp options (off by default)\n    * Added page expires configuration option\n2. [](#bugfix)\n    * Fixed issue with Nginx/Gzip and `ob_flush()` throwing error\n    * Fixed assets actions on 'direct media' URLs\n    * Fix for 'direct assets` with any parameters\n\n# v0.9.23\n## 04/09/2015\n\n1. [](#bugfix)\n    * Fix for broken GPM `selfupgrade` (Grav 0.9.21 and 0.9.22 will need to manually upgrade to this version)\n\n# v0.9.22\n## 04/08/2015\n\n1. [](#bugfix)\n    * Fix to normalize GRAV_ROOT path for Windows\n    * Fix to normalize Media image paths for Windows\n    * Fix for GPM `selfupgrade` when you are on latest version\n\n# v0.9.21\n## 04/07/2015\n\n1. [](#new)\n    * Major Media functionality enhancements: SVG, Animated GIF, Video support!\n    * Added ability to configure default image quality in system configuration\n    * Added `sizes` attributes for custom retina image breakpoints\n2. [](#improved)\n    * Don't scale @1x retina images\n    * Add filter to Iterator class\n    * Updated various composer packages\n    * Various PSR fixes\n\n# v0.9.20\n## 03/24/2015\n\n1. [](#new)\n    * Added `addAsyncJs()` and `addDeferJs()` to Assets manager\n    * Added support for extranal URL redirects\n2. [](#improved)\n    * Fix unpredictable asset ordering when set from plugin/system\n    * Updated `nginx.conf` to ensure system assets are accessible\n    * Ensure images are served as static files in Nginx\n    * Updated vendor libraries to latest versions\n    * Updated included composer.phar to latest version\n3. [](#bugfix)\n    * Fixed issue with markdown links to `#` breaking HTML\n\n# v0.9.19\n## 02/28/2015\n\n1. [](#new)\n    * Added named assets capability and bundled jQuery into Grav core\n    * Added `first()` and `last()` to `Iterator` class\n2. [](#improved)\n    * Improved page modification routine to skip _dot files_\n    * Only use files to calculate page modification dates\n    * Broke out Folder iterators into their own classes\n    * Various Sensiolabs Insight fixes\n3. [](#bugfix)\n    * Fixed `Iterator.nth()` method\n\n# v0.9.18\n## 02/19/2015\n\n1. [](#new)\n    * Added ability for GPM `install` to automatically install `_demo` content if found (w/backup)\n    * Added ability for themes and plugins to have dependencies required to install via GPM\n    * Added ability to override the system timezone rather than relying on server setting only\n    * Added new Twig filter `random_string` for generating random id values\n    * Added new Twig filter `markdown` for on-the-fly markdown processing\n    * Added new Twig filter `absoluteUrl` to convert relative to absolute URLs\n    * Added new `processTemplate()` method to Twig object for on-the-fly processing of twig template\n    * Added `rcopy()` and `contains()` helper methods in Utils\n2. [](#improved)\n    * Provided new `param_sep` variable to better support Apache on Windows\n    * Moved parsedown configuration into the trait\n    * Added optional **deep-copy** option to `mergeConfig()` for plugins\n    * Updated bundled `composer.phar` package\n    * Various Sensiolabs Insight fixes - Silver level now!\n    * Various PSR Fixes\n3. [](#bugfix)\n    * Fix for windows platforms not displaying installed themes/plugins via GPM\n    * Fix page IDs not picking up folder-only pages\n\n# v0.9.17\n## 02/05/2015\n\n1. [](#new)\n    * Added **full HHVM support!** Get your speed on with Facebook's crazy fast PHP JIT compiler\n2. [](#improved)\n    * More flexible page summary control\n    * Support **CamelCase** plugin and theme class names. Replaces dashes and underscores\n    * Moved summary delimiter into `site.yaml` so it can be configurable\n    * Various PSR fixes\n3. [](#bugfix)\n     * Fix for `mergeConfig()` not falling back to defaults\n     * Fix for `addInlineCss()` and `addInlineJs()` Assets not working between Twig tags\n     * Fix for Markdown adding HTML tags into inline CSS and JS\n\n# v0.9.16\n## 01/30/2015\n\n1. [](#new)\n    * Added **Retina** and **Responsive** image support via Grav media and `srcset` image attribute\n    * Added image debug option that overlays responsive resolution\n    * Added a new image cache stream\n2. [](#improved)\n    * Improved the markdown Lightbox functionality to better mimic Twig version\n    * Fullsize Lightbox can now have filters applied\n    * Added a new `mergeConfig()` method to Plugin class to merge system + page header configuration\n    * Added a new `disable()` method to Plugin class to programmatically disable a plugin\n    * Updated Parsedown and Parsedown Extra to address bugs\n    * Various PSR fixes\n3. [](#bugfix)\n     * Fix bug with image dispatch in traditionally _non-routable_ pages\n     * Fix for markdown link not working on non-current pages\n     * Fix for markdown images not being found on homepage\n\n# v0.9.15\n## 01/23/2015\n\n3. [](#bugfix)\n     * Typo in video mime types\n     * Fix for old `markdown_extra` system setting not getting picked up\n     * Fix in regex for Markdown links with numeric values in path\n     * Fix for broken image routing mechanism that got broken at some point\n     * Fix for markdown images/links in pages with page slug override\n\n# v0.9.14\n## 01/23/2015\n\n1. [](#new)\n    * Added **GZip** support\n    * Added multiple configurations via `setup.php`\n    * Added base structure for unit tests\n    * New `onPageContentRaw()` plugin event that processes before any page processing\n    * Added ability to dynamically set Metadata on page\n    * Added ability to dynamically configure Markdown processing via Parsedown options\n2. [](#improved)\n    * Refactored `page.content()` method to be more flexible and reliable\n    * Various updates and fixes for streams resulting in better multi-site support\n    * Updated Twig, Parsedown, ParsedownExtra, DoctrineCache libraries\n    * Refactored Parsedown trait\n    * Force modular pages to be non-visible in menus\n    * Moved RewriteBase before Exploits in `.htaccess`\n    * Added standard video formats to Media support\n    * Added priority for inline assets\n    * Check for uniqueness when adding multiple inline assets\n    * Improved support for Twig-based URLs inside Markdown links and images\n    * Improved Twig `url()` function\n3. [](#bugfix)\n    * Fix for HTML entities quotes in Metadata values\n    * Fix for `published` setting to have precedent of `publish_date` and `unpublish_date`\n    * Fix for `onShutdown()` events not closing connections properly in **php-fpm** environments\n\n# v0.9.13\n## 01/09/2015\n\n1. [](#new)\n    * Added new published `true|false` state in page headers\n    * Added `publish_date` in page headers to automatically publish page\n    * Added `unpublish_date` in page headers to automatically unpublish page\n    * Added `dateRange()` capability for collections\n    * Added ability to dynamically control Cache lifetime programmatically\n    * Added ability to sort by anything in the page header. E.g. `sort: header.taxonomy.year`\n    * Added various helper methods to collections: `copy, nonVisible, modular, nonModular, published, nonPublished, nonRoutable`\n2. [](#improved)\n    * Modified all Collection methods so they can be chained together: `$collection->published()->visible()`\n    * Set default Cache lifetime to default of 1 week (604800 seconds) - was infinite\n    * House-cleaning of some unused methods in Pages object\n3. [](#bugfix)\n    * Fix `uninstall` GPM command that was broken in last release\n    * Fix for intermittent `undefined index` error when working with Collections\n    * Fix for date of some pages being set to incorrect future timestamps\n\n# v0.9.12\n## 01/06/2015\n\n1. [](#new)\n    * Added an all-access robots.txt file for search engines\n    * Added new GPM `uninstall` command\n    * Added support for **in-page** Twig processing in **modular** pages\n    * Added configurable support for `undefined` Twig functions and filters\n2. [](#improved)\n    * Fall back to default `.html` template if error occurs on non-html pages\n    * Added ability to have PSR-1 friendly plugin names (CamelCase, no-dashes)\n    * Fix to `composer.json` to deter API rate-limit errors\n    * Added **non-exception-throwing** handler for undefined methods on `Medium` objects\n3. [](#bugfix)\n    * Fix description for `self-upgrade` method of GPM command\n    * Fix for incorrect version number when performing GPM `update`\n    * Fix for argument description of GPM `install` command\n    * Fix for recalcitrant CodeKit mac application\n\n# v0.9.11\n## 12/21/2014\n\n1. [](#new)\n    * Added support for simple redirects as well as routes\n2. [](#improved)\n    * Handle Twig errors more cleanly\n3. [](#bugfix)\n    * Fix for error caused by invalid or missing user agent string\n    * Fix for directory relative links and URL fragments (#pagelink)\n    * Fix for relative links with no subfolder in `base_url`\n\n# v0.9.10\n## 12/12/2014\n\n1. [](#new)\n    * Added Facebook-style `nicetime` date Twig filter\n2. [](#improved)\n    * Moved `clear-cache` functionality into Cache object required for Admin plugin\n3. [](#bugfix)\n    * Fix for undefined index with previous/next buttons\n\n# v0.9.9\n## 12/05/2014\n\n1. [](#new)\n    * Added new `@page` collection type\n    * Added `ksort` and `contains` Twig filters\n    * Added `gist` Twig function\n2. [](#improved)\n    * Refactored Page previous/next/adjacent functionality\n    * Updated to Symfony 2.6 for yaml/console/event-dispatcher libraries\n    * More PSR code fixes\n3. [](#bugfix)\n    * Fix for over-escaped apostrophes in YAML\n\n# v0.9.8\n## 12/01/2014\n\n1. [](#new)\n    * Added configuration option to set default lifetime on cache saves\n    * Added ability to set HTTP status code from page header\n    * Implemented simple wild-card custom routing\n2. [](#improved)\n    * Fixed elusive double load to fully cache issue (crossing fingers...)\n    * Ensure Twig tags are treated as block items in markdown\n    * Removed some older deprecated methods\n    * Ensure onPageContentProcessed() event only fires when not cached\n    * More PSR code fixes\n3. [](#bugfix)\n    * Fix issue with miscalculation of blog separator location `===`\n\n# v0.9.7\n## 11/24/2014\n\n1. [](#improved)\n    * Nginx configuration updated\n    * Added gitter.im badge to README\n    * Removed `set_time_limit()` and put checks around `ignore_user_abort`\n    * More PSR code fixes\n2. [](#bugfix)\n    * Fix issue with non-valid asset path showing up when they shouldn't\n    * Fix for JS asset pipeline and scripts that don't end in `;`\n    * Fix for schema-based markdown URLs broken routes (eg `mailto:`)\n\n# v0.9.6\n## 11/17/2014\n\n1. [](#improved)\n    * Moved base_url variables into Grav container\n    * Forced media sorting to use natural sort order by default\n    * Various PSR code tidying\n    * Added filename, extension, thumb to all medium objects\n2. [](#bugfix)\n    * Fix for infinite loop in page.content()\n    * Fix hostname for configuration overrides\n    * Fix for cached configuration\n    * Fix for relative URLs in markdown on installs with no base_url\n    * Fix for page media images with uppercase extension\n\n# v0.9.5\n## 11/09/2014\n\n1. [](#new)\n    * Added quality setting to medium for compression configuration of images\n    * Added new onPageContentProcessed() event that is post-content processing but pre-caching\n2. [](#improved)\n    * Added support for AND and OR taxonomy filtering.  AND by default (was OR)\n    * Added specific clearing options for CLI clear-cache command\n    * Moved environment method to URI so it can be accessible in plugins and themes\n    * Set Grav's output variable to public so it can be manipulated in onOutputGenerated event\n    * Updated vendor libraries to latest versions\n    * Better handing of 'home' in active menu state detection\n    * Various PSR code tidying\n    * Improved some error messages and notices\n3. [](#bugfix)\n    * Force route rebuild when configuration changes\n    * Fix for 'installed undefined' error in CLI versions command\n    * Do not remove the JSON/Text error handlers\n    * Fix for supporting inline JS and CSS when Asset pipeline enabled\n    * Fix for Data URLs in CSS being badly formed\n    * Fix Markdown links with fragment and query elements\n\n# v0.9.4\n## 10/29/2014\n\n1. [](#new)\n    * New improved Debugbar with messages, timing, config, twig information\n    * New exception handling system utilizing Whoops\n    * New logging system utilizing Monolog\n    * Support for auto-detecting environment configuration\n    * New version command for CLI\n    * Integrate Twig dump() calls into Debugbar\n2. [](#improved)\n    * Selfupgrade now clears cache on successful upgrade\n    * Selfupgrade now supports files without extensions\n    * Improved error messages when plugin is missing\n    * Improved security in .htaccess\n    * Support CSS/JS/Image assets in vendor/system folders via .htaccess\n    * Add support for system timers\n    * Improved and optimized configuration loading\n    * Automatically disable Debugbar on non-HTML pages\n    * Disable Debugbar by default\n3. [](#bugfix)\n    * More YAML blueprint fixes\n    * Fix potential double // in assets\n    * Load debugger as early as possible\n\n# v0.9.3\n## 10/09/2014\n\n1. [](#new)\n    * GPM (Grav Package Manager) Added\n    * Support for multiple Grav configurations\n    * Dynamic media support via URL\n    * Added inlineCss and inlineJs support for Assets\n2. [](#improved)\n    * YAML caching for increased performance\n    * Use stream wrapper in pages, plugins and themes\n    * Switched to RocketTheme toolbox for some core functionality\n    * Renamed `setup` CLI command to `sandbox`\n    * Broke cache types out into multiple directories in the cache folder\n    * Removed vendor libs from github repository\n    * Various PSR cleanup of code\n    * Various Blueprint updates to support upcoming admin plugin\n    * Added ability to filter page children for normal/modular/all\n    * Added `sort_by_key` twig filter\n    * Added `visible()` and `routable()` filters to page collections\n    * Use session class in shutdown process\n    * Improvements to modular page loading\n    * Various code cleanup and optimizations\n3. [](#bugfix)\n    * Fixed file checking not updating the last modified time. For real this time!\n    * Switched debugger to PRODUCTION mode by default\n    * Various fixes in URI class for increased reliability\n\n# v0.9.2\n## 09/15/2014\n\n1. [](#new)\n    * New flexible site and page metadata support including ObjectGraph and Facebook\n    * New method to get user IP address in URI object\n    * Added new onShutdown() event that fires after connection is closed for Async features\n2. [](#improved)\n    * Skip assets pipeline minify on Windows platforms by default due to PHP issue 47689\n    * Fixed multiple level menus not highlighting correctly\n    * Updated some blueprints in preparation for admin plugin\n    * Fail gracefully when theme does not exist\n    * Add stream support into ResourceLocator::addPath()\n    * Separate themes from plugins, add themes:// stream and onTask events\n    * Added barDump() to Debugger\n    * Removed stray test page\n    * Override modified only if a non-markdown file was modified\n    * Added assets attributes support\n    * Auto-run composer install when running the Grav CLI\n    * Vendor folder removed from repository\n    * Minor configuration performance optimizations\n    * Minor debugger performance optimizations\n3. [](#bugfix)\n    * Fix url() twig function when Grav isn't installed at root\n    * Workaround for PHP bug 52065\n    * Fixed getList() method on Pages object that was not working\n    * Fix for open_basedir error\n    * index.php now warns if not running on PHP 5.4\n    * Removed memcached option (redundant)\n    * Removed memcache from auto setup, added memcache server configuration option\n    * Fix broken password validation\n    * Back to proper PSR-4 Autoloader\n\n# v0.9.1\n## 09/02/2014\n\n1. [](#new)\n    * Added new `theme://` PHP stream for current theme\n2. [](#improved)\n    * Default to new `file` modification checking rather than `folder`\n    * Added support for various markdown link formats to convert to Grav-friendly URLs\n    * Moved configure() from Theme to Themes class\n    * Fix autoloading without composer update -o\n    * Added support for Twig url method\n    * Minor code cleanup\n3. [](#bugfix)\n    * Fixed issue with page changes not being picked up\n    * Fixed Minify to provide `@supports` tag compatibility\n    * Fixed ResourceLocator not working with multiple paths\n    * Fixed issue with Markdown process not stripping LFs\n    * Restrict file type extensions for added security\n    * Fixed template inheritance\n    * Moved Browser class to proper location\n\n# v0.9.0\n## 08/25/2014\n\n1. [](#new)\n    * Addition of Dependency Injection Container\n    * Refactored plugins to use Symfony Event Dispatcher\n    * New Asset Manager to provide unified management of JavaScript and CSS\n    * Asset Pipelining to provide unification, minify, and optimization of JavaScript and CSS\n    * Grav Media support directly in Markdown syntax\n    * Additional Grav Generator meta tag in default themes\n    * Added support for PHP Stream Wrapper for resource location\n    * Markdown Extra support\n    * Browser object for fast browser detection\n2. [](#improved)\n    * PSR-4 Autoloader mechanism\n    * Tracy Debugger new `detect` option to detect running environment\n    * Added new `random` collection sort option\n    * Make media images progressive by default\n    * Additional URI filtering for improved security\n    * Safety checks to ensure PHP 5.4.0+\n    * Move to Slidebars side navigation in default Antimatter theme\n    * Updates to `.htaccess` including section on `RewriteBase` which is needed for some hosting providers\n3. [](#bugfix)\n    * Fixed issue when installing in an apache userdir (~username) folder\n    * Various mobile CSS issues in default themes\n    * Various minor bug fixes\n\n\n# v0.8.0\n## 08/13/2014\n\n1. [](#new)\n    * Initial Release\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "\n# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\n[INSERT CONTACT METHOD].\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\n[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].\n\nCommunity Impact Guidelines were inspired by\n[Mozilla's code of conduct enforcement ladder][Mozilla CoC].\n\nFor answers to common questions about this code of conduct, see the FAQ at\n[https://www.contributor-covenant.org/faq][FAQ]. Translations are available\nat [https://www.contributor-covenant.org/translations][translations].\n\n[homepage]: https://www.contributor-covenant.org\n[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html\n[Mozilla CoC]: https://github.com/mozilla/diversity\n[FAQ]: https://www.contributor-covenant.org/faq\n[translations]: https://www.contributor-covenant.org/translations\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Grav\n\n:+1::tada: First, thanks for getting involved with Grav! :tada::+1:\n\nPlease take a moment to review this document in order to make the contribution\nprocess easy and effective for everyone involved.\n\nFollowing these guidelines helps to communicate that you respect the time of\nthe developers managing and developing this open source project. In return,\nthey should reciprocate that respect in addressing your issue or assessing\npatches and features.\n\n## Grav, Plugins, Themes and Skeletons\n\nGrav is a large open source project — it's made up of over 100 repositories. When you initially consider contributing to Grav, you might be unsure about which of those 200 repositories implements the functionality you want to change or report a bug for.\n\n[https://github.com/getgrav/grav](https://github.com/getgrav/grav) is the main Grav repository. The core of Grav is provided by this repo.\n\n[https://github.com/getgrav/grav-plugin-admin](https://github.com/getgrav/grav-plugin-admin) is the Admin Plugin repository.\n\nEvery Plugin and Theme has its own repository. If you have a problem you think is specific to a Theme or Plugin, please report it in its corresponding repository. Please read the Plugin or Theme documentation to ensure the problem is not addressed there already.\n\nEvery Skeleton also has its own repository, so if an issue is not specific to a theme or plugin but rather to its usage in the skeleton, report it in the skeleton repository.\n\n## Using the issue tracker\n\nThe issue tracker is the preferred channel for [bug reports](#bugs),\n[features requests](#features) and [submitting pull\nrequests](#pull-requests), but please respect the following restrictions:\n\n* Please **do not** use the issue tracker for support requests. Use\n  [the Forum](http://getgrav.org/forum) or [the Chat](https://chat.getgrav.org/).\n\n\n<a name=\"bugs\"></a>\n## Bug reports\n\nA bug is a _demonstrable problem_ that is caused by the code in the repository.\nGood bug reports are extremely helpful - thank you!\n\nGuidelines for bug reports:\n\n1. **Check you satisfy the Grav requirements** &mdash; [http://learn.getgrav.org/basics/requirements](http://learn.getgrav.org/basics/requirements)\n\n2. **Check this happens on a clean Grav install** &mdash; check if the issue happens on any Grav site, or just with a specific configuration of plugins / theme\n\n3. **Use the GitHub issue search** &mdash; check if the issue has already been\n   reported.\n\n4. **Check if the issue is already being solved in a PR** &mdash; check the open Pull Requests to see if one already solves the problem you're having\n\n5. **Check if the issue has been fixed** &mdash; try to reproduce it using the\n   latest `develop` branch in the repository.\n\n6. **Isolate the problem** &mdash; create a [reduced test\n   case](http://css-tricks.com/reduced-test-cases/) and provide a step-by-step instruction set on how to recreate the problem. Include code samples, page snippets or yaml configurations if needed.\n\n7. **Check the problem on Grav 1.1** &mdash; if you're using Grav 1.0, latest stable release, please also check if you can replicate the issue on Grav 1.1 RC as many bugs are already solved in the next Grav release.\n\nA good bug report shouldn't leave others needing to chase you up for more\ninformation. Please try to be as detailed as possible in your report.\n\n- What is your environment? Is it localhost, OSX, Linux, on a remote server? Same happening locally and or the server, or just locally or just on Linux?\n\n- What steps will reproduce the issue? What browser(s) and OS experience the problem?\n\n- What would you expect to be the outcome?\n\n- Did the problem start happening recently (e.g. after updating to a new version of Grav) or was this always a problem?\n\n- If the problem started happening recently, can you reproduce the problem in an older version of Grav? What's the most recent version in which the problem doesn't happen? You can download older versions of Grav from the releases page on Github.\n\n- Can you reliably reproduce the issue? If not, provide details about how often the problem happens and under which conditions it normally happens.\n\n\nAll these details will help contributors to fix any potential bugs.\n\nImportant: [include Code Samples in triple backticks](https://help.github.com/articles/github-flavored-markdown/#fenced-code-blocks) so that Github will provide a proper indentation. [Add the language name after the backticks](https://help.github.com/articles/github-flavored-markdown/#syntax-highlighting) to add syntax highlighting to the code snippets.\n\nExample:\n\n> Short and descriptive example bug report title\n>\n> A summary of the issue and the browser/OS environment in which it occurs. If\n> suitable, include the steps required to reproduce the bug.\n>\n> 1. This is the first step\n> 2. This is the second step\n> 3. Further steps, etc.\n>>\n> Any other information you want to share that is relevant to the issue being\n> reported. This might include the lines of code that you have identified as\n> causing the bug, and potential solutions (and your opinions on their\n> merits).\n\n\n<a name=\"features\"></a>\n## Feature requests\n\nFeature requests are welcome. But take a moment to find out whether your idea\nfits with the scope and aims of the project. It's up to *you* to make a strong\ncase to convince the project's developers of the merits of this feature. Please\nprovide as much detail and context as possible.\n\n\n<a name=\"pull-requests\"></a>\n## Pull requests\n\nGood pull requests - patches, improvements, new features - are a fantastic\nhelp. They should remain focused in scope and avoid containing unrelated\ncommits.\n\n**Please ask first** in [the Forum](http://getgrav.org/forum) or [the Chat](https://chat.getgrav.org/) \nbefore embarking on any significant pull request (e.g.\nimplementing features, refactoring code..),\notherwise you risk spending a lot of time working on something that the\nproject's developers might not want to merge into the project.\n\nPlease adhere to the coding conventions used throughout the project (indentation,\naccurate comments, etc.) and any other requirements.\n\nSee [Using Pull Request](https://help.github.com/articles/using-pull-requests/) and [Fork a Repo](https://help.github.com/articles/fork-a-repo/) if you're not familiar with Pull Requests.\n\nAny pull request should be based on the `develop` branch. We will not consider pull requests made to master.\n\n**IMPORTANT**: By submitting a patch, you agree to allow the project owner to\nlicense your work under the same license as that used by the project.\n\n<a name=\"translations\"></a>\n### Translations\nTranslations for Grav core and the Admin plugin are managed through Crowdin:\n\n- Admin: https://crowdin.com/project/grav-admin\n- Core: https://crowdin.com/project/grav-core\n\nPlease do not post translations PRs for core or admin translations on GitHub, with the exception of fixes for the english language.\n\nAll other plugins and themes translations are handled directly in their GitHub repository, and the string are usually found in the `languages.yaml` file at the root of each project.\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2021 Grav\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# ![](https://avatars1.githubusercontent.com/u/8237355?v=2&s=50) Grav\n\n[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-brightgreen.svg?style=flat)](https://github.com/phpstan/phpstan)\n[![Discord](https://img.shields.io/discord/501836936584101899.svg?logo=discord&colorB=728ADA&label=Discord%20Chat)](https://chat.getgrav.org)\n [![PHP Tests](https://github.com/getgrav/grav/workflows/PHP%20Tests/badge.svg?branch=develop)](https://github.com/getgrav/grav/actions?query=workflow%3A%22PHP+Tests%22) [![OpenCollective](https://opencollective.com/grav/tiers/backers/badge.svg?label=Backers&color=brightgreen)](#backers) [![OpenCollective](https://opencollective.com/grav/tiers/supporters/badge.svg?label=Supporters&color=brightgreen)](#supporters) [![OpenCollective](https://opencollective.com/grav/tiers/sponsors/badge.svg?label=Sponsors&color=brightgreen)](#sponsors)\n\nGrav is a **Fast**, **Simple**, and **Flexible**, file-based Web-platform.  There is **Zero** installation required.  Just extract the ZIP archive, and you are already up and running.  It follows similar principles to other flat-file CMS platforms, but has a different design philosophy than most. Grav comes with a powerful **Package Management System** to allow for simple installation and upgrading of plugins and themes, as well as simple updating of Grav itself.\n\nThe underlying architecture of Grav is designed to use well-established and _best-in-class_ technologies to ensure that Grav is simple to use and easy to extend. Some of these key technologies include:\n\n* [Twig Templating](https://twig.symfony.com/): for powerful control of the user interface\n* [Markdown](https://en.wikipedia.org/wiki/Markdown): for easy content creation\n* [YAML](https://yaml.org): for simple configuration\n* [Parsedown](https://parsedown.org/): for fast Markdown and Markdown Extra support\n* [Doctrine Cache](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/caching.html): layer for performance\n* [Pimple Dependency Injection Container](https://github.com/silexphp/Pimple): for extensibility and maintainability\n* [Symfony Event Dispatcher](https://symfony.com/doc/current/components/event_dispatcher/introduction.html): for plugin event handling\n* [Symfony Console](https://symfony.com/doc/current/components/console/introduction.html): for CLI interface\n* [Gregwar Image Library](https://github.com/Gregwar/Image): for dynamic image manipulation\n\n# Requirements\n\n- PHP 7.3.6 or higher. Check the [required modules list](https://learn.getgrav.org/basics/requirements#php-requirements)\n- Check the [Apache](https://learn.getgrav.org/basics/requirements#apache-requirements) or [IIS](https://learn.getgrav.org/basics/requirements#iis-requirements) requirements\n\n# Documentation\n\nThe full documentation can be found from [learn.getgrav.org](https://learn.getgrav.org).\n\n# QuickStart\n\nThese are the options to get Grav:\n\n### Downloading a Grav Package\n\nYou can download a **ready-built** package from the [Downloads page on https://getgrav.org](https://getgrav.org/downloads)\n\n### With Composer\n\nYou can create a new project with the latest **stable** Grav release with the following command:\n\n```bash\ncomposer create-project getgrav/grav ~/webroot/grav\n```\n\n### From GitHub\n\n1. Clone the Grav repository from [https://github.com/getgrav/grav]() to a folder in the webroot of your server, e.g. `~/webroot/grav`. Launch a **terminal** or **console** and navigate to the webroot folder:\n   ```bash\n   cd ~/webroot\n   git clone https://github.com/getgrav/grav.git\n   ```\n\n2. Install the **plugin** and **theme dependencies** by using the [Grav CLI application](https://learn.getgrav.org/advanced/grav-cli) `bin/grav`:\n   ```bash\n   cd ~/webroot/grav\n   bin/grav install\n   ```\n\nCheck out the [install procedures](https://learn.getgrav.org/basics/installation) for more information.\n\n# Adding Functionality\n\nYou can download [plugins](https://getgrav.org/downloads/plugins) or [themes](https://getgrav.org/downloads/themes) manually from the appropriate tab on the [Downloads page on https://getgrav.org](https://getgrav.org/downloads), but the preferred solution is to use the [Grav Package Manager](https://learn.getgrav.org/advanced/grav-gpm) or `GPM`:\n\n```bash\nbin/gpm index\n```\n\nThis will display all the available plugins and then you can install one or more with:\n\n```bash\nbin/gpm install <plugin/theme>\n```\n\n# Updating\n\nTo update Grav you should use the [Grav Package Manager](https://learn.getgrav.org/advanced/grav-gpm) or `GPM`:\n\n```bash\nbin/gpm selfupgrade\n```\n\nTo update plugins and themes:\n\n```bash\nbin/gpm update\n```\n\n## Upgrading from older version\n\n* [Upgrading to Grav 1.7](https://learn.getgrav.org/16/advanced/grav-development/grav-17-upgrade-guide)\n* [Upgrading to Grav 1.6](https://learn.getgrav.org/16/advanced/grav-development/grav-16-upgrade-guide)\n* [Upgrading from Grav <1.6](https://learn.getgrav.org/16/advanced/grav-development/grav-15-upgrade-guide)\n\n# Contributing\nWe appreciate any contribution to Grav, whether it is related to bugs, grammar, or simply a suggestion or improvement! Please refer to the [Contributing guide](CONTRIBUTING.md) for more guidance on this topic.\n\n## Security issues\nIf you discover a possible security issue related to Grav or one of its plugins, please email the core team at contact@getgrav.org and we'll address it as soon as possible.\n\n# Getting Started\n\n* [What is Grav?](https://learn.getgrav.org/basics/what-is-grav)\n* [Install](https://learn.getgrav.org/basics/installation) Grav in few seconds\n* Understand the [Configuration](https://learn.getgrav.org/basics/grav-configuration)\n* Take a peek at our available free [Skeletons](https://getgrav.org/downloads/skeletons)\n* If you have questions, jump on our [Discord Chat Server](https://chat.getgrav.org)!\n* Have fun!\n\n# Exploring More\n\n* Have a look at our [Basic Tutorial](https://learn.getgrav.org/basics/basic-tutorial)\n* Dive into more [advanced](https://learn.getgrav.org/advanced) functions\n* Learn about the [Grav CLI](https://learn.getgrav.org/cli-console/grav-cli)\n* Review examples in the [Grav Cookbook](https://learn.getgrav.org/cookbook)\n* More [Awesome Grav Stuff](https://github.com/getgrav/awesome-grav)\n\n# Backers\nSupport Grav with a monthly donation to help us continue development. [[Become a backer](https://opencollective.com/grav/contribute)]\n\n<img src=\"https://opencollective.com/grav/tiers/backers.svg?avatarHeight=36&width=600\" />\n\n\n# Supporters\nSupport Grav with a monthly donation to help us continue development. [[Become a supporter](https://opencollective.com/grav/contribute)]\n\n<img src=\"https://opencollective.com/grav/tiers/supporters.svg?avatarHeight=36&width=600\" />\n\n\n# Sponsors\nSupport Grav with a yearly donation to help us continue development. [[Become a sponsor](https://opencollective.com/grav/contribute)]\n\n<img src=\"https://opencollective.com/grav/tiers/sponsors.svg?avatarHeight=36&width=600\" />\n\n# License\n\nSee [LICENSE](LICENSE.txt)\n\n\n[gitflow-model]: http://nvie.com/posts/a-successful-git-branching-model/\n[gitflow-extensions]: https://github.com/nvie/gitflow\n\n# Running Tests\n\nFirst install the dev dependencies by running `composer install` from the Grav root.\n\nThen `composer test` will run the Unit Tests, which should be always executed successfully on any site.\nWindows users should use the `composer test-windows` command.\nYou can also run a single unit test file, e.g. `composer test tests/unit/Grav/Common/AssetsTest.php`\n\nTo run phpstan tests, you should run:\n\n* `composer phpstan` for global tests\n* `composer phpstan-framework` for more strict tests\n* `composer phpstan-plugins` to test all installed plugins\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nWe are focusing our security updates on the following versions\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 1.7.x   | :white_check_mark: |\n| 1.6.x   | :x:          |\n| < 1.6   | :x:                |\n\n## :pushpin: Note on Security Severity\n\n> NOTE: Please use the following guidelines when selecting a **Severity**.  Submitted advisories that are marked **High** or **Critical** that don't meet the guidelines below will be closed.\n\n* **CRITICAL** - no account required, can modify content, or run malicious code or nefarious activity without any access.\n* **HIGH** - publisher level account able to run malicious code or nefarious activity, or other high level security things.\n* **MODERATE** - admin level account able to run malicious code or do nefarious things. other moderate security things.\n* **LOW** - super admin level account able to run malicious code or do nefarious things. other minor security things.\n\n## :warning: Versions\n\nVersions with :warning: will be supported for security issues, however you won't be able to update to them, you will need to manually update through the [`direct-install` command](https://learn.getgrav.org/17/admin-panel/tools).\n\nIf you cannot update to the latest stable version available because, for example, your server does not meet the minimum PHP requirements, you can manually install a previous version by downloading the package from our Releases directory (https://github.com/getgrav/grav/releases).\n\n## :pencil: Reporting a Vulnerability\n\nPlease contact security@getgrav.org with a detailed explanation of the security issue found.  If it appears to be a legitimate issues, please submit an **advisory via GitHub Security**: https://github.com/getgrav/grav/security/advisories\n\n> NOTE: Please do not use 3rd party security issue reporting services, we like to keep everything in the GitHub ecosystem for easier manageability.\n\n## :bug: Bug Bounties\n\nWe do greatly appreciate your efforts to improve Grav, but unfortunately because we are a small open source project, we **do not have the resources to offer bounties** for security issues found.  \n\n\n"
  },
  {
    "path": "assets/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "backup/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "bin/gpm",
    "content": "#!/usr/bin/env php\n<?php\n\n/**\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nuse Grav\\Common\\Composer;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\Application\\GpmApplication;\n\n\\define('GRAV_CLI', true);\n\\define('GRAV_REQUEST_TIME', microtime(true));\n\nif (!file_exists(__DIR__ . '/../vendor/autoload.php')){\n    // Before we can even start, we need to run composer first\n    require_once __DIR__ . '/../system/src/Grav/Common/Composer.php';\n\n    $composer = Composer::getComposerExecutor();\n    echo \"Preparing to install vendor dependencies...\\n\\n\";\n    echo system($composer.' --working-dir=\"'.__DIR__.'/../\" --no-interaction --no-dev --prefer-dist -o install');\n    echo \"\\n\\n\";\n}\n\n$autoload = require __DIR__ . '/../vendor/autoload.php';\n\n// Set timezone to default, falls back to system if php.ini not set\ndate_default_timezone_set(@date_default_timezone_get());\n\n// Set internal encoding.\n@ini_set('default_charset', 'UTF-8');\nmb_internal_encoding('UTF-8');\n\nif (!file_exists(GRAV_ROOT . '/index.php')) {\n    exit('FATAL: Must be run from ROOT directory of Grav!');\n}\n\nif (!function_exists('curl_version')) {\n    exit('FATAL: GPM requires PHP Curl module to be installed');\n}\n\n$grav = Grav::instance(array('loader' => $autoload));\n\n$app = new GpmApplication('Grav Package Manager', GRAV_VERSION);\n$app->run();\n"
  },
  {
    "path": "bin/grav",
    "content": "#!/usr/bin/env php\n<?php\n\n/**\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nuse Grav\\Common\\Composer;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\Application\\GravApplication;\n\n\\define('GRAV_CLI', true);\n\\define('GRAV_REQUEST_TIME', microtime(true));\n\nif (!file_exists(__DIR__ . '/../vendor/autoload.php')){\n    // Before we can even start, we need to run composer first\n    require_once __DIR__ . '/../system/src/Grav/Common/Composer.php';\n\n    $composer = Composer::getComposerExecutor();\n    echo \"Preparing to install vendor dependencies...\\n\\n\";\n    echo system($composer.' --working-dir=\"'.__DIR__.'/../\" --no-interaction --no-dev --prefer-dist -o install');\n    echo \"\\n\\n\";\n}\n\n$autoload = require __DIR__ . '/../vendor/autoload.php';\n\n// Set timezone to default, falls back to system if php.ini not set\ndate_default_timezone_set(@date_default_timezone_get());\n\n// Set internal encoding.\n@ini_set('default_charset', 'UTF-8');\nmb_internal_encoding('UTF-8');\n\n$grav = Grav::instance(array('loader' => $autoload));\n\nif (!file_exists(GRAV_ROOT . '/index.php')) {\n    exit('FATAL: Must be run from ROOT directory of Grav!');\n}\n\n$app = new GravApplication('Grav CLI Application', GRAV_VERSION);\n$app->run();\n"
  },
  {
    "path": "bin/plugin",
    "content": "#!/usr/bin/env php\n<?php\n\n/**\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nuse Grav\\Common\\Composer;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\Application\\PluginApplication;\n\n\\define('GRAV_CLI', true);\n\\define('GRAV_REQUEST_TIME', microtime(true));\n\nif (!file_exists(__DIR__ . '/../vendor/autoload.php')){\n    // Before we can even start, we need to run composer first\n    require_once __DIR__ . '/../system/src/Grav/Common/Composer.php';\n\n    $composer = Composer::getComposerExecutor();\n    echo \"Preparing to install vendor dependencies...\\n\\n\";\n    echo system($composer.' --working-dir=\"'.__DIR__.'/../\" --no-interaction --no-dev --prefer-dist -o install');\n    echo \"\\n\\n\";\n}\n\n$autoload = require __DIR__ . '/../vendor/autoload.php';\n\n// Set timezone to default, falls back to system if php.ini not set\ndate_default_timezone_set(@date_default_timezone_get());\n\n// Set internal encoding.\n@ini_set('default_charset', 'UTF-8');\nmb_internal_encoding('UTF-8');\n\nif (!file_exists(GRAV_ROOT . '/index.php')) {\n    exit('FATAL: Must be run from ROOT directory of Grav!');\n}\n\n// Bootstrap Grav container.\n$grav = Grav::instance(array('loader' => $autoload));\n\n$app = new PluginApplication('Grav Plugins Commands', GRAV_VERSION);\n$app->run();\n"
  },
  {
    "path": "cache/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "codeception.yml",
    "content": "actor: Tester\nbootstrap: _bootstrap.php\npaths:\n    tests: tests\n    log: tests/_output\n    data: tests/_data\n    support: tests/_support\n    envs: tests/_envs\nsettings:\n    colors: true\n    memory_limit: 1024M\nextensions:\n    enabled:\n        - Codeception\\Extension\\RunFailed\nmodules:\n    config:\n"
  },
  {
    "path": "composer.json",
    "content": "{\n    \"name\": \"getgrav/grav\",\n    \"type\": \"project\",\n    \"description\": \"Modern, Crazy Fast, Ridiculously Easy and Amazingly Powerful Flat-File CMS\",\n    \"keywords\": [\n        \"cms\",\n        \"flat-file cms\",\n        \"flat cms\",\n        \"flatfile cms\",\n        \"php\"\n    ],\n    \"homepage\": \"https://getgrav.org\",\n    \"license\": \"MIT\",\n    \"require\": {\n        \"php\": \"^7.3.6 || ^8.0\",\n        \"ext-json\": \"*\",\n        \"ext-openssl\": \"*\",\n        \"ext-curl\": \"*\",\n        \"ext-zip\": \"*\",\n        \"ext-dom\": \"*\",\n        \"ext-libxml\": \"*\",\n        \"ext-gd\": \"*\",\n        \"symfony/polyfill-mbstring\": \"~1.23\",\n        \"symfony/polyfill-iconv\": \"^1.23\",\n        \"symfony/polyfill-php74\": \"^1.23\",\n        \"symfony/polyfill-php80\": \"^1.23\",\n        \"symfony/polyfill-php81\": \"^1.23\",\n        \"psr/simple-cache\": \"^1.0\",\n        \"psr/http-message\": \"^1.0\",\n        \"psr/http-server-middleware\": \"^1.0\",\n        \"psr/container\": \"~1.1.0\",\n        \"nyholm/psr7-server\": \"^1.0\",\n        \"nyholm/psr7\": \"^1.3\",\n        \"twig/twig\": \"~v1.44\",\n        \"erusev/parsedown\": \"~1.7.4\",\n        \"erusev/parsedown-extra\": \"~0.8.1\",\n        \"symfony/contracts\": \"~1.1\",\n        \"symfony/yaml\": \"~4.4\",\n        \"symfony/console\": \"~4.4\",\n        \"symfony/event-dispatcher\": \"~4.4\",\n        \"symfony/var-dumper\": \"~4.4\",\n        \"symfony/process\": \"~4.4\",\n        \"doctrine/cache\": \"^1.10\",\n        \"doctrine/collections\": \"^1.6\",\n        \"guzzlehttp/psr7\": \"^1.7\",\n        \"filp/whoops\": \"~2.9\",\n        \"matthiasmullie/minify\": \"^1.3\",\n        \"monolog/monolog\": \"~1.25\",\n        \"getgrav/image\": \"^4.0\",\n        \"getgrav/cache\": \"^2.0\",\n        \"donatj/phpuseragentparser\": \"~1.1\",\n        \"pimple/pimple\": \"~3.5.0\",\n        \"rockettheme/toolbox\": \"~1.5\",\n        \"maximebf/debugbar\": \"~1.16\",\n        \"league/climate\": \"^3.6\",\n        \"miljar/php-exif\": \"^0.6\",\n        \"composer/ca-bundle\": \"^1.2\",\n        \"dragonmantank/cron-expression\": \"^3.3\",\n        \"willdurand/negotiation\": \"^3.0\",\n        \"itsgoingd/clockwork\": \"^5.0\",\n        \"symfony/http-client\": \"^4.4\",\n        \"composer/semver\": \"^1.4\",\n        \"rhukster/dom-sanitizer\": \"^1.0\",\n        \"multiavatar/multiavatar-php\": \"^1.0\"\n    },\n    \"require-dev\": {\n        \"codeception/codeception\": \"^4.1\",\n        \"phpstan/phpstan\": \"^1.8\",\n        \"phpstan/phpstan-deprecation-rules\": \"^1.0\",\n        \"phpunit/php-code-coverage\": \"~9.2\",\n        \"getgrav/markdowndocs\": \"^2.0\",\n        \"codeception/module-asserts\": \"^1.3\",\n        \"codeception/module-phpbrowser\": \"^1.0\"\n    },\n    \"replace\": {\n        \"symfony/polyfill-php72\": \"*\",\n        \"symfony/polyfill-php73\": \"*\"\n    },\n    \"suggest\": {\n        \"ext-mbstring\": \"Recommended for better performance\",\n        \"ext-iconv\": \"Recommended for better performance\",\n        \"ext-zend-opcache\": \"Recommended for better performance\",\n        \"ext-intl\": \"Recommended for multi-language sites\",\n        \"ext-memcache\": \"Needed to support Memcache servers\",\n        \"ext-memcached\": \"Needed to support Memcached servers\",\n        \"ext-redis\": \"Needed to support Redis servers\",\n        \"ext-exif\": \"Needed to use exif data from images.\"\n    },\n    \"config\": {\n        \"apcu-autoloader\": true,\n        \"audit\": {\n            \"ignore\": [\n                \"PKSA-yhcn-xrg3-68b1\",\n                \"PKSA-2wrf-1xmk-1pky\",\n                \"PKSA-6319-ffpf-gx66\",\n                \"PKSA-n7sg-8f52-pqtf\",\n                \"PKSA-rkkf-636k-qjb3\",\n                \"PKSA-wws7-mr54-jsny\",\n                \"PKSA-4k7v-pfvw-nqvp\"\n            ]\n        }\n    },\n    \"autoload\": {\n        \"psr-4\": {\n            \"Grav\\\\\": \"system/src/Grav\",\n            \"Twig\\\\\": \"system/src/Twig\"\n        },\n        \"files\": [\n            \"system/defines.php\",\n            \"system/src/DOMLettersIterator.php\",\n            \"system/src/DOMWordsIterator.php\"\n        ]\n    },\n    \"autoload-dev\": {\n        \"psr-4\": {\n            \"PHPStan\\\\\": \"tests/phpstan/classes\"\n        }\n    },\n    \"archive\": {\n        \"exclude\": [\n            \"VERSION\"\n        ]\n    },\n    \"scripts\": {\n        \"api-17\": \"vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.17.md\",\n        \"post-create-project-cmd\": \"bin/grav install\",\n        \"phpstan\": \"vendor/bin/phpstan analyse -l 2 -c ./tests/phpstan/phpstan.neon --memory-limit=720M system/src\",\n        \"phpstan-framework\": \"vendor/bin/phpstan analyse -l 5 -c ./tests/phpstan/phpstan.neon --memory-limit=480M system/src/Grav/Framework system/src/Grav/Events system/src/Grav/Installer\",\n        \"phpstan-plugins\": \"vendor/bin/phpstan analyse -l 1 -c ./tests/phpstan/plugins.neon --memory-limit=400M user/plugins\",\n        \"test\": \"vendor/bin/codecept run unit\",\n        \"test-windows\": \"vendor\\\\bin\\\\codecept run unit\"\n    },\n    \"extra\": {\n        \"branch-alias\": {\n            \"dev-develop\": \"1.x-dev\"\n        }\n    }\n}\n"
  },
  {
    "path": "images/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "index.php",
    "content": "<?php\n\n/**\n * @package    Grav.Core\n *\n * @copyright  Copyright (c) 2015 - 2024 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav;\n\n\\define('GRAV_REQUEST_TIME', microtime(true));\n\\define('GRAV_PHP_MIN', '7.3.6');\n\nif (PHP_SAPI === 'cli-server') {\n    $symfony_server = stripos(getenv('_'), 'symfony') !== false || stripos($_SERVER['SERVER_SOFTWARE'] ?? '', 'symfony') !== false || stripos($_ENV['SERVER_SOFTWARE'] ?? '', 'symfony') !== false;\n\n    if (!isset($_SERVER['PHP_CLI_ROUTER']) && !$symfony_server) {\n        die(\"PHP webserver requires a router to run Grav, please use: <pre>php -S {$_SERVER['SERVER_NAME']}:{$_SERVER['SERVER_PORT']} system/router.php</pre>\");\n    }\n}\n\n// Maintenance mode during core upgrade\nif (file_exists(__DIR__ . '/.upgrading')) {\n    if (time() - filemtime(__DIR__ . '/.upgrading') > 300) {\n        @unlink(__DIR__ . '/.upgrading'); // Stale flag (>5 min), remove it\n    } else {\n        http_response_code(503);\n        header('Retry-After: 60');\n        echo '<!DOCTYPE html><html><head><title>Upgrading</title></head>';\n        echo '<body><h1>Site Upgrading</h1><p>Please try again in a moment.</p></body></html>';\n        exit;\n    }\n}\n\n// Ensure vendor libraries exist\n$autoload = __DIR__ . '/vendor/autoload.php';\nif (!is_file($autoload)) {\n    die('Please run: <i>bin/grav install</i>');\n}\n\n// Register the auto-loader.\n$loader = require $autoload;\n\n// Set timezone to default, falls back to system if php.ini not set\ndate_default_timezone_set(@date_default_timezone_get());\n\n// Set internal encoding.\n@ini_set('default_charset', 'UTF-8');\nmb_internal_encoding('UTF-8');\n\nuse Grav\\Common\\Grav;\nuse RocketTheme\\Toolbox\\Event\\Event;\n\n// Get the Grav instance\n$grav = Grav::instance(array('loader' => $loader));\n\n// Process the page\ntry {\n    $grav->process();\n} catch (\\Error|\\Exception $e) {\n    $grav->fireEvent('onFatalException', new Event(array('exception' => $e)));\n    throw $e;\n}\n"
  },
  {
    "path": "logs/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "now.json",
    "content": "{\n  \"version\": 2,\n  \"builds\": [{ \"src\": \"*.php\", \"use\": \"@now/php\" }]\n}\n"
  },
  {
    "path": "robots.txt",
    "content": "User-agent: *\nDisallow: /.github/\nDisallow: /.phan/\nDisallow: /assets/\nDisallow: /backup/\nDisallow: /bin/\nDisallow: /cache/\nDisallow: /logs/\nDisallow: /system/\nDisallow: /tests/\nDisallow: /tmp/\nDisallow: /user/\nDisallow: /vendor/\nDisallow: /webserver-configs/\nAllow: /user/pages/\nAllow: /user/themes/\nAllow: /user/images/\nAllow: /\nAllow: *.css$\nAllow: *.js$\nAllow: /system/*.js$\n"
  },
  {
    "path": "system/assets/debugger/clockwork.css",
    "content": "/** Clockwork Debugger CSS **/\n.clockwork-badge {\n    position: fixed;\n    z-index: 1000; /* Increased z-index for better visibility */\n    bottom: 0; /* Added some spacing from the bottom */\n    left: 0;   /* Added some spacing from the left */\n    padding: 5px;\n    background-color: #eee;\n    border: 1px solid #ccc;\n    border-bottom: 0;\n    border-left: 0;\n    display: flex;\n    align-items: center;\n    border-radius: 0 4px 0 0; /* Rounded top corners */\n    box-shadow: 0 2px 5px rgba(0,0,0,0.2);\n    font-size: 14px;\n    color: #333;\n    transition: background-color 0.3s ease;\n}\n\n.clockwork-badge:hover {\n    background-color: #ddd;\n}\n\n.clockwork-badge i {\n    display: block;\n    height: 24px;\n    width: 24px;\n    background-size: contain;\n    background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAA/1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHh4AAAD///8EBAT7+/sLCwv29vYVFRUvLy/t7e3m5ubCwsKxsbE/Pz+mpqZMTEwcHBzy8vLp6emfn5+AgIA2Njbi4uLf39+rq6tzc3NWVlYhISHa2trW1tbS0tLMzMy7u7uZmZmUlJSMjIxvb29kZGRHR0c7Ozt5eXkqKiq1tbWQkJBqampbW1tSUlLHx8eHh4ckJCRDQ0M3wD42AAAAI3RSTlMA/PibTbQ0x76TVAlw4LhZLOuEYCAN9Hjx0a2ppGZEGYw97djhXHwAAATZSURBVFjDlVcHW+MwDO1eFCjj2McNOzvdpXTTXVbL/P+/5SQ7QSSX5Di1X1onfi/Sk+Q4sTDbKqWK+YuznZ2zi3wxVdqK/Zf92M1nT9gnO8rmd398GX6Z3xaoOFoiAQcx3E5efgmeSuN8F6Xg1x3G06l/wjNpMR1B0uif4EhnIuFb+0diIoFXk3IVfokisR+h52GO4JKgyjmfaMhAFNlSaPR7DpwI+lzn/E4QKIqmKIJirxCMP4izBPPZPXhgXwMBYgULw0nfg/BF5scDbslb7QeJ08yqqTEmGYoB95d4H8ETL8+n9wBqrLu6ao3bBsMwAnxISf/9BHcqxNB8Y7cWl3Zz7TAUfPrvAT6AoNEFFXvsjutL01yOuMrtBxnFXsmT/1wQHmdWAFNnI3uI48Yj0FUcHbKf62GfUfr8eeQt7Uk3mQZpZNoVRPEui5vtEz5zFEpgWnyqVBZMc6oaGNriH2hGVZ0OxEvInPeMaZWJBA7vmPbCr5jjws5HBnAUxvDMH40aCIf4G5BjRQSs8E8HFFYf8bGxgDvD55bzGhwWkoBcuIyHR/AMdaCagxXDhtL6tSqoWpd4BMnlIR+Or+rYTK/a3EAGcc6e4AWHISnWv20iCCojsHoVlQdjrMexFF2C7UMg2A2WEGWbQhXN6l3eXC6XGp4b9qxbuEB2EBGBwtocrK90cVG5mbRXm6vmx/0phq1sIAGKDgLOBiN1MrO5a9aDl+D0W6x0Ar9BCTRuIIANa90Y7LrLVRXzwVtDInCqMRWcf2bUOEAsa4wJqFowQALL9EiAtVRk8QC4OW+1pOM9jIaVASwYagyNXDj+W0NcfuZNzjtXOiL0Zzg30Llj+ptfxQs4+vBPNiL5PawFCBkgXpUaVtqGl+A8dgZHL34BcBUQrwPptToW+o37Ku+UH9eYByJIx3YkAeFnMFuGO7S5gEp7YhXxa5OOAM39RXDPXb0qmpROsswZe+twXdU55oUIZAiEv3bD1UFwIYKkmGqytPCDCwKFQCKK0yL7qtSAPX54UAbtsLuBHkb9zyLmPQSNjsSgmQwKUOIfEY8F8t4B34DvndJY9BA8tNBJq1Nev9axmaStFcQLhgYoCTo0salkIaW8OUDdWjMTR2sHPhrAFZqx6cqcKE4pl2BJJ4K6hfwvqNgAnXfKX/HU6X3Zrhnu0k7tLNZtTBRv1hkwTDBY1NzFU6doDYjJbWdQkQhWwuU7/LvhTh3SDoco4ECL4i5dwURbc8NdDZz2IwKicE8d0KIqWetLE3+lL4hvUuGSeRfVWNLfj/gpOw4smBJBkKQHCzlHGwvAj4woB1gq5NGGLSXtORBPnUQPV5/MPVkDMxbpwG7w4x0xL6Ltxka0A/4NBvV09UVk4DoSn/jl2+JQS9q9KYawisAD4CfhsZ4TH3htylsdEHARIQBusqCKyUpymycgbbkkXEXjT3z7/oKQFTFVuZD2FMJHZIDsO5x2d4aAr2jR+GLwZhtAb028/0yJ9J8dE87jQyKObcjtTXT8dH+fDuKF4/eiPwzH44wTf/yUi6wrpRIOZ9lM1EtXAifFI+CJn9+iX/t2xMQwOMth/UZbASi8btAwR9FHWSpJr75g9Oqbin3VDg+SpwlP6k6TB4ex/7JvmcJx8jydy6XPk8eFTKhyfwCgX71MSvaBHgAAAABJRU5ErkJggg==);\n}\n\n.clockwork-badge .tooltip {\n    display: none; /* Hidden by default */\n    position: absolute;\n    bottom: 35px; /* Position above the badge */\n    left: 0;\n    width: 450px;\n    padding: 20px;\n    background-color: #fff;\n    border: 1px solid #ccc;\n    border-radius: 4px;\n    font-size: 16px;\n    color: #666;\n    line-height: 1.5;\n    box-shadow: 0 2px 8px rgba(0,0,0,0.2);\n    z-index: 1001; /* Ensure it appears above other elements */\n}\n\n.clockwork-badge:hover .tooltip {\n    display: block; /* Show tooltip on hover */\n}\n\n.clockwork-badge .tooltip a {\n    color: #007BFF;\n    text-decoration: none;\n}\n\n.clockwork-badge .tooltip a:hover {\n    text-decoration: underline;\n}\n"
  },
  {
    "path": "system/assets/debugger/clockwork.js",
    "content": "/** Clockwork Debugger JS **/\ndocument.addEventListener(\"DOMContentLoaded\", function () {\n    // Directly select the script tag by its id\n    var currentScript = document.getElementById('clockwork-script');\n\n    if (!currentScript) {\n        console.error(\"Clockwork Debugger: Script tag with id 'clockwork-script' not found.\");\n        return;\n    }\n\n    var route = currentScript.getAttribute('data-route') || '/clockwork'; // Default route if not specified\n\n    // Debugging: Log the route to verify\n    console.log(\"Clockwork Debugger Route:\", route);\n\n    // Create the badge container\n    var badge = document.createElement(\"div\");\n    badge.className = \"clockwork-badge\";\n    badge.setAttribute('aria-label', 'Clockwork Debugger Enabled');\n    badge.setAttribute('role', 'button');\n\n    // Create the icon element\n    var icon = document.createElement(\"i\");\n    badge.appendChild(icon);\n\n    // Create the tooltip element\n    var tooltip = document.createElement(\"div\");\n    tooltip.className = \"tooltip\";\n    tooltip.innerHTML = `\n        <b>Grav Clockwork Debugger Enabled.</b><br>\n        Install the <b>Clockwork Browser extension</b> (Chrome or Firefox) or use the <b>\"Clockwork Web\"</b> Grav plugin to <a href=\"${route}\" target=\"_blank\">View Debug Info 🔗</a>.\n    `;\n    badge.appendChild(tooltip);\n\n    // Append the badge to the body\n    document.body.appendChild(badge);\n});"
  },
  {
    "path": "system/assets/debugger/phpdebugbar.css",
    "content": "div.phpdebugbar {\n    font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n\n.phpdebugbar pre {\n    padding: 1rem;\n}\n\n.phpdebugbar div.phpdebugbar-header > div > * {\n    padding: 5px 15px;\n}\n\n.phpdebugbar div.phpdebugbar-header > div.phpdebugbar-header-right > * {\n    padding: 5px 8px;\n}\n\n.phpdebugbar a.phpdebugbar-restore-btn {\n    background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAA/1BMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHh4AAAD///8EBAT7+/sLCwv29vYVFRUvLy/t7e3m5ubCwsKxsbE/Pz+mpqZMTEwcHBzy8vLp6emfn5+AgIA2Njbi4uLf39+rq6tzc3NWVlYhISHa2trW1tbS0tLMzMy7u7uZmZmUlJSMjIxvb29kZGRHR0c7Ozt5eXkqKiq1tbWQkJBqampbW1tSUlLHx8eHh4ckJCRDQ0M3wD42AAAAI3RSTlMA/PibTbQ0x76TVAlw4LhZLOuEYCAN9Hjx0a2ppGZEGYw97djhXHwAAATZSURBVFjDlVcHW+MwDO1eFCjj2McNOzvdpXTTXVbL/P+/5SQ7QSSX5Di1X1onfi/Sk+Q4sTDbKqWK+YuznZ2zi3wxVdqK/Zf92M1nT9gnO8rmd398GX6Z3xaoOFoiAQcx3E5efgmeSuN8F6Xg1x3G06l/wjNpMR1B0uif4EhnIuFb+0diIoFXk3IVfokisR+h52GO4JKgyjmfaMhAFNlSaPR7DpwI+lzn/E4QKIqmKIJirxCMP4izBPPZPXhgXwMBYgULw0nfg/BF5scDbslb7QeJ08yqqTEmGYoB95d4H8ETL8+n9wBqrLu6ao3bBsMwAnxISf/9BHcqxNB8Y7cWl3Zz7TAUfPrvAT6AoNEFFXvsjutL01yOuMrtBxnFXsmT/1wQHmdWAFNnI3uI48Yj0FUcHbKf62GfUfr8eeQt7Uk3mQZpZNoVRPEui5vtEz5zFEpgWnyqVBZMc6oaGNriH2hGVZ0OxEvInPeMaZWJBA7vmPbCr5jjws5HBnAUxvDMH40aCIf4G5BjRQSs8E8HFFYf8bGxgDvD55bzGhwWkoBcuIyHR/AMdaCagxXDhtL6tSqoWpd4BMnlIR+Or+rYTK/a3EAGcc6e4AWHISnWv20iCCojsHoVlQdjrMexFF2C7UMg2A2WEGWbQhXN6l3eXC6XGp4b9qxbuEB2EBGBwtocrK90cVG5mbRXm6vmx/0phq1sIAGKDgLOBiN1MrO5a9aDl+D0W6x0Ar9BCTRuIIANa90Y7LrLVRXzwVtDInCqMRWcf2bUOEAsa4wJqFowQALL9EiAtVRk8QC4OW+1pOM9jIaVASwYagyNXDj+W0NcfuZNzjtXOiL0Zzg30Llj+ptfxQs4+vBPNiL5PawFCBkgXpUaVtqGl+A8dgZHL34BcBUQrwPptToW+o37Ku+UH9eYByJIx3YkAeFnMFuGO7S5gEp7YhXxa5OOAM39RXDPXb0qmpROsswZe+twXdU55oUIZAiEv3bD1UFwIYKkmGqytPCDCwKFQCKK0yL7qtSAPX54UAbtsLuBHkb9zyLmPQSNjsSgmQwKUOIfEY8F8t4B34DvndJY9BA8tNBJq1Nev9axmaStFcQLhgYoCTo0salkIaW8OUDdWjMTR2sHPhrAFZqx6cqcKE4pl2BJJ4K6hfwvqNgAnXfKX/HU6X3Zrhnu0k7tLNZtTBRv1hkwTDBY1NzFU6doDYjJbWdQkQhWwuU7/LvhTh3SDoco4ECL4i5dwURbc8NdDZz2IwKicE8d0KIqWetLE3+lL4hvUuGSeRfVWNLfj/gpOw4smBJBkKQHCzlHGwvAj4woB1gq5NGGLSXtORBPnUQPV5/MPVkDMxbpwG7w4x0xL6Ltxka0A/4NBvV09UVk4DoSn/jl2+JQS9q9KYawisAD4CfhsZ4TH3htylsdEHARIQBusqCKyUpymycgbbkkXEXjT3z7/oKQFTFVuZD2FMJHZIDsO5x2d4aAr2jR+GLwZhtAb028/0yJ9J8dE87jQyKObcjtTXT8dH+fDuKF4/eiPwzH44wTf/yUi6wrpRIOZ9lM1EtXAifFI+CJn9+iX/t2xMQwOMth/UZbASi8btAwR9FHWSpJr75g9Oqbin3VDg+SpwlP6k6TB4ex/7JvmcJx8jydy6XPk8eFTKhyfwCgX71MSvaBHgAAAABJRU5ErkJggg==);\n    width: 13px;\n}\n\n.phpdebugbar a.phpdebugbar-tab.phpdebugbar-active {\n    background: #3DB9EC;\n    color: #fff;\n    margin-top: -1px;\n    padding-top: 6px;\n}\n\n.phpdebugbar .phpdebugbar-widgets-toolbar {\n    border-top: 1px solid #ddd;\n    padding-left: 5px;\n    padding-right: 2px;\n    padding-top: 2px;\n    background-color: #fafafa !important;\n    width: auto !important;\n    left: 0;\n    right: 0;\n}\n\n.phpdebugbar .phpdebugbar-widgets-toolbar input {\n    background: transparent !important;\n}\n\n.phpdebugbar .phpdebugbar-widgets-toolbar .phpdebugbar-widgets-filter {\n\n}\n\n\n.phpdebugbar input[type=text] {\n    padding: 0;\n    display: inline;\n}\n\n.phpdebugbar dl.phpdebugbar-widgets-varlist, ul.phpdebugbar-widgets-timeline li span.phpdebugbar-widgets-label {\n    font-family: \"DejaVu Sans Mono\", Menlo, Monaco, Consolas, Courier, monospace;\n    font-size: 12px;\n}\n\nul.phpdebugbar-widgets-timeline li span.phpdebugbar-widgets-label {\n    text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff;\n    top: 0;\n}\n\n.phpdebugbar pre, .phpdebugbar code {\n    margin: 0;\n    font-size: 14px;\n}\n"
  },
  {
    "path": "system/assets/whoops.css",
    "content": "body header {\n    background: #3085EE;\n}\n\nbody .left-panel {\n    background: inherit;\n}\n\nbody .exc-title-primary {\n    color: #fff;\n}\n\nbody .exc-title {\n    color: #ddd;\n}\n\nbody .frame:not(.active):hover {\n    background: #e6e6e6;\n}\n"
  },
  {
    "path": "system/blueprints/config/backups.yaml",
    "content": "title: PLUGIN_ADMIN.BACKUPS\n\nform:\n    validation: loose\n\n    fields:\n        history_title:\n            type: section\n            title: PLUGIN_ADMIN.BACKUPS_HISTORY\n            underline: true\n\n        history:\n            type: backupshistory\n\n        config_title:\n            type: section\n            title: PLUGIN_ADMIN.BACKUPS_PURGE_CONFIG\n            underline: true\n\n        purge.trigger:\n            type: select\n            label: PLUGIN_ADMIN.BACKUPS_STORAGE_PURGE_TRIGGER\n            size: medium\n            default: space\n            options:\n                space: Maximum Backup Space\n                number: Maximum Number of Backups\n                time: maximum Retention Time\n            validate:\n                required: true\n\n        purge.max_backups_count:\n            type: number\n            label: PLUGIN_ADMIN.BACKUPS_MAX_COUNT\n            default: 25\n            size: x-small\n            help: PLUGIN_ADMIN.BACKUPS_MAX_COUNT\n            validate:\n                min: 0\n                type: number\n                required: true\n                message: Must be a number 0 or greater\n\n        purge.max_backups_space:\n            type: number\n            label: PLUGIN_ADMIN.BACKUPS_MAX_SPACE\n            append: in GB\n            size: x-small\n            default: 5\n            validate:\n                min: 1\n                type: number\n                required: true\n                message: Space must be 1GB or greater\n\n        purge.max_backups_time:\n            type: number\n            label: PLUGIN_ADMIN.BACKUPS_MAX_RETENTION_TIME\n            append: PLUGIN_ADMIN.BACKUPS_MAX_RETENTION_TIME_APPEND\n            size: x-small\n            default: 365\n            validate:\n                min: 7\n                type: number\n                required: true\n                message: Rentenion days must be 7 or greater\n\n        profiles_title:\n          type: section\n          title: PLUGIN_ADMIN.BACKUPS_PROFILES\n          underline: true\n\n        profiles:\n          type: list\n          style: vertical\n          label:\n          classes: backups-list compact\n          sort: false\n\n          fields:\n            .name:\n              type: text\n              label: PLUGIN_ADMIN.NAME\n              placeholder: PLUGIN_ADMIN.BACKUPS_PROFILE_NAME\n              validate:\n                  max: 20\n                  message: 'Name must be less than 20 characters'\n                  required: true\n            .root:\n              type: text\n              label: PLUGIN_ADMIN.BACKUPS_PROFILE_ROOT_FOLDER\n              help: PLUGIN_ADMIN.BACKUPS_PROFILE_ROOT_FOLDER_HELP\n              placeholder: '/'\n              default: '/'\n              validate:\n                  required: true\n            .exclude_paths:\n              type: textarea\n              label: PLUGIN_ADMIN.BACKUPS_PROFILE_EXCLUDE_PATHS\n              rows: 5\n              placeholder: \"/backup\\r/cache\\r/images\\r/logs\\r/tmp\"\n              help: PLUGIN_ADMIN.BACKUPS_PROFILE_EXCLUDE_PATHS_HELP\n            .exclude_files:\n                type: textarea\n                label: PLUGIN_ADMIN.BACKUPS_PROFILE_EXCLUDE_FILES\n                rows: 5\n                placeholder: \".DS_Store\\r.git\\r.svn\\r.hg\\r.idea\\r.vscode\\rnode_modules\"\n                help: PLUGIN_ADMIN.BACKUPS_PROFILE_EXCLUDE_FILES_HELP\n            .schedule:\n                type: toggle\n                label: PLUGIN_ADMIN.BACKUPS_PROFILE_SCHEDULE\n                highlight: 1\n                default: 1\n                options:\n                    1: PLUGIN_ADMIN.YES\n                    0: PLUGIN_ADMIN.NO\n                validate:\n                    type: bool\n            .schedule_at:\n                type: cron\n                label: PLUGIN_ADMIN.BACKUPS_PROFILE_SCHEDULE_AT\n                default: '* 3 * * *'\n                validate:\n                    required: true\n\n"
  },
  {
    "path": "system/blueprints/config/media.yaml",
    "content": "title: PLUGIN_ADMIN.MEDIA\n\nform:\n  validation: loose\n  fields:\n"
  },
  {
    "path": "system/blueprints/config/scheduler.yaml",
    "content": "title: PLUGIN_ADMIN.SCHEDULER\n\nform:\n    validation: loose\n\n    fields:\n        scheduler_tabs:\n            type: tabs\n            active: 1\n\n            fields:\n                status_tab:\n                    type: tab\n                    title: PLUGIN_ADMIN.SCHEDULER_STATUS\n\n                    fields:\n                        status_title:\n                            type: section\n                            title: PLUGIN_ADMIN.SCHEDULER_STATUS\n                            underline: true\n\n                        status:\n                            type: cronstatus\n                            validate:\n                                type: commalist\n                                \n                        modern_health:\n                            type: display\n                            label: Health Status\n                            content: |\n                                <div id=\"scheduler-health-status\">\n                                    <div class=\"text-muted\">Loading...</div>\n                                </div>\n                                <script>\n                                (function() {\n                                    function renderHealthStatus() {\n                                        var data = window.schedulerHealthData;\n                                        var statusEl = document.getElementById('scheduler-health-status');\n                                        if (!statusEl || !data) return;\n\n                                        var statusColor = '#6c757d';\n                                        var statusLabel = data.status || 'unknown';\n                                        if (data.status === 'healthy') statusColor = '#28a745';\n                                        else if (data.status === 'warning') statusColor = '#ffc107';\n                                        else if (data.status === 'critical') statusColor = '#dc3545';\n\n                                        var html = '<div style=\"display: flex; flex-direction: column; gap: 1rem;\">';\n\n                                        // Status card\n                                        html += '<div style=\"display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border-radius: 6px; border: 1px solid #e9ecef; box-shadow: 0 1px 3px rgba(0,0,0,0.05);\">';\n                                        html += '<span style=\"font-weight: 500; color: #495057;\">Status:</span>';\n                                        html += '<span style=\"background: ' + statusColor + '; color: white; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.025em;\">' + statusLabel + '</span>';\n                                        html += '</div>';\n\n                                        // Info grid\n                                        html += '<div style=\"display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;\">';\n\n                                        // Last run card\n                                        html += '<div style=\"background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);\">';\n                                        html += '<div style=\"color: #6c757d; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;\">Last Run</div>';\n                                        if (data.last_run) {\n                                            var age = data.last_run_age;\n                                            var ageText = 'just now';\n                                            if (age > 86400) {\n                                                ageText = Math.floor(age / 86400) + ' day(s) ago';\n                                            } else if (age > 3600) {\n                                                ageText = Math.floor(age / 3600) + ' hour(s) ago';\n                                            } else if (age > 60) {\n                                                ageText = Math.floor(age / 60) + ' minute(s) ago';\n                                            } else if (age > 0) {\n                                                ageText = age + ' second(s) ago';\n                                            }\n                                            html += '<div style=\"font-size: 1rem; color: #212529; font-weight: 500;\">' + ageText + '</div>';\n                                        } else {\n                                            html += '<div style=\"font-size: 1rem; color: #6c757d;\">Never</div>';\n                                        }\n                                        html += '</div>';\n\n                                        // Jobs count card\n                                        html += '<div style=\"background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);\">';\n                                        html += '<div style=\"color: #6c757d; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;\">Scheduled Jobs</div>';\n                                        html += '<div style=\"font-size: 1rem; color: #212529; font-weight: 500;\">' + (data.scheduled_jobs || 0) + '</div>';\n                                        html += '</div>';\n\n                                        html += '</div>'; // Close grid\n\n                                        // Queue size\n                                        if (data.queue_size !== undefined) {\n                                            html += '<div style=\"background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);\">';\n                                            html += '<span style=\"color: #6c757d; font-size: 0.875rem;\">Queue Size: </span>';\n                                            html += '<span style=\"font-weight: 500;\">' + data.queue_size + '</span>';\n                                            html += '</div>';\n                                        }\n\n                                        // Failed jobs warning\n                                        if (data.failed_jobs_24h > 0) {\n                                            html += '<div style=\"background: #fff5f5; border: 1px solid #feb2b2; border-radius: 6px; padding: 0.75rem; color: #c53030;\">';\n                                            html += '<strong>Failed Jobs (24h):</strong> ' + data.failed_jobs_24h;\n                                            html += '</div>';\n                                        }\n\n                                        html += '</div>';\n                                        statusEl.innerHTML = html;\n                                    }\n\n                                    if (document.readyState === 'loading') {\n                                        document.addEventListener('DOMContentLoaded', renderHealthStatus);\n                                    } else {\n                                        renderHealthStatus();\n                                    }\n                                })();\n                                </script>\n                            markdown: false\n\n                        trigger_methods:\n                            type: display\n                            label: Active Triggers\n                            content: |\n                                <div id=\"scheduler-triggers\">\n                                    <div class=\"text-muted\">Loading...</div>\n                                </div>\n                                <script>\n                                (function() {\n                                    function goToWebhookConfig() {\n                                        // Find the \"Advanced Features\" tab and click it\n                                        var tabs = document.querySelectorAll('.tabs-nav a');\n                                        for (var i = 0; i < tabs.length; i++) {\n                                            if (tabs[i].textContent.trim() === 'Advanced Features') {\n                                                tabs[i].click();\n                                                // Scroll to \"Webhook Configuration\" section heading after tab switch\n                                                setTimeout(function() {\n                                                    var headings = document.querySelectorAll('h1');\n                                                    for (var j = 0; j < headings.length; j++) {\n                                                        if (headings[j].textContent.trim() === 'Webhook Configuration') {\n                                                            headings[j].scrollIntoView({ behavior: 'smooth', block: 'start' });\n                                                            return;\n                                                        }\n                                                    }\n                                                }, 150);\n                                                return;\n                                            }\n                                        }\n                                    }\n                                    // Expose globally for onclick handlers\n                                    window.goToWebhookConfig = goToWebhookConfig;\n\n                                    function renderTriggers() {\n                                        var data = window.schedulerHealthData;\n                                        var triggersEl = document.getElementById('scheduler-triggers');\n                                        if (!triggersEl || !data) return;\n\n                                        // Check cron status from the main status field\n                                        var cronReady = false;\n                                        var statusDiv = document.querySelector('.cronstatus-status');\n                                        if (statusDiv) {\n                                            var statusText = statusDiv.textContent || statusDiv.innerText;\n                                            cronReady = statusText.includes('Ready');\n                                        }\n\n                                        var html = '<div style=\"display: flex; flex-direction: column; gap: 0.5rem;\">';\n\n                                        // Cron trigger card\n                                        var cronIcon = cronReady ? '<i class=\"fa fa-check-circle\" style=\"color: #28a745;\"></i>' : '<i class=\"fa fa-times-circle\" style=\"color: #6c757d;\"></i>';\n                                        var cronStatus = cronReady ? 'Active' : 'Not Configured';\n                                        var cronStatusColor = cronReady ? '#28a745' : '#6c757d';\n                                        var cardBg = cronReady ? '#f8f9fa' : '#fff';\n\n                                        html += '<div style=\"display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: ' + cardBg + '; border: 1px solid #e9ecef; border-radius: 4px;\">';\n                                        html += '<div style=\"display: flex; align-items: center; gap: 0.75rem;\">';\n                                        html += '<span style=\"font-size: 1.25rem; line-height: 1;\">' + cronIcon + '</span>';\n                                        html += '<span style=\"font-weight: 500; color: #212529; font-size: 1rem;\">Cron:</span>';\n                                        html += '</div>';\n                                        html += '<span style=\"background: ' + cronStatusColor + '; color: white; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;\">' + cronStatus + '</span>';\n                                        html += '</div>';\n\n                                        // Webhook trigger card\n                                        if (data.webhook_enabled) {\n                                            html += '<div style=\"display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px;\">';\n                                            html += '<div style=\"display: flex; align-items: center; gap: 0.75rem;\">';\n                                            html += '<span style=\"font-size: 1.25rem; line-height: 1;\"><i class=\"fa fa-check-circle\" style=\"color: #28a745;\"></i></span>';\n                                            html += '<span style=\"font-weight: 500; color: #212529; font-size: 1rem;\">Webhook:</span>';\n                                            html += '</div>';\n                                            html += '<span style=\"background: #28a745; color: white; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;\">ACTIVE</span>';\n                                            html += '</div>';\n                                        } else {\n                                            html += '<div onclick=\"goToWebhookConfig()\" style=\"display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: #fff; border: 1px solid #e9ecef; border-radius: 4px; cursor: pointer;\" title=\"Click to configure webhooks\">';\n                                            html += '<div style=\"display: flex; align-items: center; gap: 0.75rem;\">';\n                                            html += '<span style=\"font-size: 1.25rem; line-height: 1;\"><i class=\"fa fa-minus-circle\" style=\"color: #ffc107;\"></i></span>';\n                                            html += '<span style=\"font-weight: 500; color: #212529; font-size: 1rem;\">Webhook:</span>';\n                                            html += '</div>';\n                                            html += '<span style=\"background: #ffc107; color: #212529; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;\">DISABLED</span>';\n                                            html += '</div>';\n                                        }\n\n                                        html += '</div>';\n\n                                        if (!cronReady && !data.webhook_enabled) {\n                                            html += '<div class=\"alert alert-warning\" style=\"margin-top: 1rem; cursor: pointer;\" onclick=\"goToWebhookConfig()\">';\n                                            html += '<i class=\"fa fa-exclamation-triangle\"></i> No triggers active! ';\n                                            html += '<a onclick=\"goToWebhookConfig(); event.stopPropagation();\" style=\"cursor: pointer; text-decoration: underline;\">Enable webhooks</a>';\n                                            html += ' or configure cron.</div>';\n                                        }\n\n                                        triggersEl.innerHTML = html;\n                                    }\n\n                                    if (document.readyState === 'loading') {\n                                        document.addEventListener('DOMContentLoaded', renderTriggers);\n                                    } else {\n                                        renderTriggers();\n                                    }\n                                })();\n                                </script>\n                            markdown: false\n\n                jobs_tab:\n                    type: tab\n                    title: PLUGIN_ADMIN.SCHEDULER_JOBS\n\n                    fields:\n                        jobs_title:\n                            type: section\n                            title: PLUGIN_ADMIN.SCHEDULER_JOBS\n                            underline: true\n\n                        custom_jobs:\n                            type: list\n                            style: vertical\n                            label:\n                            classes: cron-job-list compact\n                            key: id\n                            fields:\n                                .id:\n                                    type: key\n                                    label: ID\n                                    placeholder: 'process-name'\n                                    validate:\n                                        required: true\n                                        pattern: '[a-zа-я0-9_\\-]+'\n                                        max: 20\n                                        message: 'ID must be lowercase with dashes/underscores only and less than 20 characters'\n                                .command:\n                                    type: text\n                                    label: PLUGIN_ADMIN.COMMAND\n                                    placeholder: 'ls'\n                                    validate:\n                                        required: true\n                                .args:\n                                    type: text\n                                    label: PLUGIN_ADMIN.EXTRA_ARGUMENTS\n                                    placeholder: '-lah'\n                                .at:\n                                    type: text\n                                    wrapper_classes: cron-selector\n                                    label: PLUGIN_ADMIN.SCHEDULER_RUNAT\n                                    help: PLUGIN_ADMIN.SCHEDULER_RUNAT_HELP\n                                    placeholder: '* * * * *'\n                                    validate:\n                                        required: true\n                                .output:\n                                    type: text\n                                    label: PLUGIN_ADMIN.SCHEDULER_OUTPUT\n                                    help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_HELP\n                                    placeholder: 'logs/ls-cron.out'\n                                .output_mode:\n                                    type: select\n                                    label: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE\n                                    help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE_HELP\n                                    default: append\n                                    options:\n                                        append: Append\n                                        overwrite: Overwrite\n                                .email:\n                                    type: text\n                                    label: PLUGIN_ADMIN.SCHEDULER_EMAIL\n                                    help: PLUGIN_ADMIN.SCHEDULER_EMAIL_HELP\n                                    placeholder: 'notifications@yoursite.com'\n\n                modern_tab:\n                    type: tab\n                    title: Advanced Features\n\n                    fields:\n                        workers_section:\n                            type: section\n                            title: Worker Configuration\n                            underline: true\n\n                            fields:\n                                modern.workers:\n                                    type: number\n                                    label: Concurrent Workers\n                                    help: Number of jobs that can run simultaneously (1 = sequential)\n                                    default: 4\n                                    size: x-small\n                                    append: workers\n                                    validate:\n                                        type: int\n                                        min: 1\n                                        max: 10\n\n                        retry_section:\n                            type: section\n                            title: Retry Configuration\n                            underline: true\n\n                            fields:\n                                modern.retry.enabled:\n                                    type: toggle\n                                    label: Enable Job Retry\n                                    help: Automatically retry failed jobs\n                                    highlight: 1\n                                    default: 1\n                                    options:\n                                        1: PLUGIN_ADMIN.ENABLED\n                                        0: PLUGIN_ADMIN.DISABLED\n                                    validate:\n                                        type: bool\n\n                                modern.retry.max_attempts:\n                                    type: number\n                                    label: Maximum Retry Attempts\n                                    help: Maximum number of times to retry a failed job\n                                    default: 3\n                                    size: x-small\n                                    append: retries\n                                    validate:\n                                        type: int\n                                        min: 1\n                                        max: 10\n\n                                modern.retry.backoff:\n                                    type: select\n                                    label: Retry Backoff Strategy\n                                    help: How to calculate delay between retries\n                                    default: exponential\n                                    options:\n                                        linear: Linear (fixed delay)\n                                        exponential: Exponential (increasing delay)\n\n                        queue_section:\n                            type: section\n                            title: Queue Configuration\n                            underline: true\n\n                            fields:\n                                modern.queue.path:\n                                    type: text\n                                    label: Queue Storage Path\n                                    help: Where to store queued jobs\n                                    default: 'user-data://scheduler/queue'\n                                    placeholder: 'user-data://scheduler/queue'\n\n                                modern.queue.max_size:\n                                    type: number\n                                    label: Maximum Queue Size\n                                    help: Maximum number of jobs that can be queued\n                                    default: 1000\n                                    size: x-small\n                                    append: jobs\n                                    validate:\n                                        type: int\n                                        min: 100\n                                        max: 10000\n\n                        history_section:\n                            type: section\n                            title: Job History\n                            underline: true\n\n                            fields:\n                                modern.history.enabled:\n                                    type: toggle\n                                    label: Enable Job History\n                                    help: Track execution history for all jobs\n                                    highlight: 1\n                                    default: 1\n                                    options:\n                                        1: PLUGIN_ADMIN.ENABLED\n                                        0: PLUGIN_ADMIN.DISABLED\n                                    validate:\n                                        type: bool\n\n                                modern.history.retention_days:\n                                    type: number\n                                    label: History Retention (days)\n                                    help: How long to keep job history\n                                    default: 30\n                                    size: x-small\n                                    append: days\n                                    validate:\n                                        type: int\n                                        min: 1\n                                        max: 365\n\n                        webhook_section:\n                            type: section\n                            title: Webhook Configuration\n                            underline: true\n\n                            fields:\n                                webhook_plugin_status:\n                                    type: webhook-status\n                                    label:\n                                modern.webhook.enabled:\n                                    type: toggle\n                                    label: Enable Webhook Triggers\n                                    help: Allow triggering scheduler via HTTP webhook\n                                    highlight: 0\n                                    default: 0\n                                    options:\n                                        1: PLUGIN_ADMIN.ENABLED\n                                        0: PLUGIN_ADMIN.DISABLED\n                                    validate:\n                                        type: bool\n\n                                modern.webhook.token:\n                                    type: text\n                                    label: Webhook Security Token\n                                    help: Secret token for authenticating webhook requests. Keep this secret!\n                                    placeholder: 'Click Generate to create a secure token'\n                                    autocomplete: 'off'\n                                            \n                                webhook_token_generate:\n                                    type: display\n                                    label:\n                                    content: |\n                                                <div style=\"margin-top: -10px; margin-bottom: 15px;\">\n                                                    <button type=\"button\" class=\"button button-primary\" onclick=\"generateWebhookToken()\">\n                                                        <i class=\"fa fa-refresh\"></i> Generate Token\n                                                    </button>\n                                                </div>\n                                                <script>\n                                                function generateWebhookToken() {\n                                                    try {\n                                                        // Generate token\n                                                        const array = new Uint8Array(32);\n                                                        crypto.getRandomValues(array);\n                                                        const token = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');\n                                                        \n                                                        // Try multiple selectors to find the field\n                                                        let field = document.querySelector('[name=\"data[scheduler][modern][webhook][token]\"]');\n                                                        if (!field) {\n                                                            field = document.querySelector('input[name*=\"webhook][token\"]');\n                                                        }\n                                                        if (!field) {\n                                                            field = document.getElementById('scheduler-modern-webhook-token');\n                                                        }\n                                                        if (!field) {\n                                                            // Look for any text input in the webhook section\n                                                            const webhookSection = document.querySelector('.webhook_section');\n                                                            if (webhookSection) {\n                                                                const inputs = webhookSection.querySelectorAll('input[type=\"text\"]');\n                                                                // Find the token field by checking for the placeholder\n                                                                for (let input of inputs) {\n                                                                    if (input.placeholder && input.placeholder.includes('Generate')) {\n                                                                        field = input;\n                                                                        break;\n                                                                    }\n                                                                }\n                                                            }\n                                                        }\n                                                        \n                                                        if (field) {\n                                                            field.value = token;\n                                                            field.dispatchEvent(new Event('change', { bubbles: true }));\n                                                            field.dispatchEvent(new Event('input', { bubbles: true }));\n                                                            // Flash the field to show it was updated\n                                                            field.style.backgroundColor = '#d4edda';\n                                                            setTimeout(function() {\n                                                                field.style.backgroundColor = '';\n                                                            }, 500);\n                                                            // Also try to trigger Grav's form change detection\n                                                            if (window.jQuery) {\n                                                                jQuery(field).trigger('change');\n                                                            }\n                                                        } else {\n                                                            // Log more debugging info\n                                                            console.error('Token field not found. Looking for input fields...');\n                                                            console.log('All inputs:', document.querySelectorAll('input[type=\"text\"]'));\n                                                            alert('Could not find the token field. Please ensure you are in the Advanced Features tab and the Webhook Configuration section is visible.');\n                                                        }\n                                                    } catch (e) {\n                                                        console.error('Error generating token:', e);\n                                                        alert('Error generating token: ' + e.message);\n                                                    }\n                                                }\n                                                </script>\n                                    markdown: false\n\n                                modern.webhook.path:\n                                    type: text\n                                    label: Webhook Path\n                                    help: URL path for webhook endpoint\n                                    default: '/scheduler/webhook'\n                                    placeholder: '/scheduler/webhook'\n\n                        health_section:\n                            type: section\n                            title: Health Check Configuration\n                            underline: true\n\n                            fields:\n                                modern.health.enabled:\n                                    type: toggle\n                                    label: Enable Health Check\n                                    help: Provide health status endpoint for monitoring\n                                    highlight: 1\n                                    default: 1\n                                    options:\n                                        1: PLUGIN_ADMIN.ENABLED\n                                        0: PLUGIN_ADMIN.DISABLED\n                                    validate:\n                                        type: bool\n\n                                modern.health.path:\n                                    type: text\n                                    label: Health Check Path\n                                    help: URL path for health check endpoint\n                                    default: '/scheduler/health'\n                                    placeholder: '/scheduler/health'\n                                            \n                        webhook_usage:\n                            type: section\n                            title: Usage Examples\n                            underline: true\n                            \n                            fields:\n                                webhook_examples:\n                                    type: display\n                                    label:\n                                    content: |\n                                                <script src=\"{{ url('plugin://admin/themes/grav/js/clipboard-helper.js') }}\"></script>\n                                                <div class=\"webhook-examples\">\n                                                    <script>\n                                                    // Initialize webhook commands when page loads\n                                                    document.addEventListener('DOMContentLoaded', function() {\n                                                        if (typeof GravClipboard !== 'undefined') {\n                                                            GravClipboard.initWebhookCommands();\n                                                        }\n                                                    });\n                                                    </script>\n                                                    \n                                                    <div class=\"alert alert-info\">\n                                                        <h4>How to use webhooks:</h4>\n                                                        \n                                                        <div style=\"margin-bottom: 1rem;\">\n                                                            <label style=\"display: block; margin-bottom: 0.25rem; font-weight: 500;\">Trigger all due jobs (respects schedule):</label>\n                                                            <div class=\"form-input-wrapper form-input-addon-wrapper\">\n                                                                <textarea id=\"webhook-all-cmd\" readonly rows=\"2\" style=\"font-family: monospace; background: #f5f5f5; resize: none;\">Loading...</textarea>\n                                                                <div class=\"form-input-addon form-input-append\" style=\"cursor: pointer;\" onclick=\"GravClipboard.copy(this)\"><i class=\"fa fa-copy\"></i> Copy</div>\n                                                            </div>\n                                                        </div>\n                                                        \n                                                        <div style=\"margin-bottom: 1rem;\">\n                                                            <label style=\"display: block; margin-bottom: 0.25rem; font-weight: 500;\">Force-run specific job (ignores schedule):</label>\n                                                            <div class=\"form-input-wrapper form-input-addon-wrapper\">\n                                                                <textarea id=\"webhook-job-cmd\" readonly rows=\"2\" style=\"font-family: monospace; background: #f5f5f5; resize: none;\">Loading...</textarea>\n                                                                <div class=\"form-input-addon form-input-append\" style=\"cursor: pointer;\" onclick=\"GravClipboard.copy(this)\"><i class=\"fa fa-copy\"></i> Copy</div>\n                                                            </div>\n                                                        </div>\n                                                        \n                                                        <div style=\"margin-bottom: 1rem;\">\n                                                            <label style=\"display: block; margin-bottom: 0.25rem; font-weight: 500;\">Check health status:</label>\n                                                            <div class=\"form-input-wrapper form-input-addon-wrapper\">\n                                                                <input type=\"text\" id=\"webhook-health-cmd\" readonly value=\"Loading...\" style=\"font-family: monospace; background: #f5f5f5;\">\n                                                                <div class=\"form-input-addon form-input-append\" style=\"cursor: pointer;\" onclick=\"GravClipboard.copy(this)\"><i class=\"fa fa-copy\"></i> Copy</div>\n                                                            </div>\n                                                        </div>\n                                                        \n                                                        <div style=\"margin-top: 1rem;\">\n                                                            <p><strong>GitHub Actions example:</strong></p>\n                                                            <pre>- name: Trigger Scheduler\n                                                  run: |\n                                                    curl -X POST ${{ secrets.SITE_URL }}/scheduler/webhook \\\n                                                      -H \"Authorization: Bearer ${{ secrets.WEBHOOK_TOKEN }}\"</pre>\n                                                        </div>\n                                                    </div>\n                                                </div>\n                                    markdown: false\n\n\n\n\n"
  },
  {
    "path": "system/blueprints/config/security.yaml",
    "content": "title: PLUGIN_ADMIN.SECURITY\n\nform:\n    validation: loose\n    fields:\n\n        xss_section:\n            type: section\n            title: PLUGIN_ADMIN.XSS_SECURITY\n            underline: true\n\n        xss_whitelist:\n            type: selectize\n            size: large\n            label: PLUGIN_ADMIN.XSS_WHITELIST_PERMISSIONS\n            help: PLUGIN_ADMIN.XSS_WHITELIST_PERMISSIONS_HELP\n            placeholder: 'admin.super'\n            classes: fancy\n            validate:\n                type: commalist\n\n        xss_enabled.on_events:\n            type: toggle\n            label: PLUGIN_ADMIN.XSS_ON_EVENTS\n            highlight: 1\n            options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n            default: true\n            validate:\n                type: bool\n\n        xss_enabled.invalid_protocols:\n            type: toggle\n            label: PLUGIN_ADMIN.XSS_INVALID_PROTOCOLS\n            highlight: 1\n            options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n            default: true\n            validate:\n                type: bool\n\n        xss_invalid_protocols:\n            type: selectize\n            size: large\n            label: PLUGIN_ADMIN.XSS_INVALID_PROTOCOLS_LIST\n            classes: fancy\n            validate:\n                type: commalist\n\n        xss_enabled.moz_binding:\n            type: toggle\n            label: PLUGIN_ADMIN.XSS_MOZ_BINDINGS\n            highlight: 1\n            options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n            default: true\n            validate:\n                type: bool\n\n        xss_enabled.html_inline_styles:\n            type: toggle\n            label: PLUGIN_ADMIN.XSS_HTML_INLINE_STYLES\n            highlight: 1\n            options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n            default: true\n            validate:\n                type: bool\n\n        xss_enabled.dangerous_tags:\n            type: toggle\n            label: PLUGIN_ADMIN.XSS_DANGEROUS_TAGS\n            highlight: 1\n            options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n            default: true\n            validate:\n                type: bool\n\n        xss_dangerous_tags:\n            type: selectize\n            size: large\n            label: PLUGIN_ADMIN.XSS_DANGEROUS_TAGS_LIST\n            classes: fancy\n            validate:\n                type: commalist\n\n        uploads_section:\n            type: section\n            title: PLUGIN_ADMIN.UPLOADS_SECURITY\n            underline: true\n\n\n        uploads_dangerous_extensions:\n            type: selectize\n            size: large\n            label: PLUGIN_ADMIN.UPLOADS_DANGEROUS_EXTENSIONS\n            help: PLUGIN_ADMIN.UPLOADS_DANGEROUS_EXTENSIONS_HELP\n            classes: fancy\n            validate:\n                type: commalist\n\n\n        sanitize_svg:\n            type: toggle\n            label: PLUGIN_ADMIN.SANITIZE_SVG\n            help: PLUGIN_ADMIN.SANITIZE_SVG_HELP\n            highlight: 1\n            options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n            default: true\n            validate:\n                type: bool\n"
  },
  {
    "path": "system/blueprints/config/site.yaml",
    "content": "title: PLUGIN_ADMIN.SITE\nform:\n    validation: loose\n    fields:\n\n        content:\n            type: section\n            title: PLUGIN_ADMIN.DEFAULTS\n            underline: true\n\n            fields:\n                title:\n                    type: text\n                    label: PLUGIN_ADMIN.SITE_TITLE\n                    size: large\n                    placeholder: PLUGIN_ADMIN.SITE_TITLE_PLACEHOLDER\n                    help: PLUGIN_ADMIN.SITE_TITLE_HELP\n\n                default_lang:\n                    type: text\n                    label: PLUGIN_ADMIN.SITE_DEFAULT_LANG\n                    size: x-small\n                    placeholder: PLUGIN_ADMIN.SITE_DEFAULT_LANG_PLACEHOLDER\n                    help: PLUGIN_ADMIN.SITE_DEFAULT_LANG_HELP\n\n                author.name:\n                    type: text\n                    size: large\n                    label: PLUGIN_ADMIN.DEFAULT_AUTHOR\n                    help: PLUGIN_ADMIN.DEFAULT_AUTHOR_HELP\n\n                author.email:\n                    type: text\n                    size: large\n                    label: PLUGIN_ADMIN.DEFAULT_EMAIL\n                    help: PLUGIN_ADMIN.DEFAULT_EMAIL_HELP\n                    validate:\n                        type: email\n\n                taxonomies:\n                    type: selectize\n                    size: large\n                    label: PLUGIN_ADMIN.TAXONOMY_TYPES\n                    classes: fancy\n                    help: PLUGIN_ADMIN.TAXONOMY_TYPES_HELP\n                    validate:\n                        type: commalist\n\n        summary:\n            type: section\n            title: PLUGIN_ADMIN.PAGE_SUMMARY\n            underline: true\n\n            fields:\n                summary.enabled:\n                    type: toggle\n                    label: PLUGIN_ADMIN.ENABLED\n                    highlight: 1\n                    help: PLUGIN_ADMIN.ENABLED_HELP\n                    options:\n                        1: PLUGIN_ADMIN.YES\n                        0: PLUGIN_ADMIN.NO\n                    validate:\n                        type: bool\n\n                summary.size:\n                    type: text\n                    size: small\n                    append: PLUGIN_ADMIN.CHARACTERS\n                    label: PLUGIN_ADMIN.SUMMARY_SIZE\n                    help: PLUGIN_ADMIN.SUMMARY_SIZE_HELP\n                    validate:\n                        type: int\n                        min: 0\n                        max: 65536\n\n                summary.format:\n                    type: toggle\n                    label: PLUGIN_ADMIN.FORMAT\n                    classes: fancy\n                    help: PLUGIN_ADMIN.FORMAT_HELP\n                    highlight: short\n                    options:\n                        'short': PLUGIN_ADMIN.SHORT\n                        'long': PLUGIN_ADMIN.LONG\n\n                summary.delimiter:\n                    type: text\n                    size: x-small\n                    label: PLUGIN_ADMIN.DELIMITER\n                    help: PLUGIN_ADMIN.DELIMITER_HELP\n\n        metadata:\n            type: section\n            title: PLUGIN_ADMIN.METADATA\n            underline: true\n\n            fields:\n                metadata:\n                   type: array\n                   label: PLUGIN_ADMIN.METADATA\n                   help: PLUGIN_ADMIN.METADATA_HELP\n                   placeholder_key: PLUGIN_ADMIN.METADATA_KEY\n                   placeholder_value: PLUGIN_ADMIN.METADATA_VALUE\n\n        routes:\n            type: section\n            title: PLUGIN_ADMIN.REDIRECTS_AND_ROUTES\n            underline: true\n\n            fields:\n                redirects:\n                    type: array\n                    label: PLUGIN_ADMIN.CUSTOM_REDIRECTS\n                    help: PLUGIN_ADMIN.CUSTOM_REDIRECTS_HELP\n                    placeholder_key: PLUGIN_ADMIN.CUSTOM_REDIRECTS_PLACEHOLDER_KEY\n                    placeholder_value: PLUGIN_ADMIN.CUSTOM_REDIRECTS_PLACEHOLDER_VALUE\n\n                routes:\n                    type: array\n                    label: PLUGIN_ADMIN.CUSTOM_ROUTES\n                    help: PLUGIN_ADMIN.CUSTOM_ROUTES_HELP\n                    placeholder_key: PLUGIN_ADMIN.CUSTOM_ROUTES_PLACEHOLDER_KEY\n                    placeholder_value: PLUGIN_ADMIN.CUSTOM_ROUTES_PLACEHOLDER_VALUE\n"
  },
  {
    "path": "system/blueprints/config/streams.yaml",
    "content": "title: PLUGIN_ADMIN.FILE_STREAMS\n\nform:\n  validation: loose\n  hidden: true\n  fields:\n    schemes.xxx:\n      type: array\n"
  },
  {
    "path": "system/blueprints/config/system.yaml",
    "content": "title: PLUGIN_ADMIN.SYSTEM\n\nform:\n  validation: loose\n  fields:\n\n    system_tabs:\n      type: tabs\n      classes: side-tabs\n\n      fields:\n        content:\n          type: tab\n          title: PLUGIN_ADMIN.CONTENT\n\n          fields:\n            content_section:\n              type: section\n              title: PLUGIN_ADMIN.CONTENT\n              underline: true\n\n            home.alias:\n              type: pages\n              size: large\n              classes: fancy\n              label: PLUGIN_ADMIN.HOME_PAGE\n              show_all: false\n              show_modular: false\n              show_root: false\n              show_slug: true\n              help: PLUGIN_ADMIN.HOME_PAGE_HELP\n\n            home.hide_in_urls:\n              type: toggle\n              label: PLUGIN_ADMIN.HIDE_HOME_IN_URLS\n              help: PLUGIN_ADMIN.HIDE_HOME_IN_URLS_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            pages.theme:\n              type: themeselect\n              classes: fancy\n              selectize: true\n              size: medium\n              label: PLUGIN_ADMIN.DEFAULT_THEME\n              help: PLUGIN_ADMIN.DEFAULT_THEME_HELP\n\n            pages.process:\n              type: checkboxes\n              label: PLUGIN_ADMIN.PROCESS\n              help: PLUGIN_ADMIN.PROCESS_HELP\n              default: [markdown: true, twig: true]\n              options:\n                markdown: Markdown\n                twig: Twig\n              use: keys\n\n            pages.types:\n              type: array\n              label: PLUGIN_ADMIN.PAGE_TYPES\n              help: PLUGIN_ADMIN.PAGE_TYPES_HELP\n              size: small\n              default: ['html','htm','json','xml','txt','rss','atom']\n              value_only: true\n\n            timezone:\n              type: select\n              label: PLUGIN_ADMIN.TIMEZONE\n              size: medium\n              classes: fancy\n              help: PLUGIN_ADMIN.TIMEZONE_HELP\n              data-options@: '\\Grav\\Common\\Utils::timezones'\n              default: ''\n              options:\n                '': 'Default (Server Timezone)'\n\n            pages.dateformat.default:\n              type: select\n              size: medium\n              selectize:\n                create: true\n              label: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT\n              help: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT_HELP\n              placeholder: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT_PLACEHOLDER\n              data-options@: '\\Grav\\Common\\Utils::dateFormats'\n              validate:\n                type: string\n\n            pages.dateformat.short:\n              type: dateformat\n              size: medium\n              classes: fancy\n              label: PLUGIN_ADMIN.SHORT_DATE_FORMAT\n              help: PLUGIN_ADMIN.SHORT_DATE_FORMAT_HELP\n              default: \"jS M Y\"\n              options:\n                \"F jS \\\\a\\\\t g:ia\": Date1\n                \"l jS \\\\of F g:i A\": Date2\n                \"D, d M Y G:i:s\": Date3\n                \"d-m-y G:i\": Date4\n                \"jS M Y\": Date5\n                \"Y-m-d G:i\": Date6\n\n            pages.dateformat.long:\n              type: dateformat\n              size: medium\n              classes: fancy\n              label: PLUGIN_ADMIN.LONG_DATE_FORMAT\n              help: PLUGIN_ADMIN.LONG_DATE_FORMAT_HELP\n              options:\n                \"F jS \\\\a\\\\t g:ia\": Date1\n                \"l jS \\\\of F g:i A\": Date2\n                \"D, d M Y G:i:s\": Date3\n                \"d-m-y G:i\": Date4\n                \"jS M Y\": Date5\n                \"Y-m-d G:i:s\": Date6\n\n            pages.order.by:\n              type: select\n              size: large\n              classes: fancy\n              label: PLUGIN_ADMIN.DEFAULT_ORDERING\n              help: PLUGIN_ADMIN.DEFAULT_ORDERING_HELP\n              options:\n                default: PLUGIN_ADMIN.DEFAULT_ORDERING_DEFAULT\n                folder: PLUGIN_ADMIN.DEFAULT_ORDERING_FOLDER\n                title: PLUGIN_ADMIN.DEFAULT_ORDERING_TITLE\n                date: PLUGIN_ADMIN.DEFAULT_ORDERING_DATE\n\n            pages.order.dir:\n              type: toggle\n              label: PLUGIN_ADMIN.DEFAULT_ORDER_DIRECTION\n              highlight: asc\n              default: desc\n              help: PLUGIN_ADMIN.DEFAULT_ORDER_DIRECTION_HELP\n              options:\n                asc: PLUGIN_ADMIN.ASCENDING\n                desc: PLUGIN_ADMIN.DESCENDING\n\n            pages.list.count:\n              type: text\n              size: x-small\n              append: PLUGIN_ADMIN.PAGES\n              label: PLUGIN_ADMIN.DEFAULT_PAGE_COUNT\n              help: PLUGIN_ADMIN.DEFAULT_PAGE_COUNT_HELP\n              validate:\n                type: number\n                min: 1\n\n            pages.publish_dates:\n              type: toggle\n              label: PLUGIN_ADMIN.DATE_BASED_PUBLISHING\n              help: PLUGIN_ADMIN.DATE_BASED_PUBLISHING_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            pages.events:\n              type: checkboxes\n              label: PLUGIN_ADMIN.EVENTS\n              help: PLUGIN_ADMIN.EVENTS_HELP\n              default: [page: true, twig: true]\n              options:\n                page: Page Events\n                twig: Twig Events\n              use: keys\n\n            pages.append_url_extension:\n              type: text\n              size: x-small\n              placeholder: \"e.g. .html\"\n              label: PLUGIN_ADMIN.APPEND_URL_EXT\n              help: PLUGIN_ADMIN.APPEND_URL_EXT_HELP\n\n            pages.redirect_default_code:\n              type: select\n              size: medium\n              classes: fancy\n              label: PLUGIN_ADMIN.REDIRECT_DEFAULT_CODE\n              help: PLUGIN_ADMIN.REDIRECT_DEFAULT_CODE_HELP\n              default: 302\n              options:\n                301: PLUGIN_ADMIN.REDIRECT_OPTION_301\n                302: PLUGIN_ADMIN.REDIRECT_OPTION_302\n                303: PLUGIN_ADMIN.REDIRECT_OPTION_303\n\n            pages.redirect_default_route:\n              type: select\n              size: medium\n              classes: fancy\n              label: PLUGIN_ADMIN.REDIRECT_DEFAULT_ROUTE\n              help: PLUGIN_ADMIN.REDIRECT_DEFAULT_ROUTE_HELP\n              default: 0\n              options:\n                0: PLUGIN_ADMIN.REDIRECT_OPTION_NO_REDIRECT\n                1: PLUGIN_ADMIN.REDIRECT_OPTION_DEFAULT_REDIRECT\n                301: PLUGIN_ADMIN.REDIRECT_OPTION_301\n                302: PLUGIN_ADMIN.REDIRECT_OPTION_302\n              validate:\n                type: int\n\n            pages.redirect_trailing_slash:\n              type: select\n              size: medium\n              classes: fancy\n              label: PLUGIN_ADMIN.REDIRECT_TRAILING_SLASH\n              help: PLUGIN_ADMIN.REDIRECT_TRAILING_SLASH_HELP\n              default: 1\n              options:\n                0: PLUGIN_ADMIN.REDIRECT_OPTION_NO_REDIRECT\n                1: PLUGIN_ADMIN.REDIRECT_OPTION_DEFAULT_REDIRECT\n                301: PLUGIN_ADMIN.REDIRECT_OPTION_301\n                302: PLUGIN_ADMIN.REDIRECT_OPTION_302\n              validate:\n                type: int\n\n            pages.ignore_hidden:\n              type: toggle\n              label: PLUGIN_ADMIN.IGNORE_HIDDEN\n              help: PLUGIN_ADMIN.IGNORE_HIDDEN_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            pages.ignore_files:\n              type: selectize\n              size: large\n              label: PLUGIN_ADMIN.IGNORE_FILES\n              help: PLUGIN_ADMIN.IGNORE_FILES_HELP\n              classes: fancy\n              validate:\n                type: commalist\n\n            pages.ignore_folders:\n              type: selectize\n              size: large\n              label: PLUGIN_ADMIN.IGNORE_FOLDERS\n              help: PLUGIN_ADMIN.IGNORE_FOLDERS_HELP\n              classes: fancy\n              validate:\n                type: commalist\n\n            pages.hide_empty_folders:\n              type: toggle\n              label: PLUGIN_ADMIN.HIDE_EMPTY_FOLDERS\n              help: PLUGIN_ADMIN.HIDE_EMPTY_FOLDERS_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            pages.url_taxonomy_filters:\n              type: toggle\n              label: PLUGIN_ADMIN.ALLOW_URL_TAXONOMY_FILTERS\n              help: PLUGIN_ADMIN.ALLOW_URL_TAXONOMY_FILTERS_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            pages.twig_first:\n              type: toggle\n              label: PLUGIN_ADMIN.TWIG_FIRST\n              help: PLUGIN_ADMIN.TWIG_FIRST_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            pages.never_cache_twig:\n              type: toggle\n              label: PLUGIN_ADMIN.NEVER_CACHE_TWIG\n              help: PLUGIN_ADMIN.NEVER_CACHE_TWIG_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            pages.frontmatter.process_twig:\n              type: toggle\n              label: PLUGIN_ADMIN.FRONTMATTER_PROCESS_TWIG\n              help: PLUGIN_ADMIN.FRONTMATTER_PROCESS_TWIG_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            pages.frontmatter.ignore_fields:\n              type: selectize\n              size: large\n              placeholder: \"e.g. forms\"\n              label: PLUGIN_ADMIN.FRONTMATTER_IGNORE_FIELDS\n              help: PLUGIN_ADMIN.FRONTMATTER_IGNORE_FIELDS_HELP\n              classes: fancy\n              validate:\n                type: commalist\n\n        languages:\n          type: tab\n          title: PLUGIN_ADMIN.LANGUAGES\n\n          fields:\n            languages-section:\n              type: section\n              title: PLUGIN_ADMIN.LANGUAGES\n              underline: true\n\n            languages.supported:\n              type: selectize\n              size: large\n              placeholder: \"e.g. en, fr\"\n              label: PLUGIN_ADMIN.SUPPORTED\n              help: PLUGIN_ADMIN.SUPPORTED_HELP\n              classes: fancy\n              validate:\n                type: commalist\n\n            languages.default_lang:\n              type: text\n              size: x-small\n              label: PLUGIN_ADMIN.DEFAULT_LANG\n              help: PLUGIN_ADMIN.DEFAULT_LANG_HELP\n\n            languages.include_default_lang:\n              type: toggle\n              label: PLUGIN_ADMIN.INCLUDE_DEFAULT_LANG\n              help: PLUGIN_ADMIN.INCLUDE_DEFAULT_LANG_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            languages.include_default_lang_file_extension:\n              type: toggle\n              label: PLUGIN_ADMIN.INCLUDE_DEFAULT_LANG_FILE_EXTENSION\n              help: PLUGIN_ADMIN.INCLUDE_DEFAULT_LANG_HELP_FILE_EXTENSION\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            languages.content_fallback:\n              type: list\n              label: PLUGIN_ADMIN.CONTENT_LANGUAGE_FALLBACKS\n              help: PLUGIN_ADMIN.CONTENT_LANGUAGE_FALLBACKS_HELP\n              fields:\n                key:\n                  type: key\n                  label: PLUGIN_ADMIN.LANGUAGE\n                  help: PLUGIN_ADMIN.CONTENT_FALLBACK_LANGUAGE_HELP\n                  placeholder: fr-ca\n                value:\n                  type: selectize\n                  size: large\n                  placeholder: \"fr, en\"\n                  label: PLUGIN_ADMIN.CONTENT_LANGUAGE_FALLBACK\n                  help: PLUGIN_ADMIN.CONTENT_LANGUAGE_FALLBACK_HELP\n                  classes: fancy\n# TODO: does not work.\n#                  validate:\n#                    type: commalist\n\n            languages.pages_fallback_only:\n              type: toggle\n              label: PLUGIN_ADMIN.PAGES_FALLBACK_ONLY\n              help: PLUGIN_ADMIN.PAGES_FALLBACK_ONLY_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            languages.translations:\n              type: toggle\n              label: PLUGIN_ADMIN.LANGUAGE_TRANSLATIONS\n              help: PLUGIN_ADMIN.LANGUAGE_TRANSLATIONS_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            languages.translations_fallback:\n              type: toggle\n              label: PLUGIN_ADMIN.TRANSLATIONS_FALLBACK\n              help: PLUGIN_ADMIN.TRANSLATIONS_FALLBACK_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            languages.session_store_active:\n              type: toggle\n              label: PLUGIN_ADMIN.ACTIVE_LANGUAGE_IN_SESSION\n              help: PLUGIN_ADMIN.ACTIVE_LANGUAGE_IN_SESSION_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            languages.http_accept_language:\n              type: toggle\n              label: PLUGIN_ADMIN.HTTP_ACCEPT_LANGUAGE\n              help: PLUGIN_ADMIN.HTTP_ACCEPT_LANGUAGE_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            languages.override_locale:\n              type: toggle\n              label: PLUGIN_ADMIN.OVERRIDE_LOCALE\n              help: PLUGIN_ADMIN.OVERRIDE_LOCALE_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            languages.debug:\n              type: toggle\n              label: PLUGIN_ADMIN.LANGUAGE_DEBUG\n              help: PLUGIN_ADMIN.LANGUAGE_DEBUG_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n        http_headers:\n          type: tab\n          title: PLUGIN_ADMIN.HTTP_HEADERS\n\n          fields:\n            http_headers_section:\n              type: section\n              title: PLUGIN_ADMIN.HTTP_HEADERS\n              underline: true\n\n            pages.expires:\n              type: text\n              size: x-small\n              append: GRAV.NICETIME.SECOND_PLURAL\n              label: PLUGIN_ADMIN.EXPIRES\n              help: PLUGIN_ADMIN.EXPIRES_HELP\n              validate:\n                type: number\n                min: 1\n            pages.cache_control:\n              type: text\n              size: medium\n              label: PLUGIN_ADMIN.CACHE_CONTROL\n              help: PLUGIN_ADMIN.CACHE_CONTROL_HELP\n              placeholder: 'e.g. public, max-age=31536000'\n            pages.last_modified:\n              type: toggle\n              label: PLUGIN_ADMIN.LAST_MODIFIED\n              help: PLUGIN_ADMIN.LAST_MODIFIED_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n            pages.etag:\n              type: toggle\n              label: PLUGIN_ADMIN.ETAG\n              help: PLUGIN_ADMIN.ETAG_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n            pages.vary_accept_encoding:\n              type: toggle\n              label: PLUGIN_ADMIN.VARY_ACCEPT_ENCODING\n              help: PLUGIN_ADMIN.VARY_ACCEPT_ENCODING_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n        markdown:\n          type: tab\n          title: PLUGIN_ADMIN.MARKDOWN\n\n          fields:\n            markdow_section:\n              type: section\n              title: PLUGIN_ADMIN.MARKDOWN\n              underline: true\n\n            pages.markdown.extra:\n              type: toggle\n              label: Markdown extra\n              help: PLUGIN_ADMIN.MARKDOWN_EXTRA_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n            pages.markdown.auto_line_breaks:\n              type: toggle\n              label: PLUGIN_ADMIN.AUTO_LINE_BREAKS\n              help: PLUGIN_ADMIN.AUTO_LINE_BREAKS_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n            pages.markdown.auto_url_links:\n              type: toggle\n              label: PLUGIN_ADMIN.AUTO_URL_LINKS\n              help: PLUGIN_ADMIN.AUTO_URL_LINKS_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n            pages.markdown.escape_markup:\n              type: toggle\n              label: PLUGIN_ADMIN.ESCAPE_MARKUP\n              help: PLUGIN_ADMIN.ESCAPE_MARKUP_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n            pages.markdown.valid_link_attributes:\n              type: selectize\n              size: large\n              label: PLUGIN_ADMIN.VALID_LINK_ATTRIBUTES\n              help: PLUGIN_ADMIN.VALID_LINK_ATTRIBUTES_HELP\n              placeholder: \"rel, target, id, class, classes\"\n              classes: fancy\n              validate:\n                type: commalist\n\n        caching:\n          type: tab\n          title: PLUGIN_ADMIN.CACHING\n\n          fields:\n            caching_section:\n              type: section\n              title: PLUGIN_ADMIN.CACHING\n              underline: true\n\n            cache.enabled:\n              type: toggle\n              label: PLUGIN_ADMIN.CACHING\n              help: PLUGIN_ADMIN.CACHING_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            cache.check.method:\n              type: select\n              size: medium\n              classes: fancy\n              label: PLUGIN_ADMIN.CACHE_CHECK_METHOD\n              help: PLUGIN_ADMIN.CACHE_CHECK_METHOD_HELP\n              options:\n                file: Markdown + Yaml file timestamps\n                folder: Folder timestamps\n                hash: All files timestamps\n                none: No timestamp checking\n\n            cache.driver:\n              type: select\n              size: small\n              classes: fancy\n              label: PLUGIN_ADMIN.CACHE_DRIVER\n              help: PLUGIN_ADMIN.CACHE_DRIVER_HELP\n              options:\n                auto: Auto detect\n                file: File\n                apc: APC\n                apcu: APCu\n                memcache: Memcache\n                memcached: Memcached\n                wincache: WinCache\n                redis: Redis\n\n            cache.prefix:\n              type: text\n              size: x-small\n              label: PLUGIN_ADMIN.CACHE_PREFIX\n              help: PLUGIN_ADMIN.CACHE_PREFIX_HELP\n              placeholder: PLUGIN_ADMIN.CACHE_PREFIX_PLACEHOLDER\n\n            cache.purge_max_age_days:\n                type: text\n                size: x-small\n                append: GRAV.NICETIME.DAY_PLURAL\n                label: PLUGIN_ADMIN.CACHE_PURGE_AGE\n                help: PLUGIN_ADMIN.CACHE_PURGE_AGE_HELP\n                validate:\n                    type: number\n                    min: 1\n                    max: 365\n                    step: 1\n                default: 30\n\n            cache.purge_at:\n              type: cron\n              label: PLUGIN_ADMIN.CACHE_PURGE_JOB\n              help: PLUGIN_ADMIN.CACHE_PURGE_JOB_HELP\n              default: '* 4 * * *'\n\n            cache.clear_at:\n              type: cron\n              label: PLUGIN_ADMIN.CACHE_CLEAR_JOB\n              help: PLUGIN_ADMIN.CACHE_CLEAR_JOB_HELP\n              default: '* 3 * * *'\n\n            cache.clear_job_type:\n              type: select\n              size: medium\n              label: PLUGIN_ADMIN.CACHE_JOB_TYPE\n              help: PLUGIN_ADMIN.CACHE_JOB_TYPE_HELP\n              options:\n                standard: Standard Cache Folders\n                all: All Cache Folders\n\n            cache.clear_images_by_default:\n              type: toggle\n              label: PLUGIN_ADMIN.CLEAR_IMAGES_BY_DEFAULT\n              help: PLUGIN_ADMIN.CLEAR_IMAGES_BY_DEFAULT_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            cache.cli_compatibility:\n              type: toggle\n              label: PLUGIN_ADMIN.CLI_COMPATIBILITY\n              help: PLUGIN_ADMIN.CLI_COMPATIBILITY_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            cache.lifetime:\n              type: text\n              size: small\n              append: GRAV.NICETIME.SECOND_PLURAL\n              label: PLUGIN_ADMIN.LIFETIME\n              help: PLUGIN_ADMIN.LIFETIME_HELP\n              validate:\n                type: number\n\n            cache.gzip:\n              type: toggle\n              label: PLUGIN_ADMIN.GZIP_COMPRESSION\n              help: PLUGIN_ADMIN.GZIP_COMPRESSION_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            cache.allow_webserver_gzip:\n              type: toggle\n              label: PLUGIN_ADMIN.ALLOW_WEBSERVER_GZIP\n              help: PLUGIN_ADMIN.ALLOW_WEBSERVER_GZIP_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            cache.memcache.server:\n              type: text\n              size: medium\n              label: PLUGIN_ADMIN.MEMCACHE_SERVER\n              help: PLUGIN_ADMIN.MEMCACHE_SERVER_HELP\n              placeholder: \"localhost\"\n\n            cache.memcache.port:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.MEMCACHE_PORT\n              help: PLUGIN_ADMIN.MEMCACHE_PORT_HELP\n              placeholder: \"11211\"\n\n            cache.memcached.server:\n              type: text\n              size: medium\n              label: PLUGIN_ADMIN.MEMCACHED_SERVER\n              help: PLUGIN_ADMIN.MEMCACHED_SERVER_HELP\n              placeholder: \"localhost\"\n\n            cache.memcached.port:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.MEMCACHED_PORT\n              help: PLUGIN_ADMIN.MEMCACHED_PORT_HELP\n              placeholder: \"11211\"\n\n            cache.redis.socket:\n              type: text\n              size: medium\n              label: PLUGIN_ADMIN.REDIS_SOCKET\n              help: PLUGIN_ADMIN.REDIS_SOCKET_HELP\n              placeholder: \"/var/run/redis/redis.sock\"\n\n            cache.redis.server:\n              type: text\n              size: medium\n              label: PLUGIN_ADMIN.REDIS_SERVER\n              help: PLUGIN_ADMIN.REDIS_SERVER_HELP\n              placeholder: \"localhost\"\n\n            cache.redis.port:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.REDIS_PORT\n              help: PLUGIN_ADMIN.REDIS_PORT_HELP\n              placeholder: \"6379\"\n\n            cache.redis.password:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.REDIS_PASSWORD\n\n            cache.redis.database:\n              type: text\n              size: medium\n              label: PLUGIN_ADMIN.REDIS_DATABASE\n              help: PLUGIN_ADMIN.REDIS_DATABASE_HELP\n              placeholder: \"0\"\n              validate:\n                type: number\n                min: 0\n\n            flex_caching:\n              type: section\n              title: PLUGIN_ADMIN.FLEX_CACHING\n\n            flex.cache.index.enabled:\n              type: toggle\n              label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_ENABLED\n              highlight: 1\n              default: 1\n              options:\n                1: PLUGIN_ADMIN.ENABLED\n                0: PLUGIN_ADMIN.DISABLED\n              validate:\n                type: bool\n\n            flex.cache.index.lifetime:\n              type: text\n              label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_LIFETIME\n              default: 60\n              validate:\n                type: int\n\n            flex.cache.object.enabled:\n              type: toggle\n              label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_ENABLED\n              highlight: 1\n              default: 1\n              options:\n                1: PLUGIN_ADMIN.ENABLED\n                0: PLUGIN_ADMIN.DISABLED\n              validate:\n                type: bool\n\n            flex.cache.object.lifetime:\n              type: text\n              label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_LIFETIME\n              default: 600\n              validate:\n                type: int\n\n            flex.cache.render.enabled:\n              type: toggle\n              label: PLUGIN_ADMIN.FLEX_RENDER_CACHE_ENABLED\n              highlight: 1\n              default: 1\n              options:\n                1: PLUGIN_ADMIN.ENABLED\n                0: PLUGIN_ADMIN.DISABLED\n              validate:\n                type: bool\n\n            flex.cache.render.lifetime:\n              type: text\n              label: PLUGIN_ADMIN.FLEX_RENDER_CACHE_LIFETIME\n              default: 600\n              validate:\n                type: int\n\n        twig:\n          type: tab\n          title: PLUGIN_ADMIN.TWIG_TEMPLATING\n\n          fields:\n            twig_section:\n              type: section\n              title: PLUGIN_ADMIN.TWIG_TEMPLATING\n              underline: true\n\n            twig.cache:\n              type: toggle\n              label: PLUGIN_ADMIN.TWIG_CACHING\n              help: PLUGIN_ADMIN.TWIG_CACHING_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            twig.debug:\n              type: toggle\n              label: PLUGIN_ADMIN.TWIG_DEBUG\n              help: PLUGIN_ADMIN.TWIG_DEBUG_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            twig.auto_reload:\n              type: toggle\n              label: PLUGIN_ADMIN.DETECT_CHANGES\n              help: PLUGIN_ADMIN.DETECT_CHANGES_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            twig.autoescape:\n              type: toggle\n              label: PLUGIN_ADMIN.AUTOESCAPE_VARIABLES\n              help: PLUGIN_ADMIN.AUTOESCAPE_VARIABLES_HELP\n              highlight: 1\n              default: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            twig.umask_fix:\n              type: toggle\n              label: PLUGIN_ADMIN.TWIG_UMASK_FIX\n              help: PLUGIN_ADMIN.TWIG_UMASK_FIX_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n        assets:\n          type: tab\n          title: PLUGIN_ADMIN.ASSETS\n\n          fields:\n            general_config_section:\n              type: section\n              title: PLUGIN_ADMIN.GENERAL_CONFIG\n              underline: true\n\n            assets.enable_asset_timestamp:\n              type: toggle\n              label: PLUGIN_ADMIN.ENABLED_TIMESTAMPS_ON_ASSETS\n              help: PLUGIN_ADMIN.ENABLED_TIMESTAMPS_ON_ASSETS_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.enable_asset_sri:\n              type: toggle\n              label: PLUGIN_ADMIN.ENABLED_SRI_ON_ASSETS\n              help: PLUGIN_ADMIN.ENABLED_SRI_ON_ASSETS_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.collections:\n              type: multilevel\n              label: PLUGIN_ADMIN.COLLECTIONS\n              placeholder_key: collection_name\n              placeholder_value: collection_path\n              validate:\n                type: array\n\n\n            css_assets_section:\n              type: section\n              title: PLUGIN_ADMIN.CSS_ASSETS\n              underline: true\n\n            assets.css_pipeline:\n              type: toggle\n              label: PLUGIN_ADMIN.CSS_PIPELINE\n              help: PLUGIN_ADMIN.CSS_PIPELINE_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.css_pipeline_include_externals:\n              type: toggle\n              label: PLUGIN_ADMIN.CSS_PIPELINE_INCLUDE_EXTERNALS\n              help: PLUGIN_ADMIN.CSS_PIPELINE_INCLUDE_EXTERNALS_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.css_pipeline_before_excludes:\n              type: toggle\n              label: PLUGIN_ADMIN.CSS_PIPELINE_BEFORE_EXCLUDES\n              help: PLUGIN_ADMIN.CSS_PIPELINE_BEFORE_EXCLUDES_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.css_minify:\n              type: toggle\n              label: PLUGIN_ADMIN.CSS_MINIFY\n              help: PLUGIN_ADMIN.CSS_MINIFY_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.css_minify_windows:\n              type: toggle\n              label: PLUGIN_ADMIN.CSS_MINIFY_WINDOWS_OVERRIDE\n              help: PLUGIN_ADMIN.CSS_MINIFY_WINDOWS_OVERRIDE_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.css_rewrite:\n              type: toggle\n              label: PLUGIN_ADMIN.CSS_REWRITE\n              help: PLUGIN_ADMIN.CSS_REWRITE_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            js_assets_section:\n              type: section\n              title: PLUGIN_ADMIN.JS_ASSETS\n              underline: true\n\n            assets.js_pipeline:\n              type: toggle\n              label: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE\n              help: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.js_pipeline_include_externals:\n              type: toggle\n              label: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_INCLUDE_EXTERNALS\n              help: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_INCLUDE_EXTERNALS_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.js_pipeline_before_excludes:\n              type: toggle\n              label: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_BEFORE_EXCLUDES\n              help: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE_BEFORE_EXCLUDES_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.js_minify:\n              type: toggle\n              label: PLUGIN_ADMIN.JAVASCRIPT_MINIFY\n              help: PLUGIN_ADMIN.JAVASCRIPT_MINIFY_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            js_module_assets_section:\n              type: section\n              title: PLUGIN_ADMIN.JS_MODULE_ASSETS\n              underline: true\n\n            assets.js_module_pipeline:\n              type: toggle\n              label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE\n              help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.js_module_pipeline_include_externals:\n              type: toggle\n              label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_INCLUDE_EXTERNALS\n              help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_INCLUDE_EXTERNALS_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            assets.js_module_pipeline_before_excludes:\n              type: toggle\n              label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_BEFORE_EXCLUDES\n              help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_BEFORE_EXCLUDES_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n\n\n        errors:\n          type: tab\n          title: PLUGIN_ADMIN.ERROR_HANDLER\n\n          fields:\n            errors_section:\n              type: section\n              title: PLUGIN_ADMIN.ERROR_HANDLER\n              underline: true\n\n            errors.display:\n              type: select\n              label: PLUGIN_ADMIN.DISPLAY_ERRORS\n              help: PLUGIN_ADMIN.DISPLAY_ERRORS_HELP\n              size: medium\n              highlight: 1\n              options:\n                -1: PLUGIN_ADMIN.ERROR_SYSTEM\n                0: PLUGIN_ADMIN.ERROR_SIMPLE\n                1: PLUGIN_ADMIN.ERROR_FULL_BACKTRACE\n              validate:\n                type: int\n\n\n            errors.log:\n              type: toggle\n              label: PLUGIN_ADMIN.LOG_ERRORS\n              help: PLUGIN_ADMIN.LOG_ERRORS_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            log.handler:\n              type: select\n              size: small\n              label: PLUGIN_ADMIN.LOG_HANDLER\n              help: PLUGIN_ADMIN.LOG_HANDLER_HELP\n              default: 'file'\n              options:\n                'file': 'File'\n                'syslog': 'Syslog'\n\n            log.syslog.facility:\n              type: select\n              size: small\n              label: PLUGIN_ADMIN.SYSLOG_FACILITY\n              help: PLUGIN_ADMIN.SYSLOG_FACILITY_HELP\n              default: local6\n              options:\n                auth: auth\n                authpriv: authpriv\n                cron: cron\n                daemon: daemon\n                kern: kern\n                lpr: lpr\n                mail: mail\n                news: news\n                syslog: syslog\n                user: user\n                uucp: uucp\n                local0: local0\n                local1: local1\n                local2: local2\n                local3: local3\n                local4: local4\n                local5: local5\n                local6: local6\n                local7: local7\n\n            log.syslog.tag:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.SYSLOG_TAG\n              help: PLUGIN_ADMIN.SYSLOG_TAG_HELP\n              placeholder: \"grav\"\n\n        debugger:\n          type: tab\n          title: PLUGIN_ADMIN.DEBUGGER\n\n          fields:\n            debugger_section:\n              type: section\n              title: PLUGIN_ADMIN.DEBUGGER\n              underline: true\n\n            debugger.enabled:\n              type: toggle\n              label: PLUGIN_ADMIN.DEBUGGER\n              help: PLUGIN_ADMIN.DEBUGGER_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            debugger.provider:\n              type: select\n              label: PLUGIN_ADMIN.DEBUGGER_PROVIDER\n              help: PLUGIN_ADMIN.DEBUGGER_PROVIDER_HELP\n              size: medium\n              default: debugbar\n              options:\n                debugbar: PLUGIN_ADMIN.DEBUGGER_DEBUGBAR\n                clockwork: PLUGIN_ADMIN.DEBUGGER_CLOCKWORK\n\n            debugger.censored:\n              type: toggle\n              label: PLUGIN_ADMIN.DEBUGGER_CENSORED\n              help: PLUGIN_ADMIN.DEBUGGER_CENSORED_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            debugger.shutdown.close_connection:\n              type: toggle\n              label: PLUGIN_ADMIN.SHUTDOWN_CLOSE_CONNECTION\n              help: PLUGIN_ADMIN.SHUTDOWN_CLOSE_CONNECTION_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n        media:\n          type: tab\n          title: PLUGIN_ADMIN.MEDIA\n\n          fields:\n            media_section:\n              type: section\n              title: PLUGIN_ADMIN.MEDIA\n              underline: true\n\n            images.adapter:\n              type: select\n              size: small\n              label: PLUGIN_ADMIN.IMAGE_ADAPTER\n              help: PLUGIN_ADMIN.IMAGE_ADAPTER_HELP\n              highlight: gd\n              options:\n                  gd: GD (PHP built-in)\n                  imagick: Imagick\n\n            images.default_image_quality:\n              type: range\n              append: '%'\n              label: PLUGIN_ADMIN.DEFAULT_IMAGE_QUALITY\n              help: PLUGIN_ADMIN.DEFAULT_IMAGE_QUALITY_HELP\n              validate:\n                min: 1\n                max: 100\n\n            images.cache_all:\n              type: toggle\n              label: PLUGIN_ADMIN.CACHE_ALL\n              help: PLUGIN_ADMIN.CACHE_ALL_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            images.cache_perms:\n              type: select\n              size: small\n              label: PLUGIN_ADMIN.CACHE_PERMS\n              help: PLUGIN_ADMIN.CACHE_PERMS_HELP\n              highlight: '0755'\n              options:\n                '0755': '0755'\n                '0775': '0775'\n\n            images.debug:\n              type: toggle\n              label: PLUGIN_ADMIN.IMAGES_DEBUG\n              help: PLUGIN_ADMIN.IMAGES_DEBUG_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            images.auto_fix_orientation:\n              type: toggle\n              label: PLUGIN_ADMIN.IMAGES_AUTO_FIX_ORIENTATION\n              help: PLUGIN_ADMIN.IMAGES_AUTO_FIX_ORIENTATION_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            images.defaults.loading:\n              type: select\n              size: small\n              label: PLUGIN_ADMIN.IMAGES_LOADING\n              help: PLUGIN_ADMIN.IMAGES_LOADING_HELP\n              highlight: auto\n              options:\n                auto: Auto\n                lazy: Lazy\n                eager: Eager\n            \n            images.defaults.decoding:\n              type: select\n              size: small\n              label: PLUGIN_ADMIN.IMAGES_DECODING\n              help: PLUGIN_ADMIN.IMAGES_DECODING_HELP\n              highlight: auto\n              options:\n                auto: Auto\n                sync: Sync\n                async: Async\n            \n            images.defaults.fetchpriority:\n              type: select\n              size: small\n              label: PLUGIN_ADMIN.IMAGES_FETCHPRIORITY\n              help: PLUGIN_ADMIN.IMAGES_FETCHPRIORITY_HELP\n              highlight: auto\n              options:\n                auto: Auto\n                high: High\n                low: Low\n\n            images.seofriendly:\n              type: toggle\n              label: PLUGIN_ADMIN.IMAGES_SEOFRIENDLY\n              help: PLUGIN_ADMIN.IMAGES_SEOFRIENDLY_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            media.enable_media_timestamp:\n              type: toggle\n              label: PLUGIN_ADMIN.ENABLE_MEDIA_TIMESTAMP\n              help: PLUGIN_ADMIN.ENABLE_MEDIA_TIMESTAMP_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            media.auto_metadata_exif:\n              type: toggle\n              label: PLUGIN_ADMIN.ENABLE_AUTO_METADATA\n              help: PLUGIN_ADMIN.ENABLE_AUTO_METADATA_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            media.allowed_fallback_types:\n              type: selectize\n              size: large\n              label: PLUGIN_ADMIN.FALLBACK_TYPES\n              help: PLUGIN_ADMIN.FALLBACK_TYPES_HELP\n              classes: fancy\n              validate:\n                type: commalist\n\n            media.unsupported_inline_types:\n              type: selectize\n              size: large\n              label: PLUGIN_ADMIN.INLINE_TYPES\n              help: PLUGIN_ADMIN.INLINE_TYPES_HELP\n              classes: fancy\n              validate:\n                type: commalist\n\n            section_images_cls:\n              type: section\n              title: PLUGIN_ADMIN.IMAGES_CLS_TITLE\n              underline: true\n\n            images.cls.auto_sizes:\n              type: toggle\n              label: PLUGIN_ADMIN.IMAGES_CLS_AUTO_SIZES\n              help: PLUGIN_ADMIN.IMAGES_CLS_AUTO_SIZES_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            images.cls.aspect_ratio:\n              type: toggle\n              label: PLUGIN_ADMIN.IMAGES_CLS_ASPECT_RATIO\n              help: PLUGIN_ADMIN.IMAGES_CLS_ASPECT_RATIO_HELP\n              highlight: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            images.cls.retina_scale:\n              type: select\n              label: PLUGIN_ADMIN.IMAGES_CLS_RETINA_SCALE\n              help: PLUGIN_ADMIN.IMAGES_CLS_RETINA_SCALE_HELP\n              size: small\n              highlight: 1\n              options:\n                1: 1X\n                2: 2X\n                3: 3X\n                4: 4X\n\n        session:\n          type: tab\n          title: PLUGIN_ADMIN.SESSION\n\n          fields:\n            session_section:\n              type: section\n              title: PLUGIN_ADMIN.SESSION\n              underline: true\n\n            session.enabled:\n              type: hidden\n              label: PLUGIN_ADMIN.ENABLED\n              help: PLUGIN_ADMIN.SESSION_ENABLED_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              default: true\n              validate:\n                type: bool\n\n            session.initialize:\n              type: toggle\n              label: PLUGIN_ADMIN.SESSION_INITIALIZE\n              help: PLUGIN_ADMIN.SESSION_INITIALIZE_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              default: true\n              validate:\n                type: bool\n\n            session.timeout:\n              type: text\n              size: small\n              append: GRAV.NICETIME.SECOND_PLURAL\n              label: PLUGIN_ADMIN.TIMEOUT\n              help: PLUGIN_ADMIN.TIMEOUT_HELP\n              validate:\n                type: number\n                min: 0\n\n            session.name:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.NAME\n              help: PLUGIN_ADMIN.SESSION_NAME_HELP\n\n            session.uniqueness:\n              type: select\n              size: medium\n              label: PLUGIN_ADMIN.SESSION_UNIQUENESS\n              help: PLUGIN_ADMIN.SESSION_UNIQUENESS_HELP\n              highlight: path\n              default: path\n              options:\n                path: Grav's root file path\n                salt: Grav's random security salt\n\n            session.secure:\n              type: toggle\n              label: PLUGIN_ADMIN.SESSION_SECURE\n              help: PLUGIN_ADMIN.SESSION_SECURE_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              default: false\n              validate:\n                type: bool\n\n            session.secure_https:\n              type: toggle\n              label: PLUGIN_ADMIN.SESSION_SECURE_HTTPS\n              help: PLUGIN_ADMIN.SESSION_SECURE_HTTPS_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              default: true\n              validate:\n                type: bool\n\n            session.httponly:\n              type: toggle\n              label: PLUGIN_ADMIN.SESSION_HTTPONLY\n              help: PLUGIN_ADMIN.SESSION_HTTPONLY_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              default: true\n              validate:\n                type: bool\n\n            session.domain:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.SESSION_DOMAIN\n              help: PLUGIN_ADMIN.SESSION_DOMAIN_HELP\n\n            session.path:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.SESSION_PATH\n              help: PLUGIN_ADMIN.SESSION_PATH_HELP\n\n            session.samesite:\n              type: text\n              size: small\n              label: PLUGIN_ADMIN.SESSION_SAMESITE\n              help: PLUGIN_ADMIN.SESSION_SAMESITE_HELP\n\n            session.split:\n              type: toggle\n              label: PLUGIN_ADMIN.SESSION_SPLIT\n              help: PLUGIN_ADMIN.SESSION_SPLIT_HELP\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              default: true\n              validate:\n                type: bool\n\n        advanced:\n          type: tab\n          title: PLUGIN_ADMIN.ADVANCED\n\n          fields:\n            advanced_section:\n              type: section\n              title: PLUGIN_ADMIN.ADVANCED\n              underline: true\n\n            gpm_section:\n              type: section\n              title: PLUGIN_ADMIN.GPM_SECTION\n\n            gpm.releases:\n              type: toggle\n              label: PLUGIN_ADMIN.GPM_RELEASES\n              highlight: stable\n              help: PLUGIN_ADMIN.GPM_RELEASES_HELP\n              options:\n                stable: PLUGIN_ADMIN.STABLE\n                testing: PLUGIN_ADMIN.TESTING\n\n            gpm.official_gpm_only:\n              type: toggle\n              label: PLUGIN_ADMIN.GPM_OFFICIAL_ONLY\n              highlight: 1\n              help: PLUGIN_ADMIN.GPM_OFFICIAL_ONLY_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              default: true\n              validate:\n                type: bool\n\n            http_section:\n              type: section\n              title: PLUGIN_ADMIN.HTTP_SECTION\n\n            http.method:\n              type: toggle\n              label: PLUGIN_ADMIN.GPM_METHOD\n              highlight: auto\n              help: PLUGIN_ADMIN.GPM_METHOD_HELP\n              options:\n                auto: PLUGIN_ADMIN.AUTO\n                fopen: PLUGIN_ADMIN.FOPEN\n                curl: PLUGIN_ADMIN.CURL\n\n            http.enable_proxy:\n              type: toggle\n              label: PLUGIN_ADMIN.SSL_ENABLE_PROXY\n              highlight: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              default: false\n              validate:\n                type: bool\n\n            http.proxy_url:\n              type: text\n              size: medium\n              placeholder: \"e.g. 127.0.0.1:3128\"\n              label: PLUGIN_ADMIN.PROXY_URL\n              help: PLUGIN_ADMIN.PROXY_URL_HELP\n\n            http.proxy_cert_path:\n              type: text\n              size: medium\n              placeholder: \"e.g. /Users/bob/certs/\"\n              label: PLUGIN_ADMIN.PROXY_CERT\n              help: PLUGIN_ADMIN.PROXY_CERT_HELP\n\n            http.verify_peer:\n              type: toggle\n              label: PLUGIN_ADMIN.SSL_VERIFY_PEER\n              highlight: 1\n              help: PLUGIN_ADMIN.SSL_VERIFY_PEER_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            http.verify_host:\n              type: toggle\n              label: PLUGIN_ADMIN.SSL_VERIFY_HOST\n              highlight: 1\n              help: PLUGIN_ADMIN.SSL_VERIFY_HOST_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            http.concurrent_connections:\n              type: number\n              size: x-small\n              label: PLUGIN_ADMIN.HTTP_CONNECTIONS\n              help: PLUGIN_ADMIN.HTTP_CONNECTIONS_HELP\n              validate:\n                min: 1\n                max: 20\n\n            misc_section:\n              type: section\n              title: PLUGIN_ADMIN.MISC_SECTION\n\n            reverse_proxy_setup:\n              type: toggle\n              label: PLUGIN_ADMIN.REVERSE_PROXY\n              highlight: 0\n              help: PLUGIN_ADMIN.REVERSE_PROXY_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            username_regex:\n              type: text\n              size: large\n              label: PLUGIN_ADMIN.USERNAME_REGEX\n              help: PLUGIN_ADMIN.USERNAME_REGEX_HELP\n\n            pwd_regex:\n              type: text\n              size: large\n              label: PLUGIN_ADMIN.PWD_REGEX\n              help: PLUGIN_ADMIN.PWD_REGEX_HELP\n\n            intl_enabled:\n              type: toggle\n              label: PLUGIN_ADMIN.INTL_ENABLED\n              highlight: 1\n              help: PLUGIN_ADMIN.INTL_ENABLED_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            wrapped_site:\n              type: toggle\n              label: PLUGIN_ADMIN.WRAPPED_SITE\n              highlight: 0\n              help: PLUGIN_ADMIN.WRAPPED_SITE_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            absolute_urls:\n              type: toggle\n              label: PLUGIN_ADMIN.ABSOLUTE_URLS\n              highlight: 0\n              help: PLUGIN_ADMIN.ABSOLUTE_URLS_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            param_sep:\n              type: select\n              size: medium\n              label: PLUGIN_ADMIN.PARAMETER_SEPARATOR\n              classes: fancy\n              help: PLUGIN_ADMIN.PARAMETER_SEPARATOR_HELP\n              default: ''\n              options:\n                ':': ': (default)'\n                ';': '; (for Apache running on Windows)'\n\n            force_ssl:\n              type: toggle\n              label: PLUGIN_ADMIN.FORCE_SSL\n              highlight: 0\n              help: PLUGIN_ADMIN.FORCE_SSL_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            force_lowercase_urls:\n              type: toggle\n              label: PLUGIN_ADMIN.FORCE_LOWERCASE_URLS\n              highlight: 1\n              default: 1\n              help: PLUGIN_ADMIN.FORCE_LOWERCASE_URLS_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            custom_base_url:\n              type: text\n              size: medium\n              placeholder: \"e.g. http://yoursite.com/yourpath\"\n              label: PLUGIN_ADMIN.CUSTOM_BASE_URL\n              help: PLUGIN_ADMIN.CUSTOM_BASE_URL_HELP\n\n            http_x_forwarded.protocol:\n              type: toggle\n              label: HTTP_X_FORWARDED_PROTO Enabled\n              highlight: 1\n              default: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            http_x_forwarded.host:\n              type: toggle\n              label: HTTP_X_FORWARDED_HOST Enabled\n              highlight: 0\n              default: 0\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            http_x_forwarded.port:\n              type: toggle\n              label: HTTP_X_FORWARDED_PORT Enabled\n              highlight: 1\n              default: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            http_x_forwarded.ip:\n              type: toggle\n              label: HTTP_X_FORWARDED IP Enabled\n              highlight: 1\n              default: 1\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n\n            strict_mode.blueprint_compat:\n              type: toggle\n              label: PLUGIN_ADMIN.STRICT_BLUEPRINT_COMPAT\n              highlight: 0\n              default: 0\n              help: PLUGIN_ADMIN.STRICT_BLUEPRINT_COMPAT_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            strict_mode.yaml_compat:\n              type: toggle\n              label: PLUGIN_ADMIN.STRICT_YAML_COMPAT\n              highlight: 0\n              default: 0\n              help: PLUGIN_ADMIN.STRICT_YAML_COMPAT_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n            strict_mode.twig_compat:\n              type: toggle\n              label: PLUGIN_ADMIN.STRICT_TWIG_COMPAT\n              highlight: 0\n              default: 0\n              help: PLUGIN_ADMIN.STRICT_TWIG_COMPAT_HELP\n              options:\n                1: PLUGIN_ADMIN.YES\n                0: PLUGIN_ADMIN.NO\n              validate:\n                type: bool\n\n\n        accounts:\n          type: tab\n          title: PLUGIN_ADMIN.ACCOUNTS\n\n          fields:\n            flex_accounts:\n              type: section\n              title: User Accounts\n\n            accounts.type:\n              type: select\n              label: PLUGIN_ADMIN.ACCOUNTS_TYPE\n              highlight: stable\n              help: PLUGIN_ADMIN.ACCOUNTS_TYPE_HELP\n              options:\n                regular: PLUGIN_ADMIN.REGULAR\n                flex: PLUGIN_ADMIN.FLEX\n\n            accounts.storage:\n              type: select\n              label: PLUGIN_ADMIN.ACCOUNTS_STORAGE\n              highlight: stable\n              help: PLUGIN_ADMIN.ACCOUNTS_STORAGE_HELP\n              options:\n                file: PLUGIN_ADMIN.FILE\n                folder: PLUGIN_ADMIN.FOLDER\n\n            accounts.avatar:\n              type: select\n              label: PLUGIN_ADMIN.AVATAR\n              default: gravatar\n              help: PLUGIN_ADMIN.AVATAR_HELP\n              options:\n                multiavatar: Multiavatar [local]\n                gravatar: Gravatar [external]\n\n#        experimental:\n#          type: tab\n#          title: PLUGIN_ADMIN.EXPERIMENTAL\n#\n#          fields:\n#            experimental_section:\n#              type: section\n#              title: PLUGIN_ADMIN.EXPERIMENTAL\n#              underline: true\n#\n#            flex_pages:\n#              type: section\n#              title: Flex Pages\n#\n#            pages.type:\n#              type: select\n#              label: PLUGIN_ADMIN.PAGES_TYPE\n#              highlight: regular\n#              help: PLUGIN_ADMIN.PAGES_TYPE_HELP\n#              options:\n#                regular: PLUGIN_ADMIN.REGULAR\n#                flex: PLUGIN_ADMIN.FLEX\n#\n#            pages.type:\n#              type: hidden\n\n\n\n"
  },
  {
    "path": "system/blueprints/flex/accounts.yaml",
    "content": "title: Flex User Accounts\ndescription: Manage your User Accounts in Flex.\ntype: flex-objects\n\n# Deprecated in Grav 1.7.0-rc.4: file was renamed to user-accounts.yaml\nextends@:\n  type: user-accounts\n  context: blueprints://flex\n"
  },
  {
    "path": "system/blueprints/flex/configure/compat.yaml",
    "content": "form:\n  compatibility:\n    type: tab\n    title: Compatibility\n    fields:\n      object.compat.events:\n        type: toggle\n        toggleable: true\n        label: Admin event compatibility\n        help: Enables onAdminSave and onAdminAfterSave events for plugins\n        highlight: 1\n        default: 1\n        options:\n          1: PLUGIN_ADMIN.ENABLED\n          0: PLUGIN_ADMIN.DISABLED\n        validate:\n          type: bool\n"
  },
  {
    "path": "system/blueprints/flex/pages.yaml",
    "content": "title: Pages\ndescription: Manage your Grav Pages in Flex.\ntype: flex-objects\n\n# Extends a page (blueprint gets overridden inside the object)\nextends@:\n  type: default\n  context: blueprints://pages\n\n#\n# HIGHLY SPECIALIZED FLEX TYPE, AVOID USING PAGES AS BASE FOR YOUR OWN TYPE.\n#\n\n# Flex configuration\nconfig:\n  # Administration Configuration (needs Flex Objects plugin)\n  admin:\n    # Admin router\n    router:\n      path: '/pages'\n\n    # Permissions\n    permissions:\n      # Primary permissions\n      admin.pages:\n        type: crudl\n        label: Pages\n      admin.configuration.pages:\n        type: default\n        label: Pages Configuration\n\n    # Admin menu\n    menu:\n      list:\n        route: '/pages'\n        title: PLUGIN_ADMIN.PAGES\n        icon: fa-file-text\n        authorize: ['admin.pages.list', 'admin.super']\n        priority: 5\n\n    # Admin template type (folder)\n    template: pages\n\n    # Allowed admin actions\n    actions:\n      list: true\n      create: true\n      read: true\n      update: true\n      delete: true\n\n    # List view\n    list:\n      # Fields shown in the list view\n      fields:\n        published:\n          width: 8\n          alias: header.published\n        visible:\n          width: 8\n          field:\n            label: Visible\n            type: toggle\n        menu:\n          link: edit\n          alias: header.menu\n        full_route:\n          field:\n            label: Route\n            type: text\n          link: edit\n          sort:\n            field: key\n        name:\n          width: 8\n          field:\n            label: Type\n            type: text\n        translations:\n          width: 8\n          field:\n            label: Translations\n            type: text\n#        updated_date:\n#          alias: header.update_date\n\n      # Extra options\n      options:\n        # Default number of records for pagination\n        per_page: 20\n        # Default ordering\n        order:\n          by: key\n          dir: asc\n\n      # TODO: not used yet\n      buttons:\n        back:\n          icon: reply\n          title: PLUGIN_ADMIN.BACK\n        add:\n          icon: plus\n          label: PLUGIN_ADMIN.ADD\n\n    edit:\n      title:\n        template: \"{% if object.root %}Root <small>( &lt;root&gt; )</small>{% else %}{{ (form.value('header.title') ?? form.value('folder'))|e }} <small>( {{ (object.getRoute().toString(false) ?: '/')|e }} )</small>{% endif %}\"\n\n      # TODO: not used yet\n      buttons:\n        back:\n          icon: reply\n          title: PLUGIN_ADMIN.BACK\n        preview:\n          icon: eye\n          title: PLUGIN_ADMIN.PREVIEW\n        add:\n          icon: plus\n          label: PLUGIN_ADMIN.ADD\n        copy:\n          icon: copy\n          label: PLUGIN_ADMIN.COPY\n        move:\n          icon: arrows\n          label: PLUGIN_ADMIN.MOVE\n        delete:\n          icon: close\n          label: PLUGIN_ADMIN.DELETE\n        save:\n          icon: check\n          label: PLUGIN_ADMIN.SAVE\n\n    # Preview View\n    preview:\n      enabled: true\n\n    # Configure view\n    configure:\n      authorize: 'admin.configuration.pages'\n\n  # Site Configuration\n  site:\n    # Hide from flex types\n    hidden: true\n    templates:\n      collection:\n        # Lookup for the template layout files for collections of objects\n        paths:\n          - 'flex/{TYPE}/collection/{LAYOUT}{EXT}'\n      object:\n        # Lookup for the template layout files for objects\n        paths:\n          - 'flex/{TYPE}/object/{LAYOUT}{EXT}'\n      defaults:\n        # Default template {TYPE}; overridden by filename of this blueprint if template folder exists\n        type: pages\n        # Default template {LAYOUT}; can be overridden in render calls (usually Twig in templates)\n        layout: default\n\n    # Default filters for frontend.\n    filter:\n      - withPublished\n\n  # Data Configuration\n  data:\n    object: 'Grav\\Common\\Flex\\Types\\Pages\\PageObject'\n    collection: 'Grav\\Common\\Flex\\Types\\Pages\\PageCollection'\n    index: 'Grav\\Common\\Flex\\Types\\Pages\\PageIndex'\n    storage:\n      class: 'Grav\\Common\\Flex\\Types\\Pages\\Storage\\PageStorage'\n      options:\n        formatter:\n          class: 'Grav\\Framework\\File\\Formatter\\MarkdownFormatter'\n        folder: 'page://'\n        # Keep index file in filesystem to speed up lookups\n        indexed: true\n    # Set default ordering of the pages\n    ordering:\n      storage_key: ASC\n    search:\n       # Search options\n      options:\n        contains: 1\n      # Fields to be searched\n      fields:\n        - key\n        - slug\n        - menu\n        - title\n\nblueprints:\n  configure:\n    fields:\n      import@:\n        type: configure/compat\n        context: blueprints://flex\n\n# Regular form definition\nform:\n  fields:\n    lang:\n      type: hidden\n      value: ''\n\n    tabs:\n      fields:\n        security:\n          type: tab\n          title: PLUGIN_ADMIN.SECURITY\n          import@:\n            type: partials/security\n            context: blueprints://pages\n"
  },
  {
    "path": "system/blueprints/flex/shared/configure.yaml",
    "content": "form:\n  validation: loose\n\n  fields:\n    tabs:\n      type: tabs\n      fields:\n        cache:\n          type: tab\n          title: Caching\n          fields:\n            object.cache.index.enabled:\n              type: toggle\n              toggleable: true\n              label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_ENABLED\n              highlight: 1\n              config-default@: system.flex.cache.index.enabled\n              options:\n                1: PLUGIN_ADMIN.ENABLED\n                0: PLUGIN_ADMIN.DISABLED\n              validate:\n                type: bool\n\n            object.cache.index.lifetime:\n              type: text\n              toggleable: true\n              label: PLUGIN_ADMIN.FLEX_INDEX_CACHE_LIFETIME\n              config-default@: system.flex.cache.index.lifetime\n              validate:\n                type: int\n\n            object.cache.object.enabled:\n              type: toggle\n              toggleable: true\n              label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_ENABLED\n              highlight: 1\n              config-default@: system.flex.cache.object.enabled\n              options:\n                1: PLUGIN_ADMIN.ENABLED\n                0: PLUGIN_ADMIN.DISABLED\n              validate:\n                type: bool\n\n            object.cache.object.lifetime:\n              type: text\n              toggleable: true\n              label: PLUGIN_ADMIN.FLEX_OBJECT_CACHE_LIFETIME\n              config-default@: system.flex.cache.object.lifetime\n              validate:\n                type: int\n\n            object.cache.render.enabled:\n              type: toggle\n              toggleable: true\n              label: PLUGIN_ADMIN.FLEX_RENDER_CACHE_ENABLED\n              highlight: 1\n              config-default@: system.flex.cache.render.enabled\n              options:\n                1: PLUGIN_ADMIN.ENABLED\n                0: PLUGIN_ADMIN.DISABLED\n              validate:\n                type: bool\n\n            object.cache.render.lifetime:\n              type: text\n              toggleable: true\n              label: PLUGIN_ADMIN.FLEX_RENDER_CACHE_LIFETIME\n              config-default@: system.flex.cache.render.lifetime\n              validate:\n                type: int\n"
  },
  {
    "path": "system/blueprints/flex/user-accounts.yaml",
    "content": "title: User Accounts\ndescription: Manage your User Accounts in Flex.\ntype: flex-objects\n\n# Extends user account\nextends@:\n  type: account\n  context: blueprints://user\n\n#\n# HIGHLY SPECIALIZED FLEX TYPE, AVOID USING USER ACCOUNTS AS BASE FOR YOUR OWN TYPE.\n#\n\n# Flex configuration\nconfig:\n  # Administration Configuration (needs Flex Objects plugin)\n  admin:\n    # Admin router\n    router:\n      path: '/accounts/users'\n      actions:\n        configure:\n          path: '/accounts/configure'\n      redirects:\n        '/user': '/accounts/users'\n        '/accounts': '/accounts/users'\n\n    # Permissions\n    permissions:\n      # Primary permissions\n      admin.users:\n        type: crudl\n        label: User Accounts\n      admin.configuration.users:\n        type: default\n        label: Accounts Configuration\n\n    # Admin menu\n    menu:\n      base:\n        location: '/accounts'\n        route: '/accounts/users'\n        index: 0\n        title: PLUGIN_ADMIN.ACCOUNTS\n        icon: fa-users\n        authorize: ['admin.users.list', 'admin.super']\n        priority: 6\n\n    # Admin template type (folder)\n    template: user-accounts\n\n    # List view\n    list:\n      # Fields shown in the list view\n      fields:\n        username:\n          link: edit\n          search: true\n          field:\n            label: PLUGIN_ADMIN.USERNAME\n        email:\n          search: true\n        fullname:\n          search: true\n      # Extra options\n      options:\n        per_page: 20\n        order:\n          by: username\n          dir: asc\n\n    # Edit view\n    edit:\n      title:\n        template: \"{{ form.value('fullname') ?? form.value('username') }} &lt;{{ form.value('email') }}&gt;\"\n\n    # Configure view\n    configure:\n      hidden: true\n      authorize: 'admin.configuration.users'\n      form: 'accounts'\n      title:\n        template: \"{{ 'PLUGIN_ADMIN.ACCOUNTS'|tu }} {{ 'PLUGIN_ADMIN.CONFIGURATION'|tu }}\"\n\n  # Site Configuration\n  site:\n    # Hide from flex types\n    hidden: true\n    templates:\n      collection:\n        # Lookup for the template layout files for collections of objects\n        paths:\n          - 'flex/{TYPE}/collection/{LAYOUT}{EXT}'\n      object:\n        # Lookup for the template layout files for objects\n        paths:\n          - 'flex/{TYPE}/object/{LAYOUT}{EXT}'\n      defaults:\n        # Default template {TYPE}; overridden by filename of this blueprint if template folder exists\n        type: user-accounts\n        # Default template {LAYOUT}; can be overridden in render calls (usually Twig in templates)\n        layout: default\n\n  # Data Configuration\n  data:\n    object: 'Grav\\Common\\Flex\\Types\\Users\\UserObject'\n    collection: 'Grav\\Common\\Flex\\Types\\Users\\UserCollection'\n    index: 'Grav\\Common\\Flex\\Types\\Users\\UserIndex'\n    storage:\n      class: 'Grav\\Common\\Flex\\Types\\Users\\Storage\\UserFileStorage'\n      options:\n        formatter:\n          class: 'Grav\\Framework\\File\\Formatter\\YamlFormatter'\n        folder: 'account://'\n        pattern: '{FOLDER}/{KEY}{EXT}'\n        indexed: true\n        key: username\n        case_sensitive: false\n    search:\n      options:\n        contains: 1\n      fields:\n        - key\n        - email\n        - username\n        - fullname\n\n  relationships:\n    media:\n      type: media\n      cardinality: to-many\n    avatar:\n      type: media\n      cardinality: to-one\n#    roles:\n#      type: user-groups\n#      cardinality: to-many\n\nblueprints:\n  configure:\n    fields:\n      import@:\n        type: configure/compat\n        context: blueprints://flex\n\n# Regular form definition\nform:\n  fields:\n    username:\n      flex-disabled@: exists\n      disabled: false\n      flex-readonly@: exists\n      readonly: false\n      validate:\n        required: true\n"
  },
  {
    "path": "system/blueprints/flex/user-groups.yaml",
    "content": "title: User Groups\ndescription: Manage your User Groups in Flex.\ntype: flex-objects\n\n# Extends user group\nextends@:\n  type: group\n  context: blueprints://user\n\n# Flex configuration\nconfig:\n  # Administration Configuration (needs Flex Objects plugin)\n  admin:\n    # Admin router\n    router:\n      path: '/accounts/groups'\n      actions:\n        configure:\n          path: '/accounts/configure'\n      redirects:\n        '/groups': '/accounts/groups'\n        '/accounts': '/accounts/groups'\n\n    # Permissions\n    permissions:\n      # Primary permissions\n      admin.users:\n        type: crudl\n        label: User Accounts\n      admin.configuration.users:\n        type: default\n        label: Accounts Configuration\n\n    # Admin menu\n    menu:\n      base:\n        location: '/accounts'\n        route: '/accounts/groups'\n        index: 1\n        title: PLUGIN_ADMIN.ACCOUNTS\n        icon: fa-users\n        authorize: ['admin.users.list', 'admin.super']\n        priority: 6\n\n    # Admin template type (folder)\n    template: user-groups\n\n    # List view\n    list:\n      # Fields shown in the list view\n      fields:\n        groupname:\n          link: edit\n          search: true\n        readableName:\n          search: true\n        description:\n          search: true\n      # Extra options\n      options:\n        per_page: 20\n        order:\n          by: groupname\n          dir: asc\n\n    # Edit view\n    edit:\n      title:\n        template: \"{{ form.value('readableName') ?? form.value('groupname') }}\"\n\n    # Configure view\n    configure:\n      hidden: true\n      authorize: 'admin.configuration.users'\n      form: 'accounts'\n      title:\n        template: \"{{ 'PLUGIN_ADMIN.ACCOUNTS'|tu }} {{ 'PLUGIN_ADMIN.CONFIGURATION'|tu }}\"\n\n  # Site Configuration\n  site:\n    # Hide from flex types\n    hidden: true\n    templates:\n      collection:\n        # Lookup for the template layout files for collections of objects\n        paths:\n          - 'flex/{TYPE}/collection/{LAYOUT}{EXT}'\n      object:\n        # Lookup for the template layout files for objects\n        paths:\n          - 'flex/{TYPE}/object/{LAYOUT}{EXT}'\n      defaults:\n        # Default template {TYPE}; overridden by filename of this blueprint if template folder exists\n        type: user-groups\n        # Default template {LAYOUT}; can be overridden in render calls (usually Twig in templates)\n        layout: default\n\n  # Data Configuration\n  data:\n    object: 'Grav\\Common\\Flex\\Types\\UserGroups\\UserGroupObject'\n    collection: 'Grav\\Common\\Flex\\Types\\UserGroups\\UserGroupCollection'\n    index: 'Grav\\Common\\Flex\\Types\\UserGroups\\UserGroupIndex'\n    storage:\n      class: 'Grav\\Framework\\Flex\\Storage\\SimpleStorage'\n      options:\n        formatter:\n          class: 'Grav\\Framework\\File\\Formatter\\YamlFormatter'\n        folder: 'user://config/groups.yaml'\n        key: groupname\n    search:\n      options:\n        contains: 1\n      fields:\n        - key\n        - groupname\n        - readableName\n        - description\n\nblueprints:\n  configure:\n    fields:\n      import@:\n        type: configure/compat\n        context: blueprints://flex\n"
  },
  {
    "path": "system/blueprints/pages/default.yaml",
    "content": "title: PLUGIN_ADMIN.DEFAULT\n\nrules:\n  slug:\n    pattern: '[a-zA-Zа-яA-Я0-9_\\-]+'\n    min: 1\n    max: 200\n\nform:\n  validation: loose\n\n  fields:\n\n    tabs:\n      type: tabs\n      active: 1\n\n      fields:\n        content:\n          type: tab\n          title: PLUGIN_ADMIN.CONTENT\n\n          fields:\n            xss_check:\n              type: xss\n\n            header.title:\n              type: text\n              autofocus: true\n              style: vertical\n              label: PLUGIN_ADMIN.TITLE\n\n            content:\n                type: markdown\n                validate:\n                  type: textarea\n\n            header.media_order:\n              type: pagemedia\n              label: PLUGIN_ADMIN.PAGE_MEDIA\n\n        options:\n          type: tab\n          title: PLUGIN_ADMIN.OPTIONS\n\n          fields:\n\n            publishing:\n              type: section\n              title: PLUGIN_ADMIN.PUBLISHING\n              underline: true\n\n              fields:\n                header.published:\n                  type: toggle\n                  toggleable: true\n                  label: PLUGIN_ADMIN.PUBLISHED\n                  help: PLUGIN_ADMIN.PUBLISHED_HELP\n                  highlight: 1\n                  size: medium\n                  options:\n                    1: PLUGIN_ADMIN.YES\n                    0: PLUGIN_ADMIN.NO\n                  validate:\n                    type: bool\n\n                header.date:\n                  type: datetime\n                  label: PLUGIN_ADMIN.DATE\n                  toggleable: true\n                  help: PLUGIN_ADMIN.DATE_HELP\n\n                header.publish_date:\n                  type: datetime\n                  label: PLUGIN_ADMIN.PUBLISHED_DATE\n                  toggleable: true\n                  help: PLUGIN_ADMIN.PUBLISHED_DATE_HELP\n\n                header.unpublish_date:\n                  type: datetime\n                  label: PLUGIN_ADMIN.UNPUBLISHED_DATE\n                  toggleable: true\n                  help: PLUGIN_ADMIN.UNPUBLISHED_DATE_HELP\n\n                header.metadata:\n                  toggleable: true\n                  type: array\n                  label: PLUGIN_ADMIN.METADATA\n                  help: PLUGIN_ADMIN.METADATA_HELP\n                  placeholder_key: PLUGIN_ADMIN.METADATA_KEY\n                  placeholder_value: PLUGIN_ADMIN.METADATA_VALUE\n\n            taxonomies:\n              type: section\n              title: PLUGIN_ADMIN.TAXONOMIES\n              underline: true\n\n              fields:\n                header.taxonomy:\n                  type: taxonomy\n                  label: PLUGIN_ADMIN.TAXONOMY\n                  multiple: true\n                  validate:\n                    type: array\n\n        advanced:\n          type: tab\n          title: PLUGIN_ADMIN.ADVANCED\n\n          fields:\n            columns:\n              type: columns\n              fields:\n                column1:\n                  type: column\n                  fields:\n\n                    settings:\n                      type: section\n                      title: PLUGIN_ADMIN.SETTINGS\n                      underline: true\n\n                    folder:\n                      type: folder-slug\n                      label: PLUGIN_ADMIN.FOLDER_NAME\n                      validate:\n                        rule: slug\n\n                    route:\n                      type: parents\n                      label: PLUGIN_ADMIN.PARENT\n                      classes: fancy\n\n                    name:\n                      type: select\n                      classes: fancy\n                      label: PLUGIN_ADMIN.PAGE_FILE\n                      help: PLUGIN_ADMIN.PAGE_FILE_HELP\n                      default: default\n                      data-options@: '\\Grav\\Common\\Page\\Pages::pageTypes'\n\n                    header.body_classes:\n                      type: text\n                      label: PLUGIN_ADMIN.BODY_CLASSES\n\n\n                column2:\n                  type: column\n\n                  fields:\n                    order_title:\n                      type: section\n                      title: PLUGIN_ADMIN.ORDERING\n                      underline: true\n\n                    ordering:\n                      type: toggle\n                      label: PLUGIN_ADMIN.FOLDER_NUMERIC_PREFIX\n                      help: PLUGIN_ADMIN.FOLDER_NUMERIC_PREFIX_HELP\n                      highlight: 1\n                      options:\n                        1: PLUGIN_ADMIN.ENABLED\n                        0: PLUGIN_ADMIN.DISABLED\n                      validate:\n                        type: bool\n\n                    order:\n                      type: order\n                      label: PLUGIN_ADMIN.SORTABLE_PAGES\n                      sitemap:\n\n            overrides:\n              type: section\n              title: PLUGIN_ADMIN.OVERRIDES\n              underline: true\n\n              fields:\n\n                header.dateformat:\n                  toggleable: true\n                  type: select\n                  size: medium\n                  selectize:\n                    create: true\n                  label: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT\n                  help: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT_HELP\n                  placeholder: PLUGIN_ADMIN.DEFAULT_DATE_FORMAT_PLACEHOLDER\n                  data-options@: '\\Grav\\Common\\Utils::dateFormats'\n                  validate:\n                    type: string\n\n                header.menu:\n                  type: text\n                  label: PLUGIN_ADMIN.MENU\n                  toggleable: true\n                  help: PLUGIN_ADMIN.MENU_HELP\n\n                header.slug:\n                  type: text\n                  label: PLUGIN_ADMIN.SLUG\n                  toggleable: true\n                  help: PLUGIN_ADMIN.SLUG_HELP\n                  validate:\n                    message: PLUGIN_ADMIN.SLUG_VALIDATE_MESSAGE\n                    rule: slug\n\n                header.redirect:\n                  type: text\n                  label: PLUGIN_ADMIN.REDIRECT\n                  toggleable: true\n                  help: PLUGIN_ADMIN.REDIRECT_HELP\n\n                header.process:\n                  type: checkboxes\n                  label: PLUGIN_ADMIN.PROCESS\n                  toggleable: true\n                  config-default@: system.pages.process\n                  default:\n                    markdown: true\n                    twig: false\n                  options:\n                    markdown: Markdown\n                    twig: Twig\n                  use: keys\n\n                header.twig_first:\n                  type: toggle\n                  toggleable: true\n                  label: PLUGIN_ADMIN.TWIG_FIRST\n                  help: PLUGIN_ADMIN.TWIG_FIRST_HELP\n                  highlight: 0\n                  options:\n                    1: PLUGIN_ADMIN.YES\n                    0: PLUGIN_ADMIN.NO\n                  validate:\n                    type: bool\n\n                header.never_cache_twig:\n                  type: toggle\n                  toggleable: true\n                  label: PLUGIN_ADMIN.NEVER_CACHE_TWIG\n                  help: PLUGIN_ADMIN.NEVER_CACHE_TWIG_HELP\n                  highlight: 0\n                  options:\n                    1: PLUGIN_ADMIN.YES\n                    0: PLUGIN_ADMIN.NO\n                  validate:\n                    type: bool\n\n                header.child_type:\n                  type: select\n                  toggleable: true\n                  label: PLUGIN_ADMIN.DEFAULT_CHILD_TYPE\n                  default: default\n                  placeholder: PLUGIN_ADMIN.USE_GLOBAL\n                  data-options@: '\\Grav\\Common\\Page\\Pages::types'\n\n                header.routable:\n                  type: toggle\n                  toggleable: true\n                  label: PLUGIN_ADMIN.ROUTABLE\n                  help: PLUGIN_ADMIN.ROUTABLE_HELP\n                  highlight: 1\n                  options:\n                    1: PLUGIN_ADMIN.ENABLED\n                    0: PLUGIN_ADMIN.DISABLED\n                  validate:\n                    type: bool\n\n                header.cache_enable:\n                  type: toggle\n                  toggleable: true\n                  label: PLUGIN_ADMIN.CACHING\n                  highlight: 1\n                  options:\n                    1: PLUGIN_ADMIN.ENABLED\n                    0: PLUGIN_ADMIN.DISABLED\n                  validate:\n                    type: bool\n\n                header.visible:\n                  type: toggle\n                  toggleable: true\n                  label: PLUGIN_ADMIN.VISIBLE\n                  help: PLUGIN_ADMIN.VISIBLE_HELP\n                  highlight: 1\n                  options:\n                    1: PLUGIN_ADMIN.ENABLED\n                    0: PLUGIN_ADMIN.DISABLED\n                  validate:\n                    type: bool\n\n                header.debugger:\n                  type: toggle\n                  toggleable: true\n                  label: PLUGIN_ADMIN.DEBUGGER\n                  help: PLUGIN_ADMIN.DEBUGGER_HELP\n                  highlight: 1\n                  options:\n                    1: PLUGIN_ADMIN.ENABLED\n                    0: PLUGIN_ADMIN.DISABLED\n                  validate:\n                    type: bool\n\n                header.template:\n                  type: text\n                  toggleable: true\n                  label: PLUGIN_ADMIN.DISPLAY_TEMPLATE\n\n                header.append_url_extension:\n                  type: text\n                  label: PLUGIN_ADMIN.APPEND_URL_EXT\n                  toggleable: true\n                  help: PLUGIN_ADMIN.APPEND_URL_EXT_HELP\n\n            routes_only:\n              type: section\n              title: PLUGIN_ADMIN.ROUTE_OVERRIDES\n              underline: true\n\n              fields:\n\n                header.redirect_default_route:\n                  type: toggle\n                  toggleable: true\n                  label: PLUGIN_ADMIN.REDIRECT_DEFAULT_ROUTE\n                  help: PLUGIN_ADMIN.REDIRECT_DEFAULT_ROUTE_HELP\n                  config-highlight@: system.pages.redirect_default_route\n                  options:\n                    1: PLUGIN_ADMIN.YES\n                    0: PLUGIN_ADMIN.NO\n                  validate:\n                    type: bool\n\n                header.routes.default:\n                  type: text\n                  toggleable: true\n                  label: PLUGIN_ADMIN.ROUTE_DEFAULT\n\n                header.routes.canonical:\n                  type: text\n                  toggleable: true\n                  label: PLUGIN_ADMIN.ROUTE_CANONICAL\n\n                header.routes.aliases:\n                  type: array\n                  toggleable: true\n                  value_only: true\n                  size: large\n                  label: PLUGIN_ADMIN.ROUTE_ALIASES\n\n\n            admin_only:\n              type: section\n              title: PLUGIN_ADMIN.ADMIN_SPECIFIC_OVERRIDES\n              underline: true\n\n              fields:\n\n                header.admin.children_display_order:\n                  type: select\n                  label: PLUGIN_ADMIN.ADMIN_CHILDREN_DISPLAY_ORDER\n                  help: PLUGIN_ADMIN.ADMIN_CHILDREN_DISPLAY_ORDER_HELP\n                  toggleable: true\n                  classes: fancy\n                  default: 'collection'\n                  options:\n                    'default': 'Ordered by Folder name (default)'\n                    'collection': 'Ordered by Collection definition'\n\n\n                header.order_by:\n                  type: hidden\n\n                header.order_manual:\n                  type: hidden\n                  validate:\n                    type: commalist\n\n                blueprint:\n                  type: blueprint\n"
  },
  {
    "path": "system/blueprints/pages/external.yaml",
    "content": "title: PLUGIN_ADMIN.EXTERNAL\nextends@:\n  type: default\n  context: blueprints://pages\n\nform:\n  validation: loose\n  fields:\n\n    tabs:\n      type: tabs\n      active: 1\n\n      fields:\n\n        content:\n          fields:\n\n            header.title:\n              type: text\n              autofocus: true\n              style: horizontal\n              label: PLUGIN_ADMIN.TITLE\n\n            content:\n              unset@: true\n\n            header.media_order:\n              unset@: true\n\n            header.external_url:\n              type: text\n              label: PLUGIN_ADMIN.EXTERNAL_URL\n              placeholder: https://getgrav.org\n              validate:\n                required: true\n\n        options:\n          fields:\n\n            publishing:\n              fields:\n\n                header.date:\n                  unset@: true\n\n                header.metadata:\n                  unset@: true\n\n            taxonomies:\n              unset@: true\n\n"
  },
  {
    "path": "system/blueprints/pages/modular.yaml",
    "content": "title: PLUGIN_ADMIN.MODULE\nextends@: default\n\nform:\n  fields:\n    tabs:\n      type: tabs\n      active: 1\n\n      fields:\n        content:\n          fields:\n\n            modular_title:\n              type: spacer\n              title: PLUGIN_ADMIN.MODULE_SETUP\n\n            header.content.items:\n              type: text\n              label: PLUGIN_ADMIN.ITEMS\n              default: '@self.modular'\n              size: medium\n\n            header.content.order.by:\n              type: text\n              label: PLUGIN_ADMIN.ORDER_BY\n              placeholder: date\n              help:\n              size: small\n\n            header.content.order.dir:\n              type: text\n              label: PLUGIN_ADMIN.ORDER\n              help: '\"desc\" or \"asc\" are valid values'\n              placeholder: desc\n              size: small\n"
  },
  {
    "path": "system/blueprints/pages/partials/security.yaml",
    "content": "form:\n  fields:\n    _site:\n      type: section\n      title: PLUGIN_ADMIN.PAGE_ACCESS\n      underline: true\n\n      fields:\n\n        header.login.visibility_requires_access:\n          type: toggle\n          toggleable: true\n          label: PLUGIN_ADMIN.PAGE_VISIBILITY_REQUIRES_ACCESS\n          help: PLUGIN_ADMIN.PAGE_VISIBILITY_REQUIRES_ACCESS_HELP\n          highlight: 0\n          options:\n            1: PLUGIN_ADMIN.YES\n            0: PLUGIN_ADMIN.NO\n          validate:\n            type: bool\n\n\n        header.access:\n          type: acl_picker\n          label: PLUGIN_ADMIN.PAGE_ACCESS\n          help: PLUGIN_ADMIN.PAGE_ACCESS_HELP\n          ignore_empty: true\n          data_type: access\n          validate:\n            type: array\n            value_type: bool\n\n    _admin:\n      security@: {or: [admin.super, admin.configuration.pages]}\n      type: section\n      title: PLUGIN_ADMIN.PAGE PERMISSIONS\n      underline: true\n\n      fields:\n\n        header.permissions.inherit:\n          type: toggle\n          toggleable: true\n          label: PLUGIN_ADMIN.PAGE_INHERIT_PERMISSIONS\n          help: PLUGIN_ADMIN.PAGE_INHERIT_PERMISSIONS_HELP\n          highlight: 1\n          options:\n            1: PLUGIN_ADMIN.YES\n            0: PLUGIN_ADMIN.NO\n          validate:\n            type: bool\n\n        header.permissions.authors:\n          type: array\n          toggleable: true\n          value_only: true\n          placeholder_value: PLUGIN_ADMIN.USERNAME\n          label: PLUGIN_ADMIN.PAGE_AUTHORS\n          help: PLUGIN_ADMIN.PAGE_AUTHORS_HELP\n\n        header.permissions.groups:\n          ignore@: true\n          type: acl_picker\n          label: PLUGIN_ADMIN.PAGE_GROUPS\n          help: PLUGIN_ADMIN.PAGE_GROUPS_HELP\n          ignore_empty: true\n          data_type: permissions\n"
  },
  {
    "path": "system/blueprints/pages/root.yaml",
    "content": "title: PLUGIN_ADMIN.ROOT\n\nrules:\n  slug:\n    pattern: '[a-zA-Zа-яA-Я0-9_\\-]+'\n    min: 1\n    max: 200\n\nform:\n  validation: loose\n\n  fields:\n\n    tabs:\n      type: tabs\n      active: 1\n"
  },
  {
    "path": "system/blueprints/user/account.yaml",
    "content": "title: Account\nform:\n    validation: loose\n\n    fields:\n\n        info:\n            type: userinfo\n            size: large\n\n        avatar:\n            type: file\n            size: large\n            destination: 'account://avatars'\n            multiple: false\n            random_name: true\n\n        multiavatar_only:\n          type: conditional\n          condition: config.system.accounts.avatar == 'multiavatar'\n          fields:\n            avatar_hash:\n                type: text\n                label: ''\n                placeholder: 'e.g. dceaadcfda491f4e45'\n                description: PLUGIN_ADMIN.AVATAR_HASH\n                size: large\n\n        content:\n            type: section\n            title: PLUGIN_ADMIN.ACCOUNT\n            underline: true\n\n        username:\n            type: text\n            size: large\n            label: PLUGIN_ADMIN.USERNAME\n            disabled: true\n            readonly: true\n\n        email:\n            type: email\n            size: large\n            label: PLUGIN_ADMIN.EMAIL\n            validate:\n              type: email\n              message: PLUGIN_ADMIN.EMAIL_VALIDATION_MESSAGE\n              required: true\n\n        password:\n            type: password\n            size: large\n            label: PLUGIN_ADMIN.PASSWORD\n            autocomplete: new-password\n            validate:\n              required: false\n              message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE\n              config-pattern@: system.pwd_regex\n\n        fullname:\n            type: text\n            size: large\n            label: PLUGIN_ADMIN.FULL_NAME\n            validate:\n              required: true\n\n        title:\n            type: text\n            size: large\n            label: PLUGIN_ADMIN.TITLE\n\n        language:\n            type: select\n            label: PLUGIN_ADMIN.LANGUAGE\n            size: medium\n            classes: fancy\n            data-options@: '\\Grav\\Plugin\\Admin\\Admin::adminLanguages'\n            default: 'en'\n            help: PLUGIN_ADMIN.LANGUAGE_HELP\n\n        content_editor:\n            type: select\n            label: PLUGIN_ADMIN.CONTENT_EDITOR\n            size: medium\n            classes: fancy\n            data-options@: 'Grav\\Plugin\\Admin\\Admin::contentEditor'\n            default: 'default'\n            help: PLUGIN_ADMIN.CONTENT_EDITOR_HELP\n\n        twofa_check:\n            type: conditional\n            condition: config.plugins.admin.twofa_enabled\n\n            fields:\n\n                twofa:\n                    title: PLUGIN_ADMIN.2FA_TITLE\n                    type: section\n                    underline: true\n\n                twofa_enabled:\n                    type: toggle\n                    label: PLUGIN_ADMIN.2FA_ENABLED\n                    classes: twofa-toggle\n                    highlight: 1\n                    default: 0\n                    options:\n                      1: PLUGIN_ADMIN.YES\n                      0: PLUGIN_ADMIN.NO\n                    validate:\n                      type: bool\n\n\n                twofa_secret:\n                    type: 2fa_secret\n                    outerclasses: 'twofa-secret'\n                    markdown: true\n                    label: PLUGIN_ADMIN.2FA_SECRET\n                    sublabel: PLUGIN_ADMIN.2FA_SECRET_HELP\n\n                yubikey_id:\n                    type: text\n                    label: PLUGIN_ADMIN.YUBIKEY_ID\n                    description: PLUGIN_ADMIN.YUBIKEY_HELP\n                    size: small\n                    maxlength: 12\n\n\n\n        security:\n            security@: admin.super\n            title: PLUGIN_ADMIN.ACCESS_LEVELS\n            type: section\n            underline: true\n\n            fields:\n                groups:\n                    security@: admin.super\n                    type: select\n                    multiple: true\n                    size: large\n                    label: PLUGIN_ADMIN.GROUPS\n                    data-options@: '\\Grav\\Common\\User\\Group::groupNames'\n                    classes: fancy\n                    help: PLUGIN_ADMIN.GROUPS_HELP\n                    validate:\n                        type: commalist\n\n                access:\n                    security@: admin.super\n                    type: permissions\n                    check_authorize: true\n                    label: PLUGIN_ADMIN.PERMISSIONS\n                    ignore_empty: true\n                    validate:\n                        type: array\n                        value_type: bool\n"
  },
  {
    "path": "system/blueprints/user/account_new.yaml",
    "content": "title: PLUGIN_ADMIN.ADD_ACCOUNT\n\nform:\n  validation: loose\n  fields:\n\n    content:\n      type: section\n      title: PLUGIN_ADMIN.ADD_ACCOUNT\n\n    username:\n      type: text\n      label: PLUGIN_ADMIN.USERNAME\n      help: PLUGIN_ADMIN.USERNAME_HELP\n      unset-disabled@: true\n      unset-readonly@: true\n      validate:\n        required: true\n"
  },
  {
    "path": "system/blueprints/user/group.yaml",
    "content": "title: Group\nrules:\n  slug:\n    pattern: '[a-zA-Zа-яA-Я0-9_\\-]+'\n    min: 1\n    max: 200\n\nform:\n  validation: loose\n\n  fields:\n    groupname:\n      type: text\n      size: large\n      label: PLUGIN_ADMIN.GROUP_NAME\n      flex-disabled@: exists\n      flex-readonly@: exists\n      validate:\n        required: true\n        rule: slug\n\n    readableName:\n      type: text\n      size: large\n      label: PLUGIN_ADMIN.DISPLAY_NAME\n\n    description:\n      type: text\n      size: large\n      label: PLUGIN_ADMIN.DESCRIPTION\n\n    icon:\n      type: text\n      size: small\n      label: PLUGIN_ADMIN.ICON\n\n    enabled:\n      type: toggle\n      label: PLUGIN_ADMIN.ENABLED\n      highlight: 1\n      default: 1\n      options:\n        1: PLUGIN_ADMIN.YES\n        0: PLUGIN_ADMIN.NO\n      validate:\n        type: bool\n\n    access:\n      type: permissions\n      check_authorize: false\n      label: PLUGIN_ADMIN.PERMISSIONS\n      ignore_empty: true\n      validate:\n        type: array\n        value_type: bool\n"
  },
  {
    "path": "system/blueprints/user/group_new.yaml",
    "content": "title: PLUGIN_ADMIN_PRO.ADD_GROUP\n\nrules:\n  slug:\n    pattern: '[a-zA-Zа-яA-Я0-9_\\-]+'\n    min: 1\n    max: 200\n\nform:\n  validation: loose\n  fields:\n\n    content:\n      type: section\n      title: PLUGIN_ADMIN_PRO.ADD_GROUP\n\n    groupname:\n      type: text\n      label: PLUGIN_ADMIN_PRO.GROUP_NAME\n      help: PLUGIN_ADMIN_PRO.GROUP_NAME_HELP\n      validate:\n        required: true\n        rule: slug\n"
  },
  {
    "path": "system/config/backups.yaml",
    "content": "purge:\n    trigger: space\n    max_backups_count: 25\n    max_backups_space: 5\n    max_backups_time: 365\n\nprofiles:\n  -\n    name: 'Default Site Backup'\n    root: '/'\n    schedule: false\n    schedule_at: '0 3 * * *'\n    exclude_paths: \"/backup\\r\\n/cache\\r\\n/images\\r\\n/logs\\r\\n/tmp\"\n    exclude_files: \".DS_Store\\r\\n.git\\r\\n.svn\\r\\n.hg\\r\\n.idea\\r\\n.vscode\\r\\nnode_modules\"\n\n"
  },
  {
    "path": "system/config/media.yaml",
    "content": "types:\n  defaults:\n    type: file\n    thumb: media/thumb.png\n    mime: application/octet-stream\n    image:\n      filters:\n        default:\n          - enableProgressive\n\n  jpg:\n    type: image\n    thumb: media/thumb-jpg.png\n    mime: image/jpeg\n  jpe:\n    type: image\n    thumb: media/thumb-jpg.png\n    mime: image/jpeg\n  jpeg:\n    type: image\n    thumb: media/thumb-jpg.png\n    mime: image/jpeg\n  png:\n    type: image\n    thumb: media/thumb-png.png\n    mime: image/png\n  webp:\n    type: image\n    thumb: media/thumb-webp.png\n    mime: image/webp\n  avif:\n    type: image\n    thumb: media/thumb.png\n    mime: image/avif\n  gif:\n    type: animated\n    thumb: media/thumb-gif.png\n    mime: image/gif\n  svg:\n    type: vector\n    thumb: media/thumb-svg.png\n    mime: image/svg+xml\n  mp4:\n    type: video\n    thumb: media/thumb-mp4.png\n    mime: video/mp4\n  mov:\n    type: video\n    thumb: media/thumb-mov.png\n    mime: video/quicktime\n  m4v:\n    type: video\n    thumb: media/thumb-m4v.png\n    mime: video/x-m4v\n  swf:\n    type: video\n    thumb: media/thumb-swf.png\n    mime: video/x-flv\n  flv:\n    type: video\n    thumb: media/thumb-flv.png\n    mime: video/x-flv\n  webm:\n    type: video\n    thumb: media/thumb-webm.png\n    mime: video/webm\n  ogv:\n    type: video\n    thumb: media/thumb-ogg.png\n    mime: video/ogg\n  mp3:\n    type: audio\n    thumb: media/thumb-mp3.png\n    mime: audio/mp3\n  ogg:\n    type: audio\n    thumb: media/thumb-ogg.png\n    mime: audio/ogg\n  wma:\n    type: audio\n    thumb: media/thumb-wma.png\n    mime: audio/wma\n  m4a:\n    type: audio\n    thumb: media/thumb-m4a.png\n    mime: audio/m4a\n  wav:\n    type: audio\n    thumb: media/thumb-wav.png\n    mime: audio/wav\n  aiff:\n    type: audio\n    thumb: media/thumb-aif.png\n    mime: audio/aiff\n  aif:\n    type: audio\n    thumb: media/thumb-aif.png\n    mime: audio/aiff\n  txt:\n    type: file\n    thumb: media/thumb-txt.png\n    mime: text/plain\n  xml:\n    type: file\n    thumb: media/thumb-xml.png\n    mime: application/xml\n  doc:\n    type: file\n    thumb: media/thumb-doc.png\n    mime: application/msword\n  docx:\n    type: file\n    thumb: media/thumb-docx.png\n    mime: application/vnd.openxmlformats-officedocument.wordprocessingml.document\n  xls:\n    type: file\n    thumb: media/thumb-xls.png\n    mime: application/vnd.ms-excel\n  xlsx:\n    type: file\n    thumb: media/thumb-xlsx.png\n    mime: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\n  ppt:\n    type: file\n    thumb: media/thumb-ppt.png\n    mime: application/vnd.ms-powerpoint\n  pptx:\n    type: file\n    thumb: media/thumb-pptx.png\n    mime: application/vnd.openxmlformats-officedocument.presentationml.presentation\n  pps:\n    type: file\n    thumb: media/thumb-pps.png\n    mime: application/vnd.ms-powerpoint\n  rtf:\n    type: file\n    thumb: media/thumb-rtf.png\n    mime: application/rtf\n  bmp:\n    type: file\n    thumb: media/thumb-bmp.png\n    mime: image/bmp\n  tiff:\n    type: file\n    thumb: media/thumb-tiff.png\n    mime: image/tiff\n  mpeg:\n    type: file\n    thumb: media/thumb-mpg.png\n    mime: video/mpeg\n  mpg:\n    type: file\n    thumb: media/thumb-mpg.png\n    mime: video/mpeg\n  mpe:\n    type: file\n    thumb: media/thumb-mpe.png\n    mime: video/mpeg\n  avi:\n    type: file\n    thumb: media/thumb-avi.png\n    mime: video/msvideo\n  wmv:\n    type: file\n    thumb: media/thumb-wmv.png\n    mime: video/x-ms-wmv\n  html:\n    type: file\n    thumb: media/thumb-html.png\n    mime: text/html\n  htm:\n    type: file\n    thumb: media/thumb-html.png\n    mime: text/html\n  ics:\n    type: iCal\n    thumb: media/thumb-ics.png\n    mime: text/calendar\n  pdf:\n    type: file\n    thumb: media/thumb-pdf.png\n    mime: application/pdf\n  ai:\n    type: file\n    thumb: media/thumb-ai.png\n    mime: image/ai\n  psd:\n    type: file\n    thumb: media/thumb-psd.png\n    mime: image/psd\n  zip:\n    type: file\n    thumb: media/thumb-zip.png\n    mime: application/zip\n  7z:\n    type: file\n    thumb: media/thumb-7z.png\n    mime: application/x-7z-compressed\n  gz:\n    type: file\n    thumb: media/thumb-gz.png\n    mime: application/x-gzip\n  tar:\n    type: file\n    thumb: media/thumb-tar.png\n    mime: application/x-tar\n  css:\n    type: file\n    thumb: media/thumb-css.png\n    mime: text/css\n  js:\n    type: file\n    thumb: media/thumb-js.png\n    mime: text/javascript\n  json:\n    type: file\n    thumb: media/thumb-json.png\n    mime: application/json\n  vcf:\n    type: file\n    thumb: media/thumb-vcf.png\n    mime: text/x-vcard\n\n"
  },
  {
    "path": "system/config/mime.yaml",
    "content": "types:\n  '123':\n  - application/vnd.lotus-1-2-3\n  wof:\n  - application/font-woff\n  php:\n  - application/php\n  - application/x-httpd-php\n  - application/x-httpd-php-source\n  - application/x-php\n  - text/php\n  - text/x-php\n  otf:\n  - application/x-font-otf\n  - font/otf\n  ttf:\n  - application/x-font-ttf\n  - font/ttf\n  ttc:\n  - application/x-font-ttf\n  - font/collection\n  zip:\n  - application/x-gzip\n  - application/zip\n  - application/x-zip-compressed\n  amr:\n  - audio/amr\n  mp3:\n  - audio/mpeg\n  mpga:\n  - audio/mpeg\n  mp2:\n  - audio/mpeg\n  mp2a:\n  - audio/mpeg\n  m2a:\n  - audio/mpeg\n  m3a:\n  - audio/mpeg\n  jpg:\n  - image/jpeg\n  jpeg:\n  - image/jpeg\n  jpe:\n  - image/jpeg\n  bmp:\n  - image/x-ms-bmp\n  - image/bmp\n  ez:\n  - application/andrew-inset\n  aw:\n  - application/applixware\n  atom:\n  - application/atom+xml\n  atomcat:\n  - application/atomcat+xml\n  atomsvc:\n  - application/atomsvc+xml\n  ccxml:\n  - application/ccxml+xml\n  cdmia:\n  - application/cdmi-capability\n  cdmic:\n  - application/cdmi-container\n  cdmid:\n  - application/cdmi-domain\n  cdmio:\n  - application/cdmi-object\n  cdmiq:\n  - application/cdmi-queue\n  cu:\n  - application/cu-seeme\n  davmount:\n  - application/davmount+xml\n  dbk:\n  - application/docbook+xml\n  dssc:\n  - application/dssc+der\n  xdssc:\n  - application/dssc+xml\n  ecma:\n  - application/ecmascript\n  emma:\n  - application/emma+xml\n  epub:\n  - application/epub+zip\n  exi:\n  - application/exi\n  pfr:\n  - application/font-tdpfr\n  gml:\n  - application/gml+xml\n  gpx:\n  - application/gpx+xml\n  gxf:\n  - application/gxf\n  stk:\n  - application/hyperstudio\n  ink:\n  - application/inkml+xml\n  inkml:\n  - application/inkml+xml\n  ipfix:\n  - application/ipfix\n  jar:\n  - application/java-archive\n  ser:\n  - application/java-serialized-object\n  class:\n  - application/java-vm\n  js:\n  - application/javascript\n  json:\n  - application/json\n  jsonml:\n  - application/jsonml+json\n  lostxml:\n  - application/lost+xml\n  hqx:\n  - application/mac-binhex40\n  cpt:\n  - application/mac-compactpro\n  mads:\n  - application/mads+xml\n  mrc:\n  - application/marc\n  mrcx:\n  - application/marcxml+xml\n  ma:\n  - application/mathematica\n  nb:\n  - application/mathematica\n  mb:\n  - application/mathematica\n  mathml:\n  - application/mathml+xml\n  mbox:\n  - application/mbox\n  mscml:\n  - application/mediaservercontrol+xml\n  metalink:\n  - application/metalink+xml\n  meta4:\n  - application/metalink4+xml\n  mets:\n  - application/mets+xml\n  mods:\n  - application/mods+xml\n  m21:\n  - application/mp21\n  mp21:\n  - application/mp21\n  mp4s:\n  - application/mp4\n  doc:\n  - application/msword\n  dot:\n  - application/msword\n  mxf:\n  - application/mxf\n  bin:\n  - application/octet-stream\n  dms:\n  - application/octet-stream\n  lrf:\n  - application/octet-stream\n  mar:\n  - application/octet-stream\n  so:\n  - application/octet-stream\n  dist:\n  - application/octet-stream\n  distz:\n  - application/octet-stream\n  pkg:\n  - application/octet-stream\n  bpk:\n  - application/octet-stream\n  dump:\n  - application/octet-stream\n  elc:\n  - application/octet-stream\n  deploy:\n  - application/octet-stream\n  oda:\n  - application/oda\n  opf:\n  - application/oebps-package+xml\n  ogx:\n  - application/ogg\n  omdoc:\n  - application/omdoc+xml\n  onetoc:\n  - application/onenote\n  onetoc2:\n  - application/onenote\n  onetmp:\n  - application/onenote\n  onepkg:\n  - application/onenote\n  oxps:\n  - application/oxps\n  xer:\n  - application/patch-ops-error+xml\n  pdf:\n  - application/pdf\n  pgp:\n  - application/pgp-encrypted\n  asc:\n  - application/pgp-signature\n  sig:\n  - application/pgp-signature\n  prf:\n  - application/pics-rules\n  p10:\n  - application/pkcs10\n  p7m:\n  - application/pkcs7-mime\n  p7c:\n  - application/pkcs7-mime\n  p7s:\n  - application/pkcs7-signature\n  p8:\n  - application/pkcs8\n  ac:\n  - application/pkix-attr-cert\n  cer:\n  - application/pkix-cert\n  crl:\n  - application/pkix-crl\n  pkipath:\n  - application/pkix-pkipath\n  pki:\n  - application/pkixcmp\n  pls:\n  - application/pls+xml\n  ai:\n  - application/postscript\n  eps:\n  - application/postscript\n  ps:\n  - application/postscript\n  cww:\n  - application/prs.cww\n  pskcxml:\n  - application/pskc+xml\n  rdf:\n  - application/rdf+xml\n  rif:\n  - application/reginfo+xml\n  rnc:\n  - application/relax-ng-compact-syntax\n  rl:\n  - application/resource-lists+xml\n  rld:\n  - application/resource-lists-diff+xml\n  rs:\n  - application/rls-services+xml\n  gbr:\n  - application/rpki-ghostbusters\n  mft:\n  - application/rpki-manifest\n  roa:\n  - application/rpki-roa\n  rsd:\n  - application/rsd+xml\n  rss:\n  - application/rss+xml\n  rtf:\n  - application/rtf\n  sbml:\n  - application/sbml+xml\n  scq:\n  - application/scvp-cv-request\n  scs:\n  - application/scvp-cv-response\n  spq:\n  - application/scvp-vp-request\n  spp:\n  - application/scvp-vp-response\n  sdp:\n  - application/sdp\n  setpay:\n  - application/set-payment-initiation\n  setreg:\n  - application/set-registration-initiation\n  shf:\n  - application/shf+xml\n  smi:\n  - application/smil+xml\n  smil:\n  - application/smil+xml\n  rq:\n  - application/sparql-query\n  srx:\n  - application/sparql-results+xml\n  gram:\n  - application/srgs\n  grxml:\n  - application/srgs+xml\n  sru:\n  - application/sru+xml\n  ssdl:\n  - application/ssdl+xml\n  ssml:\n  - application/ssml+xml\n  tei:\n  - application/tei+xml\n  teicorpus:\n  - application/tei+xml\n  tfi:\n  - application/thraud+xml\n  tsd:\n  - application/timestamped-data\n  plb:\n  - application/vnd.3gpp.pic-bw-large\n  psb:\n  - application/vnd.3gpp.pic-bw-small\n  pvb:\n  - application/vnd.3gpp.pic-bw-var\n  tcap:\n  - application/vnd.3gpp2.tcap\n  pwn:\n  - application/vnd.3m.post-it-notes\n  aso:\n  - application/vnd.accpac.simply.aso\n  imp:\n  - application/vnd.accpac.simply.imp\n  acu:\n  - application/vnd.acucobol\n  atc:\n  - application/vnd.acucorp\n  acutc:\n  - application/vnd.acucorp\n  air:\n  - application/vnd.adobe.air-application-installer-package+zip\n  fcdt:\n  - application/vnd.adobe.formscentral.fcdt\n  fxp:\n  - application/vnd.adobe.fxp\n  fxpl:\n  - application/vnd.adobe.fxp\n  xdp:\n  - application/vnd.adobe.xdp+xml\n  xfdf:\n  - application/vnd.adobe.xfdf\n  ahead:\n  - application/vnd.ahead.space\n  azf:\n  - application/vnd.airzip.filesecure.azf\n  azs:\n  - application/vnd.airzip.filesecure.azs\n  azw:\n  - application/vnd.amazon.ebook\n  acc:\n  - application/vnd.americandynamics.acc\n  ami:\n  - application/vnd.amiga.ami\n  apk:\n  - application/vnd.android.package-archive\n  cii:\n  - application/vnd.anser-web-certificate-issue-initiation\n  fti:\n  - application/vnd.anser-web-funds-transfer-initiation\n  atx:\n  - application/vnd.antix.game-component\n  mpkg:\n  - application/vnd.apple.installer+xml\n  m3u8:\n  - application/vnd.apple.mpegurl\n  swi:\n  - application/vnd.aristanetworks.swi\n  iota:\n  - application/vnd.astraea-software.iota\n  aep:\n  - application/vnd.audiograph\n  mpm:\n  - application/vnd.blueice.multipass\n  bmi:\n  - application/vnd.bmi\n  rep:\n  - application/vnd.businessobjects\n  cdxml:\n  - application/vnd.chemdraw+xml\n  mmd:\n  - application/vnd.chipnuts.karaoke-mmd\n  cdy:\n  - application/vnd.cinderella\n  cla:\n  - application/vnd.claymore\n  rp9:\n  - application/vnd.cloanto.rp9\n  c4g:\n  - application/vnd.clonk.c4group\n  c4d:\n  - application/vnd.clonk.c4group\n  c4f:\n  - application/vnd.clonk.c4group\n  c4p:\n  - application/vnd.clonk.c4group\n  c4u:\n  - application/vnd.clonk.c4group\n  c11amc:\n  - application/vnd.cluetrust.cartomobile-config\n  c11amz:\n  - application/vnd.cluetrust.cartomobile-config-pkg\n  csp:\n  - application/vnd.commonspace\n  cdbcmsg:\n  - application/vnd.contact.cmsg\n  cmc:\n  - application/vnd.cosmocaller\n  clkx:\n  - application/vnd.crick.clicker\n  clkk:\n  - application/vnd.crick.clicker.keyboard\n  clkp:\n  - application/vnd.crick.clicker.palette\n  clkt:\n  - application/vnd.crick.clicker.template\n  clkw:\n  - application/vnd.crick.clicker.wordbank\n  wbs:\n  - application/vnd.criticaltools.wbs+xml\n  pml:\n  - application/vnd.ctc-posml\n  ppd:\n  - application/vnd.cups-ppd\n  car:\n  - application/vnd.curl.car\n  pcurl:\n  - application/vnd.curl.pcurl\n  dart:\n  - application/vnd.dart\n  rdz:\n  - application/vnd.data-vision.rdz\n  uvf:\n  - application/vnd.dece.data\n  uvvf:\n  - application/vnd.dece.data\n  uvd:\n  - application/vnd.dece.data\n  uvvd:\n  - application/vnd.dece.data\n  uvt:\n  - application/vnd.dece.ttml+xml\n  uvvt:\n  - application/vnd.dece.ttml+xml\n  uvx:\n  - application/vnd.dece.unspecified\n  uvvx:\n  - application/vnd.dece.unspecified\n  uvz:\n  - application/vnd.dece.zip\n  uvvz:\n  - application/vnd.dece.zip\n  fe_launch:\n  - application/vnd.denovo.fcselayout-link\n  dna:\n  - application/vnd.dna\n  mlp:\n  - application/vnd.dolby.mlp\n  dpg:\n  - application/vnd.dpgraph\n  dfac:\n  - application/vnd.dreamfactory\n  kpxx:\n  - application/vnd.ds-keypoint\n  ait:\n  - application/vnd.dvb.ait\n  svc:\n  - application/vnd.dvb.service\n  geo:\n  - application/vnd.dynageo\n  mag:\n  - application/vnd.ecowin.chart\n  nml:\n  - application/vnd.enliven\n  esf:\n  - application/vnd.epson.esf\n  msf:\n  - application/vnd.epson.msf\n  qam:\n  - application/vnd.epson.quickanime\n  slt:\n  - application/vnd.epson.salt\n  ssf:\n  - application/vnd.epson.ssf\n  es3:\n  - application/vnd.eszigno3+xml\n  et3:\n  - application/vnd.eszigno3+xml\n  ez2:\n  - application/vnd.ezpix-album\n  ez3:\n  - application/vnd.ezpix-package\n  fdf:\n  - application/vnd.fdf\n  mseed:\n  - application/vnd.fdsn.mseed\n  seed:\n  - application/vnd.fdsn.seed\n  dataless:\n  - application/vnd.fdsn.seed\n  gph:\n  - application/vnd.flographit\n  ftc:\n  - application/vnd.fluxtime.clip\n  fm:\n  - application/vnd.framemaker\n  frame:\n  - application/vnd.framemaker\n  maker:\n  - application/vnd.framemaker\n  book:\n  - application/vnd.framemaker\n  fnc:\n  - application/vnd.frogans.fnc\n  ltf:\n  - application/vnd.frogans.ltf\n  fsc:\n  - application/vnd.fsc.weblaunch\n  oas:\n  - application/vnd.fujitsu.oasys\n  oa2:\n  - application/vnd.fujitsu.oasys2\n  oa3:\n  - application/vnd.fujitsu.oasys3\n  fg5:\n  - application/vnd.fujitsu.oasysgp\n  bh2:\n  - application/vnd.fujitsu.oasysprs\n  ddd:\n  - application/vnd.fujixerox.ddd\n  xdw:\n  - application/vnd.fujixerox.docuworks\n  xbd:\n  - application/vnd.fujixerox.docuworks.binder\n  fzs:\n  - application/vnd.fuzzysheet\n  txd:\n  - application/vnd.genomatix.tuxedo\n  ggb:\n  - application/vnd.geogebra.file\n  ggt:\n  - application/vnd.geogebra.tool\n  gex:\n  - application/vnd.geometry-explorer\n  gre:\n  - application/vnd.geometry-explorer\n  gxt:\n  - application/vnd.geonext\n  g2w:\n  - application/vnd.geoplan\n  g3w:\n  - application/vnd.geospace\n  gmx:\n  - application/vnd.gmx\n  kml:\n  - application/vnd.google-earth.kml+xml\n  kmz:\n  - application/vnd.google-earth.kmz\n  gqf:\n  - application/vnd.grafeq\n  gqs:\n  - application/vnd.grafeq\n  gac:\n  - application/vnd.groove-account\n  ghf:\n  - application/vnd.groove-help\n  gim:\n  - application/vnd.groove-identity-message\n  grv:\n  - application/vnd.groove-injector\n  gtm:\n  - application/vnd.groove-tool-message\n  tpl:\n  - application/vnd.groove-tool-template\n  vcg:\n  - application/vnd.groove-vcard\n  hal:\n  - application/vnd.hal+xml\n  zmm:\n  - application/vnd.handheld-entertainment+xml\n  hbci:\n  - application/vnd.hbci\n  les:\n  - application/vnd.hhe.lesson-player\n  hpgl:\n  - application/vnd.hp-hpgl\n  hpid:\n  - application/vnd.hp-hpid\n  hps:\n  - application/vnd.hp-hps\n  jlt:\n  - application/vnd.hp-jlyt\n  pcl:\n  - application/vnd.hp-pcl\n  pclxl:\n  - application/vnd.hp-pclxl\n  sfd-hdstx:\n  - application/vnd.hydrostatix.sof-data\n  mpy:\n  - application/vnd.ibm.minipay\n  afp:\n  - application/vnd.ibm.modcap\n  listafp:\n  - application/vnd.ibm.modcap\n  list3820:\n  - application/vnd.ibm.modcap\n  irm:\n  - application/vnd.ibm.rights-management\n  sc:\n  - application/vnd.ibm.secure-container\n  icc:\n  - application/vnd.iccprofile\n  icm:\n  - application/vnd.iccprofile\n  igl:\n  - application/vnd.igloader\n  ivp:\n  - application/vnd.immervision-ivp\n  ivu:\n  - application/vnd.immervision-ivu\n  igm:\n  - application/vnd.insors.igm\n  xpw:\n  - application/vnd.intercon.formnet\n  xpx:\n  - application/vnd.intercon.formnet\n  i2g:\n  - application/vnd.intergeo\n  qbo:\n  - application/vnd.intu.qbo\n  qfx:\n  - application/vnd.intu.qfx\n  rcprofile:\n  - application/vnd.ipunplugged.rcprofile\n  irp:\n  - application/vnd.irepository.package+xml\n  xpr:\n  - application/vnd.is-xpr\n  fcs:\n  - application/vnd.isac.fcs\n  jam:\n  - application/vnd.jam\n  rms:\n  - application/vnd.jcp.javame.midlet-rms\n  jisp:\n  - application/vnd.jisp\n  joda:\n  - application/vnd.joost.joda-archive\n  ktz:\n  - application/vnd.kahootz\n  ktr:\n  - application/vnd.kahootz\n  karbon:\n  - application/vnd.kde.karbon\n  chrt:\n  - application/vnd.kde.kchart\n  kfo:\n  - application/vnd.kde.kformula\n  flw:\n  - application/vnd.kde.kivio\n  kon:\n  - application/vnd.kde.kontour\n  kpr:\n  - application/vnd.kde.kpresenter\n  kpt:\n  - application/vnd.kde.kpresenter\n  ksp:\n  - application/vnd.kde.kspread\n  kwd:\n  - application/vnd.kde.kword\n  kwt:\n  - application/vnd.kde.kword\n  htke:\n  - application/vnd.kenameaapp\n  kia:\n  - application/vnd.kidspiration\n  kne:\n  - application/vnd.kinar\n  knp:\n  - application/vnd.kinar\n  skp:\n  - application/vnd.koan\n  skd:\n  - application/vnd.koan\n  skt:\n  - application/vnd.koan\n  skm:\n  - application/vnd.koan\n  sse:\n  - application/vnd.kodak-descriptor\n  lasxml:\n  - application/vnd.las.las+xml\n  lbd:\n  - application/vnd.llamagraphics.life-balance.desktop\n  lbe:\n  - application/vnd.llamagraphics.life-balance.exchange+xml\n  apr:\n  - application/vnd.lotus-approach\n  pre:\n  - application/vnd.lotus-freelance\n  nsf:\n  - application/vnd.lotus-notes\n  org:\n  - application/vnd.lotus-organizer\n  scm:\n  - application/vnd.lotus-screencam\n  lwp:\n  - application/vnd.lotus-wordpro\n  portpkg:\n  - application/vnd.macports.portpkg\n  mcd:\n  - application/vnd.mcd\n  mc1:\n  - application/vnd.medcalcdata\n  cdkey:\n  - application/vnd.mediastation.cdkey\n  mwf:\n  - application/vnd.mfer\n  mfm:\n  - application/vnd.mfmp\n  flo:\n  - application/vnd.micrografx.flo\n  igx:\n  - application/vnd.micrografx.igx\n  mif:\n  - application/vnd.mif\n  daf:\n  - application/vnd.mobius.daf\n  dis:\n  - application/vnd.mobius.dis\n  mbk:\n  - application/vnd.mobius.mbk\n  mqy:\n  - application/vnd.mobius.mqy\n  msl:\n  - application/vnd.mobius.msl\n  plc:\n  - application/vnd.mobius.plc\n  txf:\n  - application/vnd.mobius.txf\n  mpn:\n  - application/vnd.mophun.application\n  mpc:\n  - application/vnd.mophun.certificate\n  xul:\n  - application/vnd.mozilla.xul+xml\n  cil:\n  - application/vnd.ms-artgalry\n  cab:\n  - application/vnd.ms-cab-compressed\n  xls:\n  - application/vnd.ms-excel\n  xlm:\n  - application/vnd.ms-excel\n  xla:\n  - application/vnd.ms-excel\n  xlc:\n  - application/vnd.ms-excel\n  xlt:\n  - application/vnd.ms-excel\n  xlw:\n  - application/vnd.ms-excel\n  xlam:\n  - application/vnd.ms-excel.addin.macroenabled.12\n  xlsb:\n  - application/vnd.ms-excel.sheet.binary.macroenabled.12\n  xlsm:\n  - application/vnd.ms-excel.sheet.macroenabled.12\n  xltm:\n  - application/vnd.ms-excel.template.macroenabled.12\n  eot:\n  - application/vnd.ms-fontobject\n  chm:\n  - application/vnd.ms-htmlhelp\n  ims:\n  - application/vnd.ms-ims\n  lrm:\n  - application/vnd.ms-lrm\n  thmx:\n  - application/vnd.ms-officetheme\n  cat:\n  - application/vnd.ms-pki.seccat\n  stl:\n  - application/vnd.ms-pki.stl\n  ppt:\n  - application/vnd.ms-powerpoint\n  pps:\n  - application/vnd.ms-powerpoint\n  pot:\n  - application/vnd.ms-powerpoint\n  ppam:\n  - application/vnd.ms-powerpoint.addin.macroenabled.12\n  pptm:\n  - application/vnd.ms-powerpoint.presentation.macroenabled.12\n  sldm:\n  - application/vnd.ms-powerpoint.slide.macroenabled.12\n  ppsm:\n  - application/vnd.ms-powerpoint.slideshow.macroenabled.12\n  potm:\n  - application/vnd.ms-powerpoint.template.macroenabled.12\n  mpp:\n  - application/vnd.ms-project\n  mpt:\n  - application/vnd.ms-project\n  docm:\n  - application/vnd.ms-word.document.macroenabled.12\n  dotm:\n  - application/vnd.ms-word.template.macroenabled.12\n  wps:\n  - application/vnd.ms-works\n  wks:\n  - application/vnd.ms-works\n  wcm:\n  - application/vnd.ms-works\n  wdb:\n  - application/vnd.ms-works\n  wpl:\n  - application/vnd.ms-wpl\n  xps:\n  - application/vnd.ms-xpsdocument\n  mseq:\n  - application/vnd.mseq\n  mus:\n  - application/vnd.musician\n  msty:\n  - application/vnd.muvee.style\n  taglet:\n  - application/vnd.mynfc\n  nlu:\n  - application/vnd.neurolanguage.nlu\n  ntf:\n  - application/vnd.nitf\n  nitf:\n  - application/vnd.nitf\n  nnd:\n  - application/vnd.noblenet-directory\n  nns:\n  - application/vnd.noblenet-sealer\n  nnw:\n  - application/vnd.noblenet-web\n  ngdat:\n  - application/vnd.nokia.n-gage.data\n  n-gage:\n  - application/vnd.nokia.n-gage.symbian.install\n  rpst:\n  - application/vnd.nokia.radio-preset\n  rpss:\n  - application/vnd.nokia.radio-presets\n  edm:\n  - application/vnd.novadigm.edm\n  edx:\n  - application/vnd.novadigm.edx\n  ext:\n  - application/vnd.novadigm.ext\n  odc:\n  - application/vnd.oasis.opendocument.chart\n  otc:\n  - application/vnd.oasis.opendocument.chart-template\n  odb:\n  - application/vnd.oasis.opendocument.database\n  odf:\n  - application/vnd.oasis.opendocument.formula\n  odft:\n  - application/vnd.oasis.opendocument.formula-template\n  odg:\n  - application/vnd.oasis.opendocument.graphics\n  otg:\n  - application/vnd.oasis.opendocument.graphics-template\n  odi:\n  - application/vnd.oasis.opendocument.image\n  oti:\n  - application/vnd.oasis.opendocument.image-template\n  odp:\n  - application/vnd.oasis.opendocument.presentation\n  otp:\n  - application/vnd.oasis.opendocument.presentation-template\n  ods:\n  - application/vnd.oasis.opendocument.spreadsheet\n  ots:\n  - application/vnd.oasis.opendocument.spreadsheet-template\n  odt:\n  - application/vnd.oasis.opendocument.text\n  odm:\n  - application/vnd.oasis.opendocument.text-master\n  ott:\n  - application/vnd.oasis.opendocument.text-template\n  oth:\n  - application/vnd.oasis.opendocument.text-web\n  xo:\n  - application/vnd.olpc-sugar\n  dd2:\n  - application/vnd.oma.dd2+xml\n  oxt:\n  - application/vnd.openofficeorg.extension\n  pptx:\n  - application/vnd.openxmlformats-officedocument.presentationml.presentation\n  sldx:\n  - application/vnd.openxmlformats-officedocument.presentationml.slide\n  ppsx:\n  - application/vnd.openxmlformats-officedocument.presentationml.slideshow\n  potx:\n  - application/vnd.openxmlformats-officedocument.presentationml.template\n  xlsx:\n  - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\n  xltx:\n  - application/vnd.openxmlformats-officedocument.spreadsheetml.template\n  docx:\n  - application/vnd.openxmlformats-officedocument.wordprocessingml.document\n  dotx:\n  - application/vnd.openxmlformats-officedocument.wordprocessingml.template\n  mgp:\n  - application/vnd.osgeo.mapguide.package\n  dp:\n  - application/vnd.osgi.dp\n  esa:\n  - application/vnd.osgi.subsystem\n  pdb:\n  - application/vnd.palm\n  pqa:\n  - application/vnd.palm\n  oprc:\n  - application/vnd.palm\n  paw:\n  - application/vnd.pawaafile\n  str:\n  - application/vnd.pg.format\n  ei6:\n  - application/vnd.pg.osasli\n  efif:\n  - application/vnd.picsel\n  wg:\n  - application/vnd.pmi.widget\n  plf:\n  - application/vnd.pocketlearn\n  pbd:\n  - application/vnd.powerbuilder6\n  box:\n  - application/vnd.previewsystems.box\n  mgz:\n  - application/vnd.proteus.magazine\n  qps:\n  - application/vnd.publishare-delta-tree\n  ptid:\n  - application/vnd.pvi.ptid1\n  qxd:\n  - application/vnd.quark.quarkxpress\n  qxt:\n  - application/vnd.quark.quarkxpress\n  qwd:\n  - application/vnd.quark.quarkxpress\n  qwt:\n  - application/vnd.quark.quarkxpress\n  qxl:\n  - application/vnd.quark.quarkxpress\n  qxb:\n  - application/vnd.quark.quarkxpress\n  bed:\n  - application/vnd.realvnc.bed\n  mxl:\n  - application/vnd.recordare.musicxml\n  musicxml:\n  - application/vnd.recordare.musicxml+xml\n  cryptonote:\n  - application/vnd.rig.cryptonote\n  cod:\n  - application/vnd.rim.cod\n  rm:\n  - application/vnd.rn-realmedia\n  rmvb:\n  - application/vnd.rn-realmedia-vbr\n  link66:\n  - application/vnd.route66.link66+xml\n  st:\n  - application/vnd.sailingtracker.track\n  see:\n  - application/vnd.seemail\n  sema:\n  - application/vnd.sema\n  semd:\n  - application/vnd.semd\n  semf:\n  - application/vnd.semf\n  ifm:\n  - application/vnd.shana.informed.formdata\n  itp:\n  - application/vnd.shana.informed.formtemplate\n  iif:\n  - application/vnd.shana.informed.interchange\n  ipk:\n  - application/vnd.shana.informed.package\n  twd:\n  - application/vnd.simtech-mindmapper\n  twds:\n  - application/vnd.simtech-mindmapper\n  mmf:\n  - application/vnd.smaf\n  teacher:\n  - application/vnd.smart.teacher\n  sdkm:\n  - application/vnd.solent.sdkm+xml\n  sdkd:\n  - application/vnd.solent.sdkm+xml\n  dxp:\n  - application/vnd.spotfire.dxp\n  sfs:\n  - application/vnd.spotfire.sfs\n  sdc:\n  - application/vnd.stardivision.calc\n  sda:\n  - application/vnd.stardivision.draw\n  sdd:\n  - application/vnd.stardivision.impress\n  smf:\n  - application/vnd.stardivision.math\n  sdw:\n  - application/vnd.stardivision.writer\n  vor:\n  - application/vnd.stardivision.writer\n  sgl:\n  - application/vnd.stardivision.writer-global\n  smzip:\n  - application/vnd.stepmania.package\n  sm:\n  - application/vnd.stepmania.stepchart\n  sxc:\n  - application/vnd.sun.xml.calc\n  stc:\n  - application/vnd.sun.xml.calc.template\n  sxd:\n  - application/vnd.sun.xml.draw\n  std:\n  - application/vnd.sun.xml.draw.template\n  sxi:\n  - application/vnd.sun.xml.impress\n  sti:\n  - application/vnd.sun.xml.impress.template\n  sxm:\n  - application/vnd.sun.xml.math\n  sxw:\n  - application/vnd.sun.xml.writer\n  sxg:\n  - application/vnd.sun.xml.writer.global\n  stw:\n  - application/vnd.sun.xml.writer.template\n  sus:\n  - application/vnd.sus-calendar\n  susp:\n  - application/vnd.sus-calendar\n  svd:\n  - application/vnd.svd\n  sis:\n  - application/vnd.symbian.install\n  sisx:\n  - application/vnd.symbian.install\n  xsm:\n  - application/vnd.syncml+xml\n  bdm:\n  - application/vnd.syncml.dm+wbxml\n  xdm:\n  - application/vnd.syncml.dm+xml\n  tao:\n  - application/vnd.tao.intent-module-archive\n  pcap:\n  - application/vnd.tcpdump.pcap\n  cap:\n  - application/vnd.tcpdump.pcap\n  dmp:\n  - application/vnd.tcpdump.pcap\n  tmo:\n  - application/vnd.tmobile-livetv\n  tpt:\n  - application/vnd.trid.tpt\n  mxs:\n  - application/vnd.triscape.mxs\n  tra:\n  - application/vnd.trueapp\n  ufd:\n  - application/vnd.ufdl\n  ufdl:\n  - application/vnd.ufdl\n  utz:\n  - application/vnd.uiq.theme\n  umj:\n  - application/vnd.umajin\n  unityweb:\n  - application/vnd.unity\n  uoml:\n  - application/vnd.uoml+xml\n  vcx:\n  - application/vnd.vcx\n  vsd:\n  - application/vnd.visio\n  vst:\n  - application/vnd.visio\n  vss:\n  - application/vnd.visio\n  vsw:\n  - application/vnd.visio\n  vis:\n  - application/vnd.visionary\n  vsf:\n  - application/vnd.vsf\n  wbxml:\n  - application/vnd.wap.wbxml\n  wmlc:\n  - application/vnd.wap.wmlc\n  wmlsc:\n  - application/vnd.wap.wmlscriptc\n  wtb:\n  - application/vnd.webturbo\n  nbp:\n  - application/vnd.wolfram.player\n  wpd:\n  - application/vnd.wordperfect\n  wqd:\n  - application/vnd.wqd\n  stf:\n  - application/vnd.wt.stf\n  xar:\n  - application/vnd.xara\n  xfdl:\n  - application/vnd.xfdl\n  hvd:\n  - application/vnd.yamaha.hv-dic\n  hvs:\n  - application/vnd.yamaha.hv-script\n  hvp:\n  - application/vnd.yamaha.hv-voice\n  osf:\n  - application/vnd.yamaha.openscoreformat\n  osfpvg:\n  - application/vnd.yamaha.openscoreformat.osfpvg+xml\n  saf:\n  - application/vnd.yamaha.smaf-audio\n  spf:\n  - application/vnd.yamaha.smaf-phrase\n  cmp:\n  - application/vnd.yellowriver-custom-menu\n  zir:\n  - application/vnd.zul\n  zirz:\n  - application/vnd.zul\n  zaz:\n  - application/vnd.zzazz.deck+xml\n  vxml:\n  - application/voicexml+xml\n  wgt:\n  - application/widget\n  hlp:\n  - application/winhlp\n  wsdl:\n  - application/wsdl+xml\n  wspolicy:\n  - application/wspolicy+xml\n  7z:\n  - application/x-7z-compressed\n  abw:\n  - application/x-abiword\n  ace:\n  - application/x-ace-compressed\n  dmg:\n  - application/x-apple-diskimage\n  aab:\n  - application/x-authorware-bin\n  x32:\n  - application/x-authorware-bin\n  u32:\n  - application/x-authorware-bin\n  vox:\n  - application/x-authorware-bin\n  aam:\n  - application/x-authorware-map\n  aas:\n  - application/x-authorware-seg\n  bcpio:\n  - application/x-bcpio\n  torrent:\n  - application/x-bittorrent\n  blb:\n  - application/x-blorb\n  blorb:\n  - application/x-blorb\n  bz:\n  - application/x-bzip\n  bz2:\n  - application/x-bzip2\n  boz:\n  - application/x-bzip2\n  cbr:\n  - application/x-cbr\n  cba:\n  - application/x-cbr\n  cbt:\n  - application/x-cbr\n  cbz:\n  - application/x-cbr\n  cb7:\n  - application/x-cbr\n  vcd:\n  - application/x-cdlink\n  cfs:\n  - application/x-cfs-compressed\n  chat:\n  - application/x-chat\n  pgn:\n  - application/x-chess-pgn\n  nsc:\n  - application/x-conference\n  cpio:\n  - application/x-cpio\n  csh:\n  - application/x-csh\n  deb:\n  - application/x-debian-package\n  udeb:\n  - application/x-debian-package\n  dgc:\n  - application/x-dgc-compressed\n  dir:\n  - application/x-director\n  dcr:\n  - application/x-director\n  dxr:\n  - application/x-director\n  cst:\n  - application/x-director\n  cct:\n  - application/x-director\n  cxt:\n  - application/x-director\n  w3d:\n  - application/x-director\n  fgd:\n  - application/x-director\n  swa:\n  - application/x-director\n  wad:\n  - application/x-doom\n  ncx:\n  - application/x-dtbncx+xml\n  dtb:\n  - application/x-dtbook+xml\n  res:\n  - application/x-dtbresource+xml\n  dvi:\n  - application/x-dvi\n  evy:\n  - application/x-envoy\n  eva:\n  - application/x-eva\n  bdf:\n  - application/x-font-bdf\n  gsf:\n  - application/x-font-ghostscript\n  psf:\n  - application/x-font-linux-psf\n  pcf:\n  - application/x-font-pcf\n  snf:\n  - application/x-font-snf\n  pfa:\n  - application/x-font-type1\n  pfb:\n  - application/x-font-type1\n  pfm:\n  - application/x-font-type1\n  afm:\n  - application/x-font-type1\n  arc:\n  - application/x-freearc\n  spl:\n  - application/x-futuresplash\n  gca:\n  - application/x-gca-compressed\n  ulx:\n  - application/x-glulx\n  gnumeric:\n  - application/x-gnumeric\n  gramps:\n  - application/x-gramps-xml\n  gtar:\n  - application/x-gtar\n  hdf:\n  - application/x-hdf\n  install:\n  - application/x-install-instructions\n  iso:\n  - application/x-iso9660-image\n  jnlp:\n  - application/x-java-jnlp-file\n  latex:\n  - application/x-latex\n  lzh:\n  - application/x-lzh-compressed\n  lha:\n  - application/x-lzh-compressed\n  mie:\n  - application/x-mie\n  prc:\n  - application/x-mobipocket-ebook\n  mobi:\n  - application/x-mobipocket-ebook\n  application:\n  - application/x-ms-application\n  lnk:\n  - application/x-ms-shortcut\n  wmd:\n  - application/x-ms-wmd\n  wmz:\n  - application/x-ms-wmz\n  - application/x-msmetafile\n  xbap:\n  - application/x-ms-xbap\n  mdb:\n  - application/x-msaccess\n  obd:\n  - application/x-msbinder\n  crd:\n  - application/x-mscardfile\n  clp:\n  - application/x-msclip\n  exe:\n  - application/x-msdownload\n  dll:\n  - application/x-msdownload\n  com:\n  - application/x-msdownload\n  bat:\n  - application/x-msdownload\n  msi:\n  - application/x-msdownload\n  mvb:\n  - application/x-msmediaview\n  m13:\n  - application/x-msmediaview\n  m14:\n  - application/x-msmediaview\n  wmf:\n  - application/x-msmetafile\n  emf:\n  - application/x-msmetafile\n  emz:\n  - application/x-msmetafile\n  mny:\n  - application/x-msmoney\n  pub:\n  - application/x-mspublisher\n  scd:\n  - application/x-msschedule\n  trm:\n  - application/x-msterminal\n  wri:\n  - application/x-mswrite\n  nc:\n  - application/x-netcdf\n  cdf:\n  - application/x-netcdf\n  nzb:\n  - application/x-nzb\n  p12:\n  - application/x-pkcs12\n  pfx:\n  - application/x-pkcs12\n  p7b:\n  - application/x-pkcs7-certificates\n  spc:\n  - application/x-pkcs7-certificates\n  p7r:\n  - application/x-pkcs7-certreqresp\n  rar:\n  - application/x-rar-compressed\n  ris:\n  - application/x-research-info-systems\n  sh:\n  - application/x-sh\n  shar:\n  - application/x-shar\n  swf:\n  - application/x-shockwave-flash\n  xap:\n  - application/x-silverlight-app\n  sql:\n  - application/x-sql\n  sit:\n  - application/x-stuffit\n  sitx:\n  - application/x-stuffitx\n  srt:\n  - application/x-subrip\n  sv4cpio:\n  - application/x-sv4cpio\n  sv4crc:\n  - application/x-sv4crc\n  t3:\n  - application/x-t3vm-image\n  gam:\n  - application/x-tads\n  tar:\n  - application/x-tar\n  tcl:\n  - application/x-tcl\n  tex:\n  - application/x-tex\n  tfm:\n  - application/x-tex-tfm\n  texinfo:\n  - application/x-texinfo\n  texi:\n  - application/x-texinfo\n  obj:\n  - application/x-tgif\n  ustar:\n  - application/x-ustar\n  src:\n  - application/x-wais-source\n  der:\n  - application/x-x509-ca-cert\n  crt:\n  - application/x-x509-ca-cert\n  fig:\n  - application/x-xfig\n  xlf:\n  - application/x-xliff+xml\n  xpi:\n  - application/x-xpinstall\n  xz:\n  - application/x-xz\n  z1:\n  - application/x-zmachine\n  z2:\n  - application/x-zmachine\n  z3:\n  - application/x-zmachine\n  z4:\n  - application/x-zmachine\n  z5:\n  - application/x-zmachine\n  z6:\n  - application/x-zmachine\n  z7:\n  - application/x-zmachine\n  z8:\n  - application/x-zmachine\n  xaml:\n  - application/xaml+xml\n  xdf:\n  - application/xcap-diff+xml\n  xenc:\n  - application/xenc+xml\n  xhtml:\n  - application/xhtml+xml\n  xht:\n  - application/xhtml+xml\n  xml:\n  - application/xml\n  xsl:\n  - application/xml\n  dtd:\n  - application/xml-dtd\n  xop:\n  - application/xop+xml\n  xpl:\n  - application/xproc+xml\n  xslt:\n  - application/xslt+xml\n  xspf:\n  - application/xspf+xml\n  mxml:\n  - application/xv+xml\n  xhvml:\n  - application/xv+xml\n  xvml:\n  - application/xv+xml\n  xvm:\n  - application/xv+xml\n  yang:\n  - application/yang\n  yin:\n  - application/yin+xml\n  adp:\n  - audio/adpcm\n  au:\n  - audio/basic\n  snd:\n  - audio/basic\n  mid:\n  - audio/midi\n  midi:\n  - audio/midi\n  kar:\n  - audio/midi\n  rmi:\n  - audio/midi\n  m4a:\n  - audio/mp4\n  mp4a:\n  - audio/mp4\n  oga:\n  - audio/ogg\n  ogg:\n  - audio/ogg\n  spx:\n  - audio/ogg\n  s3m:\n  - audio/s3m\n  sil:\n  - audio/silk\n  uva:\n  - audio/vnd.dece.audio\n  uvva:\n  - audio/vnd.dece.audio\n  eol:\n  - audio/vnd.digital-winds\n  dra:\n  - audio/vnd.dra\n  dts:\n  - audio/vnd.dts\n  dtshd:\n  - audio/vnd.dts.hd\n  lvp:\n  - audio/vnd.lucent.voice\n  pya:\n  - audio/vnd.ms-playready.media.pya\n  ecelp4800:\n  - audio/vnd.nuera.ecelp4800\n  ecelp7470:\n  - audio/vnd.nuera.ecelp7470\n  ecelp9600:\n  - audio/vnd.nuera.ecelp9600\n  rip:\n  - audio/vnd.rip\n  weba:\n  - audio/webm\n  aac:\n  - audio/x-aac\n  aif:\n  - audio/x-aiff\n  aiff:\n  - audio/x-aiff\n  aifc:\n  - audio/x-aiff\n  caf:\n  - audio/x-caf\n  flac:\n  - audio/x-flac\n  mka:\n  - audio/x-matroska\n  m3u:\n  - audio/x-mpegurl\n  wax:\n  - audio/x-ms-wax\n  wma:\n  - audio/x-ms-wma\n  ram:\n  - audio/x-pn-realaudio\n  ra:\n  - audio/x-pn-realaudio\n  rmp:\n  - audio/x-pn-realaudio-plugin\n  wav:\n  - audio/x-wav\n  xm:\n  - audio/xm\n  cdx:\n  - chemical/x-cdx\n  cif:\n  - chemical/x-cif\n  cmdf:\n  - chemical/x-cmdf\n  cml:\n  - chemical/x-cml\n  csml:\n  - chemical/x-csml\n  xyz:\n  - chemical/x-xyz\n  woff:\n  - font/woff\n  woff2:\n  - font/woff2\n  cgm:\n  - image/cgm\n  g3:\n  - image/g3fax\n  gif:\n  - image/gif\n  ief:\n  - image/ief\n  ktx:\n  - image/ktx\n  png:\n  - image/png\n  btif:\n  - image/prs.btif\n  sgi:\n  - image/sgi\n  svg:\n  - image/svg+xml\n  svgz:\n  - image/svg+xml\n  tiff:\n  - image/tiff\n  tif:\n  - image/tiff\n  psd:\n  - image/vnd.adobe.photoshop\n  uvi:\n  - image/vnd.dece.graphic\n  uvvi:\n  - image/vnd.dece.graphic\n  uvg:\n  - image/vnd.dece.graphic\n  uvvg:\n  - image/vnd.dece.graphic\n  djvu:\n  - image/vnd.djvu\n  djv:\n  - image/vnd.djvu\n  sub:\n  - image/vnd.dvb.subtitle\n  - text/vnd.dvb.subtitle\n  dwg:\n  - image/vnd.dwg\n  dxf:\n  - image/vnd.dxf\n  fbs:\n  - image/vnd.fastbidsheet\n  fpx:\n  - image/vnd.fpx\n  fst:\n  - image/vnd.fst\n  mmr:\n  - image/vnd.fujixerox.edmics-mmr\n  rlc:\n  - image/vnd.fujixerox.edmics-rlc\n  mdi:\n  - image/vnd.ms-modi\n  wdp:\n  - image/vnd.ms-photo\n  npx:\n  - image/vnd.net-fpx\n  wbmp:\n  - image/vnd.wap.wbmp\n  xif:\n  - image/vnd.xiff\n  webp:\n  - image/webp\n  3ds:\n  - image/x-3ds\n  ras:\n  - image/x-cmu-raster\n  cmx:\n  - image/x-cmx\n  fh:\n  - image/x-freehand\n  fhc:\n  - image/x-freehand\n  fh4:\n  - image/x-freehand\n  fh5:\n  - image/x-freehand\n  fh7:\n  - image/x-freehand\n  ico:\n  - image/x-icon\n  sid:\n  - image/x-mrsid-image\n  pcx:\n  - image/x-pcx\n  pic:\n  - image/x-pict\n  pct:\n  - image/x-pict\n  pnm:\n  - image/x-portable-anymap\n  pbm:\n  - image/x-portable-bitmap\n  pgm:\n  - image/x-portable-graymap\n  ppm:\n  - image/x-portable-pixmap\n  rgb:\n  - image/x-rgb\n  tga:\n  - image/x-tga\n  xbm:\n  - image/x-xbitmap\n  xpm:\n  - image/x-xpixmap\n  xwd:\n  - image/x-xwindowdump\n  eml:\n  - message/rfc822\n  mime:\n  - message/rfc822\n  igs:\n  - model/iges\n  iges:\n  - model/iges\n  msh:\n  - model/mesh\n  mesh:\n  - model/mesh\n  silo:\n  - model/mesh\n  dae:\n  - model/vnd.collada+xml\n  dwf:\n  - model/vnd.dwf\n  gdl:\n  - model/vnd.gdl\n  gtw:\n  - model/vnd.gtw\n  mts:\n  - model/vnd.mts\n  vtu:\n  - model/vnd.vtu\n  wrl:\n  - model/vrml\n  vrml:\n  - model/vrml\n  x3db:\n  - model/x3d+binary\n  x3dbz:\n  - model/x3d+binary\n  x3dv:\n  - model/x3d+vrml\n  x3dvz:\n  - model/x3d+vrml\n  x3d:\n  - model/x3d+xml\n  x3dz:\n  - model/x3d+xml\n  appcache:\n  - text/cache-manifest\n  ics:\n  - text/calendar\n  ifb:\n  - text/calendar\n  css:\n  - text/css\n  csv:\n  - text/csv\n  html:\n  - text/html\n  htm:\n  - text/html\n  n3:\n  - text/n3\n  txt:\n  - text/plain\n  text:\n  - text/plain\n  conf:\n  - text/plain\n  def:\n  - text/plain\n  list:\n  - text/plain\n  log:\n  - text/plain\n  in:\n  - text/plain\n  dsc:\n  - text/prs.lines.tag\n  rtx:\n  - text/richtext\n  sgml:\n  - text/sgml\n  sgm:\n  - text/sgml\n  tsv:\n  - text/tab-separated-values\n  t:\n  - text/troff\n  tr:\n  - text/troff\n  roff:\n  - text/troff\n  man:\n  - text/troff\n  me:\n  - text/troff\n  ms:\n  - text/troff\n  ttl:\n  - text/turtle\n  uri:\n  - text/uri-list\n  uris:\n  - text/uri-list\n  urls:\n  - text/uri-list\n  vcard:\n  - text/vcard\n  curl:\n  - text/vnd.curl\n  dcurl:\n  - text/vnd.curl.dcurl\n  mcurl:\n  - text/vnd.curl.mcurl\n  scurl:\n  - text/vnd.curl.scurl\n  fly:\n  - text/vnd.fly\n  flx:\n  - text/vnd.fmi.flexstor\n  gv:\n  - text/vnd.graphviz\n  3dml:\n  - text/vnd.in3d.3dml\n  spot:\n  - text/vnd.in3d.spot\n  jad:\n  - text/vnd.sun.j2me.app-descriptor\n  wml:\n  - text/vnd.wap.wml\n  wmls:\n  - text/vnd.wap.wmlscript\n  s:\n  - text/x-asm\n  asm:\n  - text/x-asm\n  c:\n  - text/x-c\n  cc:\n  - text/x-c\n  cxx:\n  - text/x-c\n  cpp:\n  - text/x-c\n  h:\n  - text/x-c\n  hh:\n  - text/x-c\n  dic:\n  - text/x-c\n  f:\n  - text/x-fortran\n  for:\n  - text/x-fortran\n  f77:\n  - text/x-fortran\n  f90:\n  - text/x-fortran\n  java:\n  - text/x-java-source\n  nfo:\n  - text/x-nfo\n  opml:\n  - text/x-opml\n  p:\n  - text/x-pascal\n  pas:\n  - text/x-pascal\n  etx:\n  - text/x-setext\n  sfv:\n  - text/x-sfv\n  uu:\n  - text/x-uuencode\n  vcs:\n  - text/x-vcalendar\n  vcf:\n  - text/x-vcard\n  3gp:\n  - video/3gpp\n  3g2:\n  - video/3gpp2\n  h261:\n  - video/h261\n  h263:\n  - video/h263\n  h264:\n  - video/h264\n  jpgv:\n  - video/jpeg\n  jpm:\n  - video/jpm\n  jpgm:\n  - video/jpm\n  mj2:\n  - video/mj2\n  mjp2:\n  - video/mj2\n  mp4:\n  - video/mp4\n  mp4v:\n  - video/mp4\n  mpg4:\n  - video/mp4\n  mpeg:\n  - video/mpeg\n  mpg:\n  - video/mpeg\n  mpe:\n  - video/mpeg\n  m1v:\n  - video/mpeg\n  m2v:\n  - video/mpeg\n  ogv:\n  - video/ogg\n  qt:\n  - video/quicktime\n  mov:\n  - video/quicktime\n  uvh:\n  - video/vnd.dece.hd\n  uvvh:\n  - video/vnd.dece.hd\n  uvm:\n  - video/vnd.dece.mobile\n  uvvm:\n  - video/vnd.dece.mobile\n  uvp:\n  - video/vnd.dece.pd\n  uvvp:\n  - video/vnd.dece.pd\n  uvs:\n  - video/vnd.dece.sd\n  uvvs:\n  - video/vnd.dece.sd\n  uvv:\n  - video/vnd.dece.video\n  uvvv:\n  - video/vnd.dece.video\n  dvb:\n  - video/vnd.dvb.file\n  fvt:\n  - video/vnd.fvt\n  mxu:\n  - video/vnd.mpegurl\n  m4u:\n  - video/vnd.mpegurl\n  pyv:\n  - video/vnd.ms-playready.media.pyv\n  uvu:\n  - video/vnd.uvvu.mp4\n  uvvu:\n  - video/vnd.uvvu.mp4\n  viv:\n  - video/vnd.vivo\n  webm:\n  - video/webm\n  f4v:\n  - video/x-f4v\n  fli:\n  - video/x-fli\n  flv:\n  - video/x-flv\n  m4v:\n  - video/x-m4v\n  mkv:\n  - video/x-matroska\n  mk3d:\n  - video/x-matroska\n  mks:\n  - video/x-matroska\n  mng:\n  - video/x-mng\n  asf:\n  - video/x-ms-asf\n  asx:\n  - video/x-ms-asf\n  vob:\n  - video/x-ms-vob\n  wm:\n  - video/x-ms-wm\n  wmv:\n  - video/x-ms-wmv\n  wmx:\n  - video/x-ms-wmx\n  wvx:\n  - video/x-ms-wvx\n  avi:\n  - video/x-msvideo\n  movie:\n  - video/x-sgi-movie\n  smv:\n  - video/x-smv\n  ice:\n  - x-conference/x-cooltalk\n"
  },
  {
    "path": "system/config/permissions.yaml",
    "content": "actions:\n  site:\n    type: access\n    label: Site\n  admin:\n    type: access\n    label: Admin\n  admin.pages:\n    type: access\n    label: Pages\n  admin.users:\n    type: access\n    label: User Accounts\n\ntypes:\n  default:\n    type: access\n\n  crud:\n    type: compact\n    letters:\n      c:\n        action: create\n        label: PLUGIN_ADMIN.CREATE\n      r:\n        action: read\n        label: PLUGIN_ADMIN.READ\n      u:\n        action: update\n        label: PLUGIN_ADMIN.UPDATE\n      d:\n        action: delete\n        label: PLUGIN_ADMIN.DELETE\n\n  crudp:\n    type: crud\n    letters:\n      p:\n        action: publish\n        label: PLUGIN_ADMIN.PUBLISH\n\n  crudl:\n    type: crud\n    letters:\n      l:\n        action: list\n        label: PLUGIN_ADMIN.LIST\n\n  crudpl:\n    type: crud\n    use:\n      - crudp\n      - crudl\n"
  },
  {
    "path": "system/config/scheduler.yaml",
    "content": "# Grav Scheduler Configuration\n\n# Default scheduler settings (backward compatible)\ndefaults:\n  output: true\n  output_type: file\n  email: null\n\n# Status of individual jobs (enabled/disabled)\nstatus: {}\n\n# Custom scheduled jobs\ncustom_jobs: {}\n\n# Modern scheduler features\nmodern:\n  \n  # Number of concurrent workers (1 = sequential execution like legacy)\n  workers: 1\n  \n  # Job retry configuration\n  retry:\n    enabled: true\n    max_attempts: 3\n    backoff: exponential  # 'linear' or 'exponential'\n    \n  # Job queue configuration\n  queue:\n    path: user-data://scheduler/queue\n    max_size: 1000\n    \n  # Webhook trigger configuration\n  webhook:\n    enabled: false\n    token: null  # Set a secure token to enable webhook triggers\n    path: /scheduler/webhook\n    \n  # Health check endpoint\n  health:\n    enabled: true\n    path: /scheduler/health\n    \n  # Job execution history\n  history:\n    enabled: true\n    retention_days: 30\n    path: user-data://scheduler/history\n    \n  # Performance settings\n  performance:\n    job_timeout: 300  # Default timeout in seconds\n    lock_timeout: 10   # Lock acquisition timeout in seconds\n    \n  # Monitoring and alerts\n  monitoring:\n    enabled: false\n    alert_on_failure: true\n    alert_email: null\n    webhook_url: null\n    \n  # Trigger detection methods\n  triggers:\n    check_cron: true\n    check_systemd: true\n    check_webhook: true\n    check_external: true"
  },
  {
    "path": "system/config/security.yaml",
    "content": "xss_whitelist: [admin.super] # Whitelist of user access that should 'skip' XSS checking\nxss_enabled:\n    on_events: true\n    invalid_protocols: true\n    moz_binding: true\n    html_inline_styles: true\n    dangerous_tags: true\nxss_invalid_protocols:\n    - javascript\n    - livescript\n    - vbscript\n    - mocha\n    - feed\n    - data\nxss_dangerous_tags:\n    - applet\n    - meta\n    - xml\n    - blink\n    - link\n    - style\n    - script\n    - embed\n    - object\n    - iframe\n    - frame\n    - frameset\n    - ilayer\n    - layer\n    - bgsound\n    - title\n    - base\nuploads_dangerous_extensions:\n    - php\n    - php2\n    - php3\n    - php4\n    - php5\n    - phar\n    - phtml\n    - html\n    - htm\n    - shtml\n    - shtm\n    - js\n    - exe\nsanitize_svg: true\n"
  },
  {
    "path": "system/config/site.yaml",
    "content": "title: Grav                                 # Name of the site\ndefault_lang: en                            # Default language for site (potentially used by theme)\n\nauthor:\n  name: John Appleseed                      # Default author name\n  email: 'john@example.com'                 # Default author email\n\ntaxonomies: [category,tag]                  # Arbitrary list of taxonomy types\n\nmetadata:\n  description: 'My Grav Site'               # Site description\n\nsummary:\n  enabled: true                             # enable or disable summary of page\n  format: short                             # long = summary delimiter will be ignored; short = use the first occurrence of delimiter or size\n  size: 300                                 # Maximum length of summary (characters)\n  delimiter: ===                            # The summary delimiter\n\nredirects:\n#  '/redirect-test': '/'                    # Redirect test goes to home page\n#  '/old/(.*)': '/new/$1'                   # Would redirect /old/my-page to /new/my-page\n\nroutes:\n#  '/something/else': '/blog/sample-3'      # Alias for /blog/sample-3\n#  '/new/(.*)': '/blog/$1'                  # Regex any /new/my-page URL to /blog/my-page Route\n\nblog:\n  route: '/blog'                            # Custom value added (accessible via site.blog.route)\n\n#menu:                                      # Menu Example\n#    - text: Source\n#      icon: github\n#      url: https://github.com/getgrav/grav\n#    - icon: twitter\n#      url: http://twitter.com/getgrav\n"
  },
  {
    "path": "system/config/system.yaml",
    "content": "absolute_urls: false                             # Absolute or relative URLs for `base_url`\ntimezone: ''                                     # Valid values: http://php.net/manual/en/timezones.php\ndefault_locale:                                  # Default locale (defaults to system)\nparam_sep: ':'                                   # Parameter separator, use ';' for Apache on windows\nwrapped_site: false                              # For themes/plugins to know if Grav is wrapped by another platform\nreverse_proxy_setup: false                       # Running in a reverse proxy scenario with different webserver ports than proxy\nforce_ssl: false                                 # If enabled, Grav forces to be accessed via HTTPS (NOTE: Not an ideal solution)\nforce_lowercase_urls: true                       # If you want to support mixed cased URLs set this to false\ncustom_base_url: ''                              # Set the base_url manually, e.g. http://yoursite.com/yourpath\nusername_regex: '^[a-z0-9_-]{3,16}$'             # Only lowercase chars, digits, dashes, underscores. 3 - 16 chars\npwd_regex: '(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}' # At least one number, one uppercase and lowercase letter, and be at least 8+ chars\nintl_enabled: true                               # Special logic for PHP International Extension (mod_intl)\nhttp_x_forwarded:                                # Configuration options for the various HTTP_X_FORWARD headers\n  protocol: true\n  host: false\n  port: true\n  ip: true\n\nlanguages:\n  supported: []                                  # List of languages supported. eg: [en, fr, de]\n  default_lang:                                  # Default is the first supported language. Must be one of the supported languages\n  include_default_lang: true                     # Include the default lang prefix in all URLs\n  include_default_lang_file_extension: true      # If true, include language code for the default language in file extension: default.en.md\n  translations: true                             # If false, translation keys are used instead of translated strings\n  translations_fallback: true                    # Fallback through supported translations if active lang doesn't exist\n  session_store_active: false                    # Store active language in session\n  http_accept_language: false                    # Attempt to set the language based on http_accept_language header in the browser\n  override_locale: false                         # Override the default or system locale with language specific one\n  content_fallback: {}                           # Custom language fallbacks. eg: {fr: ['fr', 'en']}\n  pages_fallback_only: false                     # DEPRECATED: Use `content_fallback` instead\n  debug: false                                   # Debug language detection\n\nhome:\n  alias: '/home'                                 # Default path for home, ie /\n  hide_in_urls: false                            # Hide the home route in URLs\n\npages:\n  type: regular                                  # EXPERIMENTAL: Page type: regular or flex\n  dirs: ['page://']                              # Advanced functionality, allows for multiple page paths\n  theme: quark                                   # Default theme (defaults to \"quark\" theme)\n  order:\n    by: default                                  # Order pages by \"default\", \"alpha\" or \"date\"\n    dir: asc                                     # Default ordering direction, \"asc\" or \"desc\"\n  list:\n    count: 20                                    # Default item count per page\n  dateformat:\n    default:                                     # The default date format Grav expects in the `date: ` field\n    short: 'jS M Y'                              # Short date format\n    long: 'F jS \\a\\t g:ia'                       # Long date format\n  publish_dates: true                            # automatically publish/unpublish based on dates\n  process:\n    markdown: true                               # Process Markdown\n    twig: false                                  # Process Twig\n  twig_first: false                              # Process Twig before markdown when processing both on a page\n  never_cache_twig: false                        # Only cache content, never cache twig processed in content (incompatible with `twig_first: true`)\n  events:\n    page: true                                   # Enable page level events\n    twig: true                                   # Enable Twig level events\n  markdown:\n    extra: false                                 # Enable support for Markdown Extra support (GFM by default)\n    auto_line_breaks: false                      # Enable automatic line breaks\n    auto_url_links: false                        # Enable automatic HTML links\n    escape_markup: false                         # Escape markup tags into entities\n    special_chars:                               # List of special characters to automatically convert to entities\n      '>': 'gt'\n      '<': 'lt'\n    valid_link_attributes:                       # Valid attributes to pass through via markdown links\n      - rel\n      - target\n      - id\n      - class\n      - classes\n  types: [html,htm,xml,txt,json,rss,atom]        # list of valid page types\n  append_url_extension: ''                       # Append page's extension in Page urls (e.g. '.html' results in /path/page.html)\n  expires: 604800                                # Page expires time in seconds (604800 seconds = 7 days)\n  cache_control:                                 # Can be blank for no setting, or a valid `cache-control` text value\n  last_modified: false                           # Set the last modified date header based on file modification timestamp\n  etag: true                                     # Set the etag header tag\n  vary_accept_encoding: false                    # Add `Vary: Accept-Encoding` header\n  redirect_default_code: 302                     # Default code to use for redirects: 301|302|303\n  redirect_trailing_slash: 1                     # Always redirect trailing slash with redirect code 0|1|301|302 (0: no redirect, 1: use default code)\n  redirect_default_route: 0                      # Always redirect to page's default route using code 0|1|301|302, also removes .htm and .html extensions\n  ignore_files: [.DS_Store]                      # Files to ignore in Pages\n  ignore_folders: [.git, .idea]                  # Folders to ignore in Pages\n  ignore_hidden: true                            # Ignore all Hidden files and folders\n  hide_empty_folders: false                      # If folder has no .md file, should it be hidden\n  url_taxonomy_filters: true                     # Enable auto-magic URL-based taxonomy filters for page collections\n  frontmatter:\n    process_twig: false                          # Should the frontmatter be processed to replace Twig variables?\n    ignore_fields: ['form','forms']              # Fields that might contain Twig variables and should not be processed\n\ncache:\n  enabled: true                                  # Set to true to enable caching\n  check:\n    method: file                                 # Method to check for updates in pages: file|folder|hash|none\n  driver: auto                                   # One of: auto|file|apcu|memcache|wincache\n  prefix: 'g'                                    # Cache prefix string (prevents cache conflicts)\n  purge_at: '0 4 * * *'                          # How often to purge old file cache (using new scheduler)\n  clear_at: '0 3 * * *'                           # How often to clear cache (using new scheduler)\n  clear_job_type: 'standard'                     # Type to clear when processing the scheduled clear job `standard`|`all`\n  clear_images_by_default: false                 # By default grav does not include processed images in cache clear, this can be enabled\n  cli_compatibility: false                       # Ensures only non-volatile drivers are used (file, redis, memcache, etc.)\n  lifetime: 604800                               # Lifetime of cached data in seconds (0 = infinite)\n  purge_max_age_days: 30                         # Maximum age of cache items in days before they are purged\n  gzip: false                                    # GZip compress the page output\n  allow_webserver_gzip: false                    # If true, `content-encoding: identity` but connection isn't closed before `onShutDown()` event\n  redis:\n    socket: false                                # Path to redis unix socket (e.g. /var/run/redis/redis.sock), false = use server and port to connect\n    password:                                    # Optional password\n    database:                                    # Optional database ID\n\ntwig:\n  cache: true                                    # Set to true to enable Twig caching\n  debug: true                                    # Enable Twig debug\n  auto_reload: true                              # Refresh cache on changes\n  autoescape: true                               # Autoescape Twig vars (DEPRECATED, always enabled in strict mode)\n  undefined_functions: true                      # Allow undefined functions\n  undefined_filters: true                        # Allow undefined filters\n  safe_functions: []                             # List of PHP functions which are allowed to be used as Twig functions\n  safe_filters: []                               # List of PHP functions which are allowed to be used as Twig filters\n  umask_fix: false                               # By default Twig creates cached files as 755, fix switches this to 775\n\nassets:                                          # Configuration for Assets Manager (JS, CSS)\n  css_pipeline: false                            # The CSS pipeline is the unification of multiple CSS resources into one file\n  css_pipeline_include_externals: true           # Include external URLs in the pipeline by default\n  css_pipeline_before_excludes: true             # Render the pipeline before any excluded files\n  css_minify: true                               # Minify the CSS during pipelining\n  css_minify_windows: false                      # Minify Override for Windows platforms. False by default due to ThreadStackSize\n  css_rewrite: true                              # Rewrite any CSS relative URLs during pipelining\n  js_pipeline: false                             # The JS pipeline is the unification of multiple JS resources into one file\n  js_pipeline_include_externals: true            # Include external URLs in the pipeline by default\n  js_pipeline_before_excludes: true              # Render the pipeline before any excluded files\n  js_module_pipeline: false                      # The JS Module pipeline is the unification of multiple JS Module resources into one file\n  js_module_pipeline_include_externals: true     # Include external URLs in the pipeline by default\n  js_module_pipeline_before_excludes: true       # Render the pipeline before any excluded files\n  js_minify: true                                # Minify the JS during pipelining\n  enable_asset_timestamp: false                  # Enable asset timestamps\n  enable_asset_sri: false                        # Enable asset SRI\n  collections:\n    jquery: system://assets/jquery/jquery-3.x.min.js\n\nerrors:\n  display: 0                                     # Display either (1) Full backtrace | (0) Simple Error | (-1) System Error\n  log: true                                      # Log errors to /logs folder\n\nlog:\n  handler: file                                 # Log handler. Currently supported: file | syslog\n  syslog:\n    facility: local6                            # Syslog facilities output\n    tag: grav                                   # Syslog tag. Default: \"grav\".\n\ndebugger:\n  enabled: false                                 # Enable Grav debugger and following settings\n  provider: clockwork                            # Debugger provider: debugbar | clockwork\n  censored: false                                # Censor potentially sensitive information (POST parameters, cookies, files, configuration and most array/object data in log messages)\n  shutdown:\n    close_connection: true                       # Close the connection before calling onShutdown(). false for debugging\n\nimages:\n  adapter: gd                                    # Image adapter to use: gd | imagick\n  default_image_quality: 85                      # Default image quality to use when resampling images (85%)\n  cache_all: false                               # Cache all image by default\n  cache_perms: '0755'                            # MUST BE IN QUOTES!! Default cache folder perms. Usually '0755' or '0775'\n  debug: false                                   # Show an overlay over images indicating the pixel depth of the image when working with retina for example\n  auto_fix_orientation: true                     # Automatically fix the image orientation based on the Exif data\n  seofriendly: false                             # SEO-friendly processed image names\n  cls:                                           # Cumulative Layout Shift: See https://web.dev/optimize-cls/\n    auto_sizes: false                            # Automatically add height/width to image\n    aspect_ratio: false                          # Reserve space with aspect ratio style\n    retina_scale: 1                              # scale to adjust auto-sizes for better handling of HiDPI resolutions\n  defaults:\n    loading: auto                                # Let browser pick [auto|lazy|eager]\n    decoding: auto                               # Let browser pick [auto|sync|async]\n    fetchpriority: auto                          # Let browser pick [auto|high|low]\n  watermark:\n    image: 'system://images/watermark.png'       # Path to a watermark image\n    position_y: 'center'                         # top|center|bottom\n    position_x: 'center'                         # left|center|right\n    scale: 33                                    # percentage of watermark scale\n    watermark_all: false                         # automatically watermark all images\n\nmedia:\n  enable_media_timestamp: false                  # Enable media timestamps\n  unsupported_inline_types: []                   # Array of supported media types to try to display inline\n  allowed_fallback_types: []                     # Array of allowed media types of files found if accessed via Page route\n  auto_metadata_exif: false                      # Automatically create metadata files from Exif data where possible\n\nsession:\n  enabled: true                                  # Enable Session support\n  initialize: true                               # Initialize session from Grav (if false, plugin needs to start the session)\n  timeout: 1800                                  # Timeout in seconds\n  name: grav-site                                # Name prefix of the session cookie. Use alphanumeric, dashes or underscores only. Do not use dots in the session name\n  uniqueness: path                               # Should sessions be `path` based or `security.salt` based\n  secure: false                                  # Set session secure. If true, indicates that communication for this cookie must be over an encrypted transmission. Enable this only on sites that run exclusively on HTTPS\n  secure_https: true                             # Set session secure on HTTPS but not on HTTP. Has no effect if you have `session.secure: true`. Set to false if your site jumps between HTTP and HTTPS.\n  httponly: true                                 # Set session HTTP only. If true, indicates that cookies should be used only over HTTP, and JavaScript modification is not allowed.\n  samesite: Lax                                  # Set session SameSite. Possible values are Lax, Strict and None. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite\n  split: true                                    # Sessions should be independent between site and plugins (such as admin)\n  domain:                                        # Domain used by sessions.\n  path:                                          # Path used by sessions.\n\ngpm:\n  releases: stable                               # Set to either 'stable' or 'testing'\n  official_gpm_only: true                        # By default GPM direct-install will only allow URLs via the official GPM proxy to ensure security\n\nhttp:\n  method: auto                                   # Either 'curl', 'fopen' or 'auto'. 'auto' will try fopen first and if not available cURL\n  enable_proxy: true                             # Enable proxy server configuration\n  proxy_url:                                     # Configure a manual proxy URL for GPM (eg 127.0.0.1:3128)\n  proxy_cert_path:                               # Local path to proxy certificate folder containing pem files\n  concurrent_connections: 5                      # Concurrent HTTP connections when multiplexing\n  verify_peer: true                              # Enable/Disable SSL verification of peer certificates\n  verify_host: true                              # Enable/Disable SSL verification of host certificates\n\naccounts:\n  type: regular                                  # EXPERIMENTAL: Account type: regular or flex\n  storage: file                                  # EXPERIMENTAL: Flex storage type: file or folder\n  avatar: gravatar                               # Avatar generator [multiavatar|gravatar]\n\nflex:\n  cache:\n    index:\n      enabled: true                             # Set to true to enable Flex index caching. Is used to cache timestamps in files\n      lifetime: 60                              # Lifetime of cached index in seconds (0 = infinite)\n    object:\n      enabled: true                             # Set to true to enable Flex object caching. Is used to cache object data\n      lifetime: 600                             # Lifetime of cached objects in seconds (0 = infinite)\n    render:\n      enabled: true                             # Set to true to enable Flex render caching. Is used to cache rendered output\n      lifetime: 600                             # Lifetime of cached HTML in seconds (0 = infinite)\n\nstrict_mode:\n  yaml_compat: false                            # Set to true to enable YAML backwards compatibility\n  twig_compat: false                            # Set to true to enable deprecated Twig settings (autoescape: false)\n  blueprint_compat: false                       # Set to true to enable backward compatible strict support for blueprints\n"
  },
  {
    "path": "system/defines.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Core\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\n// Some standard defines\ndefine('GRAV', true);\ndefine('GRAV_VERSION', '1.7.49.5');\ndefine('GRAV_SCHEMA', '1.7.0_2020-11-20_1');\ndefine('GRAV_TESTING', false);\n\n// PHP minimum requirement\nif (!defined('GRAV_PHP_MIN')) {\n    define('GRAV_PHP_MIN', '7.3.6');\n}\n\n// Directory separator\nif (!defined('DS')) {\n    define('DS', '/');\n}\n\n// Absolute path to Grav root. This is where Grav is installed into.\nif (!defined('GRAV_ROOT')) {\n    $path = rtrim(str_replace(DIRECTORY_SEPARATOR, DS, getenv('GRAV_ROOT') ?: getcwd()), DS);\n    define('GRAV_ROOT', $path ?: DS);\n}\n// Absolute path to Grav webroot. This is the path where your site is located in.\nif (!defined('GRAV_WEBROOT')) {\n    $path = rtrim(getenv('GRAV_WEBROOT') ?: GRAV_ROOT, DS);\n    define('GRAV_WEBROOT', $path ?: DS);\n}\n// Relative path to user folder. This path needs to be located under GRAV_WEBROOT.\nif (!defined('GRAV_USER_PATH')) {\n    $path = rtrim(getenv('GRAV_USER_PATH') ?: 'user', DS);\n    define('GRAV_USER_PATH', $path);\n}\n// Absolute or relative path to system folder. Defaults to GRAV_ROOT/system\n// If system folder is outside of webroot, see https://github.com/getgrav/grav/issues/3297#issuecomment-810294972\nif (!defined('GRAV_SYSTEM_PATH')) {\n    $path = rtrim(getenv('GRAV_SYSTEM_PATH') ?: 'system', DS);\n    define('GRAV_SYSTEM_PATH', $path);\n}\n// Absolute or relative path to cache folder. Defaults to GRAV_ROOT/cache\nif (!defined('GRAV_CACHE_PATH')) {\n    $path = rtrim(getenv('GRAV_CACHE_PATH') ?: 'cache', DS);\n    define('GRAV_CACHE_PATH', $path);\n}\n// Absolute or relative path to logs folder. Defaults to GRAV_ROOT/logs\nif (!defined('GRAV_LOG_PATH')) {\n    $path = rtrim(getenv('GRAV_LOG_PATH') ?: 'logs', DS);\n    define('GRAV_LOG_PATH', $path);\n}\n// Absolute or relative path to tmp folder. Defaults to GRAV_ROOT/tmp\nif (!defined('GRAV_TMP_PATH')) {\n    $path = rtrim(getenv('GRAV_TMP_PATH') ?: 'tmp', DS);\n    define('GRAV_TMP_PATH', $path);\n}\n// Absolute or relative path to backup folder. Defaults to GRAV_ROOT/backup\nif (!defined('GRAV_BACKUP_PATH')) {\n    $path = rtrim(getenv('GRAV_BACKUP_PATH') ?: 'backup', DS);\n    define('GRAV_BACKUP_PATH', $path);\n}\nunset($path);\n\n// INTERNAL: Do not use!\ndefine('USER_DIR', GRAV_WEBROOT . '/' . GRAV_USER_PATH . '/');\ndefine('CACHE_DIR', (!preg_match('`^(/|[a-z]:[\\\\\\/])`ui', GRAV_CACHE_PATH) ? GRAV_ROOT . '/' : '') . GRAV_CACHE_PATH . '/');\n\n// DEPRECATED: Do not use!\ndefine('CACHE_PATH', GRAV_CACHE_PATH . DS);\ndefine('USER_PATH', GRAV_USER_PATH . DS);\ndefine('ROOT_DIR', GRAV_ROOT . DS);\ndefine('ASSETS_DIR', GRAV_WEBROOT . '/assets/');\ndefine('IMAGES_DIR', GRAV_WEBROOT . '/images/');\ndefine('ACCOUNTS_DIR', USER_DIR . 'accounts/');\ndefine('PAGES_DIR', USER_DIR . 'pages/');\ndefine('DATA_DIR', USER_DIR . 'data/');\ndefine('PLUGINS_DIR', USER_DIR . 'plugins/');\ndefine('THEMES_DIR', USER_DIR . 'themes/');\ndefine('SYSTEM_DIR', (!preg_match('`^(/|[a-z]:[\\\\\\/])`ui', GRAV_SYSTEM_PATH) ? GRAV_ROOT . '/' : '') . GRAV_SYSTEM_PATH . '/');\ndefine('LIB_DIR', SYSTEM_DIR . 'src/');\ndefine('VENDOR_DIR', GRAV_ROOT . '/vendor/');\ndefine('LOG_DIR', (!preg_match('`^(/|[a-z]:[\\\\\\/])`ui', GRAV_LOG_PATH) ? GRAV_ROOT . '/' : '') . GRAV_LOG_PATH . '/');\n// END DEPRECATED\n\n// Some extensions\ndefine('CONTENT_EXT', '.md');\ndefine('TEMPLATE_EXT', '.html.twig');\ndefine('TWIG_EXT', '.twig');\ndefine('PLUGIN_EXT', '.php');\ndefine('YAML_EXT', '.yaml');\n\n// Content types\ndefine('RAW_CONTENT', 1);\ndefine('TWIG_CONTENT', 2);\ndefine('TWIG_CONTENT_LIST', 3);\ndefine('TWIG_TEMPLATES', 4);\n\n// Filters\ndefine('GRAV_SANITIZE_STRING', 5001);\n"
  },
  {
    "path": "system/install.php",
    "content": "<?php\n/**\n * @package    Grav\\Core\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nif (!defined('GRAV_ROOT')) {\n    die();\n}\n\nrequire_once __DIR__ . '/src/Grav/Installer/Install.php';\n\nreturn Grav\\Installer\\Install::instance();\n"
  },
  {
    "path": "system/languages/ar.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nالعنوان: %1$s\\n---\\n# خطأ: مادة أمامية غير صحيحة\\n\\nمسار: '%2$s'\\n\\n**%3$s**\\n\\n, , ,\\n\\n%4$s\\n, , ,\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'معدّات'\n    - 'معلومات'\n    - 'أرز'\n    - 'مال'\n    - 'نوع'\n    - 'سلسلة'\n    - 'سمك'\n    - 'خروف'\n  INFLECTOR_IRREGULAR:\n    'person': 'أشخاص'\n  NICETIME:\n    NO_DATE_PROVIDED: لم يتم تقديم التاريخ\n    BAD_DATE: تاريخ خاطئ\n    AGO: من قبل\n    FROM_NOW: من الآن\n    SECOND: ثانية\n    MINUTE: دقيقة\n    HOUR: ساعة\n    DAY: يوم\n    WEEK: أسبوع\n    MONTH: شهر\n    YEAR: سنة\n    DECADE: عقد\n    SEC: ثانية\n    MIN: دقيقة\n    HR: ساعة\n    WK: أسبوع\n    MO: شهر\n    YR: سنة\n    DEC: عقد\n    SECOND_PLURAL: ثواني\n    MINUTE_PLURAL: '‮دقائق'\n    HOUR_PLURAL: ساعات\n    DAY_PLURAL: أيام\n    WEEK_PLURAL: أسابيع\n    MONTH_PLURAL: أشهر\n    YEAR_PLURAL: سنوات\n    DECADE_PLURAL: عقود\n    SEC_PLURAL: ثواني\n    MIN_PLURAL: دقائق\n    HR_PLURAL: ساعات\n    WK_PLURAL: أسابيع\n    MO_PLURAL: أشهر\n    YR_PLURAL: سنوات\n    DEC_PLURAL: عقود\n  FORM:\n    VALIDATION_FAIL: '<b>فشل التحقق من صحة:</b>'\n    INVALID_INPUT: 'إدخال غير صحيح في'\n    MISSING_REQUIRED_FIELD: 'حقل مطلوب مفقود:'\n    XSS_ISSUES: \"مشاكل XSS محتملة تم اكتشافها في حقل '%s' '\"\n  MONTHS_OF_THE_YEAR:\n    - 'كانون الثاني'\n    - 'شباط'\n    - 'آذار/ مارس'\n    - 'نيسان'\n    - 'أيار'\n    - 'حزيران'\n    - 'تموز'\n    - 'آب'\n    - 'أيلول'\n    - 'تشرين الأول'\n    - 'تشرين الثاني'\n    - 'كانون الأول'\n  DAYS_OF_THE_WEEK:\n    - 'الاثنين'\n    - 'الثلاثاء'\n    - 'الأربعاء'\n    - 'الخميس'\n    - 'الجمعة'\n    - 'السبت'\n    - 'الأحد'\n  YES: \"نعم\"\n  NO: \"لا\"\n  CRON:\n    EVERY: كل\n    EVERY_HOUR: كل ساعة\n    EVERY_MINUTE: كل دقيقة\n    EVERY_DAY_OF_WEEK: كل يوم في الأسبوع\n    EVERY_DAY_OF_MONTH: كل يوم في الشهر\n    EVERY_MONTH: ' كل شهر'\n    TEXT_PERIOD: كل <b />\n    TEXT_MINS: ' في <b /> دقيقة(دقائق) بعد الساعة'\n    TEXT_TIME: ' في <b />:<b />'\n    TEXT_DOW: ' في <b />'\n    TEXT_MONTH: ' من <b />'\n    TEXT_DOM: ' في <b />'\n    ERROR1: الوسم %s غير مدعوم!\n    ERROR2: عدد عناصر غير صالح.\n    ERROR4: تعبير غير معروف\n"
  },
  {
    "path": "system/languages/bg.yaml",
    "content": "---\nGRAV:\n  NICETIME:\n    NO_DATE_PROVIDED: Не е въведена дата\n    BAD_DATE: Невалидна дата\n    AGO: преди\n    FROM_NOW: от сега\n    JUST_NOW: току що\n    SECOND: секунда\n    MINUTE: минута\n    HOUR: час\n    DAY: ден\n    WEEK: седмица\n    MONTH: месец\n    YEAR: година\n    DECADE: десетилетие\n    SEC: сек\n    MIN: мин\n    HR: ч\n    WK: седм\n    MO: мес\n    YR: г\n    DEC: дстлт\n    SECOND_PLURAL: секунди\n    MINUTE_PLURAL: минути\n    HOUR_PLURAL: часа\n    DAY_PLURAL: дена\n    WEEK_PLURAL: седмици\n    MONTH_PLURAL: месеца\n    YEAR_PLURAL: години\n    DECADE_PLURAL: десетилетия\n    SEC_PLURAL: сек\n    MIN_PLURAL: мин\n    HR_PLURAL: ч\n    WK_PLURAL: седм\n    MO_PLURAL: мес\n    YR_PLURAL: г\n    DEC_PLURAL: дстлт\n  FORM:\n    VALIDATION_FAIL: '<b>Неуспешна проверка:</b>'\n    INVALID_INPUT: 'Невалидно въвеждане в'\n    MISSING_REQUIRED_FIELD: 'Липсва задължително поле:'\n  MONTHS_OF_THE_YEAR:\n    - 'януари'\n    - 'февруари'\n    - 'март'\n    - 'април'\n    - 'май'\n    - 'юни'\n    - 'юли'\n    - 'август'\n    - 'септември'\n    - 'октомври'\n    - 'ноември'\n    - 'декември'\n  DAYS_OF_THE_WEEK:\n    - 'понеделник'\n    - 'вторник'\n    - 'сряда'\n    - 'четвъртък'\n    - 'петък'\n    - 'събота'\n    - 'неделя'\n  YES: \"Да\"\n  NO: \"Не\"\n  CRON:\n    EVERY: всеки\n    EVERY_HOUR: Всеки час\n    EVERY_MINUTE: Всяка минута\n    EVERY_DAY_OF_WEEK: Всеки ден от седмицата\n    EVERY_DAY_OF_MONTH: Всеки ден от месеца\n    EVERY_MONTH: Всеки месец\n"
  },
  {
    "path": "system/languages/ca.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# S'ha produït un error: Frontmatter invàlid\\n\\nRuta: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - ''\n    - 'informació'\n    - ''\n    - ''\n    - ''\n    - ''\n    - ''\n    - ''\n  NICETIME:\n    NO_DATE_PROVIDED: No s'ha proporcionat data\n    BAD_DATE: Data invàlida\n    AGO: abans\n    FROM_NOW: des d'ara\n    JUST_NOW: Ara mateix\n    SECOND: segon\n    MINUTE: minut\n    HOUR: hora\n    DAY: dia\n    WEEK: setmana\n    MONTH: mes\n    YEAR: any\n    DECADE: dècada\n    SEC: s\n    HR: h\n    WK: setm.\n    MO: m.\n    YR: a.\n    DEC: dèc.\n    SECOND_PLURAL: segons\n    MINUTE_PLURAL: minuts\n    HOUR_PLURAL: hores\n    DAY_PLURAL: dies\n    WEEK_PLURAL: setmanes\n    MONTH_PLURAL: mesos\n    YEAR_PLURAL: anys\n    DECADE_PLURAL: dècades\n    SEC_PLURAL: s\n    MIN_PLURAL: min\n    HR_PLURAL: h\n    WK_PLURAL: setm.\n    MO_PLURAL: mesos\n    YR_PLURAL: anys\n    DEC_PLURAL: dèc.\n  FORM:\n    VALIDATION_FAIL: '<b>Ha fallat la validació:</b>'\n    INVALID_INPUT: 'Entrada no vàlida a'\n    MISSING_REQUIRED_FIELD: 'Falta camp obligatori:'\n    XSS_ISSUES: \"Detectats potencials problemes XSS al camp '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'Gener'\n    - 'Febrer'\n    - 'Març'\n    - 'Abril'\n    - 'Maig'\n    - 'Juny'\n    - 'Juliol'\n    - 'Agost'\n    - 'Setembre'\n    - 'Octubre'\n    - 'Novembre'\n    - 'Desembre'\n  DAYS_OF_THE_WEEK:\n    - 'Dilluns'\n    - 'Dimarts'\n    - 'Dimecres'\n    - 'Dijous'\n    - 'Divendres'\n    - 'Dissabte'\n    - 'Diumenge'\n  YES: \"Sí\"\n  NO: \"No\"\n  CRON:\n    EVERY: cada\n    EVERY_HOUR: cada hora\n    EVERY_MINUTE: cada minut\n    EVERY_DAY_OF_WEEK: cada dia de la setmana\n    EVERY_DAY_OF_MONTH: cada dia del mes\n    EVERY_MONTH: cada mes\n    TEXT_PERIOD: Cada <b />\n    ERROR1: L'etiqueta %s no està suportada!\n    ERROR2: Nombre d'elements incorrecte\n    ERROR3: El jquery_element s'ha d'establir a la configuració de jqCron\n    ERROR4: Expressió no reconeguda\n"
  },
  {
    "path": "system/languages/cs.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Chyba: Chybná hlavička\\n\\nCesta: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'vybavení'\n    - 'informace'\n    - 'rýže'\n    - 'peníze'\n    - 'druhy'\n    - 'série'\n    - 'ryba'\n    - 'ovce'\n  INFLECTOR_IRREGULAR:\n    'person': 'lidé'\n    'man': 'muži'\n    'child': 'děti'\n    'sex': 'pohlaví'\n    'move': 'pohyby'\n  INFLECTOR_ORDINALS:\n    'default': '.'\n    'first': '.'\n    'second': '.'\n    'third': '.'\n  NICETIME:\n    NO_DATE_PROVIDED: Datum nebylo vloženo\n    BAD_DATE: Chybné datum\n    AGO: zpět\n    FROM_NOW: od teď\n    JUST_NOW: právě teď\n    SECOND: sekunda\n    MINUTE: minuta\n    HOUR: hodina\n    DAY: den\n    WEEK: týden\n    MONTH: měsíc\n    YEAR: rok\n    DECADE: dekáda\n    SEC: sek\n    MIN: min\n    HR: hod\n    WK: t\n    MO: m\n    YR: r\n    DEC: dek\n    SECOND_PLURAL: sekundy\n    MINUTE_PLURAL: minuty\n    HOUR_PLURAL: hodiny\n    DAY_PLURAL: dny\n    WEEK_PLURAL: týdny\n    MONTH_PLURAL: měsíce\n    YEAR_PLURAL: roky\n    DECADE_PLURAL: dekády\n    SEC_PLURAL: sek\n    MIN_PLURAL: min\n    HR_PLURAL: hod\n    WK_PLURAL: t\n    MO_PLURAL: m\n    YR_PLURAL: r\n    DEC_PLURAL: dek\n  FORM:\n    VALIDATION_FAIL: '<b>Ověření se nezdařilo:</b>'\n    INVALID_INPUT: 'Neplatný vstup v'\n    MISSING_REQUIRED_FIELD: 'Chybí požadované pole:'\n    XSS_ISSUES: \"Byly zjištěny možné problémy XSS v poli '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'leden'\n    - 'únor'\n    - 'březen'\n    - 'duben'\n    - 'květen'\n    - 'červen'\n    - 'červenec'\n    - 'srpen'\n    - 'září'\n    - 'říjen'\n    - 'listopad'\n    - 'prosinec'\n  DAYS_OF_THE_WEEK:\n    - 'pondělí'\n    - 'úterý'\n    - 'středa'\n    - 'čtvrtek'\n    - 'pátek'\n    - 'sobota'\n    - 'neděle'\n  YES: \"Ano\"\n  NO: \"Ne\"\n  CRON:\n    EVERY: každý\n    EVERY_HOUR: každou hodinu\n    EVERY_MINUTE: každou minutu\n    EVERY_DAY_OF_WEEK: každý den v týdnu\n    EVERY_DAY_OF_MONTH: každý den v měsíci\n    EVERY_MONTH: každý měsíc\n    TEXT_PERIOD: Every <b />\n    TEXT_MINS: ' at <b /> minute(s) past the hour'\n    TEXT_TIME: ' at <b />:<b />'\n    TEXT_DOW: ' on <b />'\n    TEXT_MONTH: ' of <b />'\n    TEXT_DOM: ' on <b />'\n    ERROR1: Tag %s není podporován!\n    ERROR2: Chybný počet prvků\n    ERROR3: jquery_element musí být nastaven v nastaveních pro jqCron\n    ERROR4: Nerozpoznaný výraz\n"
  },
  {
    "path": "system/languages/da.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nTitel: %1$s\\n---\\n\\n# Fejl: Ugyldigt frontmatter\\n\\nSti: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'udstyr'\n    - 'information'\n    - 'ris'\n    - 'penge'\n    - 'arter'\n    - 'Serier'\n    - 'fisk'\n    - 'får'\n  INFLECTOR_IRREGULAR:\n    'person': 'personer'\n    'man': 'mænd'\n    'child': 'børn'\n    'sex': 'køn'\n    'move': 'flyt'\n  NICETIME:\n    NO_DATE_PROVIDED: Ingen dato angivet\n    BAD_DATE: Ugyldig dato\n    AGO: siden\n    FROM_NOW: fra nu\n    JUST_NOW: lige nu\n    SECOND: sekund\n    MINUTE: minut\n    HOUR: time\n    DAY: dag\n    WEEK: uge\n    MONTH: måned\n    YEAR: år\n    DECADE: årti\n    SEC: sek\n    MIN: min.\n    HR: t\n    WK: u\n    MO: md\n    YR: år\n    DEC: årti\n    SECOND_PLURAL: sekunder\n    MINUTE_PLURAL: minutter\n    HOUR_PLURAL: timer\n    DAY_PLURAL: dage\n    WEEK_PLURAL: uger\n    MONTH_PLURAL: måneder\n    YEAR_PLURAL: år\n    DECADE_PLURAL: årtier\n    SEC_PLURAL: sek\n    MIN_PLURAL: min\n    HR_PLURAL: timer\n    WK_PLURAL: uger\n    MO_PLURAL: mdr\n    YR_PLURAL: år\n    DEC_PLURAL: årtier\n  FORM:\n    VALIDATION_FAIL: '<b>Validering mislykkedes:</b>'\n    INVALID_INPUT: 'Ugyldigt input i'\n    MISSING_REQUIRED_FIELD: 'Mangler obligatorisk felt:'\n  MONTHS_OF_THE_YEAR:\n    - 'januar'\n    - 'februar'\n    - 'mars'\n    - 'april'\n    - 'mai'\n    - 'juni'\n    - 'juli'\n    - 'august'\n    - 'september'\n    - 'oktober'\n    - 'november'\n    - 'desember'\n  DAYS_OF_THE_WEEK:\n    - 'mandag'\n    - 'tirsdag'\n    - 'onsdag'\n    - 'torsdag'\n    - 'fredag'\n    - 'lørdag'\n    - 'søndag'\n  CRON:\n    EVERY: hver\n    EVERY_HOUR: hver time\n    EVERY_MINUTE: hvert minut\n    EVERY_DAY_OF_WEEK: alle ugens dage\n    EVERY_DAY_OF_MONTH: alle dage i måneden\n    EVERY_MONTH: hver måned\n    TEXT_PERIOD: Hver <b />\n    TEXT_MINS: ' ved <b /> minut(ter) over timen'\n    ERROR1: Tagget %s understøttes ikke!\n    ERROR2: Ugyldigt antal elementer\n"
  },
  {
    "path": "system/languages/de.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n# Fehler: Frontmatter enthält Fehler\\n\\nPfad: `%2$s`\\n\\n**%3$s ** \\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ice'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1ies'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2ves'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'Ausstattung'\n    - 'Informationen'\n    - 'Reis'\n    - 'Geld'\n    - 'Arten'\n    - 'Serie'\n    - 'Fisch'\n    - 'Schaf'\n  INFLECTOR_IRREGULAR:\n    'person': 'Personen'\n    'man': 'Menschen'\n    'child': 'Kinder'\n    'sex': 'Geschlecht'\n    'move': 'Züge'\n  INFLECTOR_ORDINALS:\n    'default': '.'\n    'first': '.'\n    'second': '.'\n    'third': '.'\n  NICETIME:\n    NO_DATE_PROVIDED: Kein Datum angegeben\n    BAD_DATE: Falsches Datum\n    AGO: her\n    FROM_NOW: ab jetzt\n    JUST_NOW: jetzt gerade\n    SECOND: Sekunde\n    MINUTE: Minute\n    HOUR: Stunde\n    DAY: Tag\n    WEEK: Woche\n    MONTH: Monat\n    YEAR: Jahr\n    DECADE: Jahrzehnt\n    SEC: Sek.\n    MIN: Min.\n    HR: Std.\n    WK: Wo.\n    MO: Mo.\n    YR: J.\n    DEC: Dez\n    SECOND_PLURAL: Sekunden\n    MINUTE_PLURAL: Minuten\n    HOUR_PLURAL: Stunden\n    DAY_PLURAL: Tage\n    WEEK_PLURAL: Wochen\n    MONTH_PLURAL: Monate\n    YEAR_PLURAL: Jahre\n    DECADE_PLURAL: Jahrzehnte\n    SEC_PLURAL: Sekunden\n    MIN_PLURAL: Minuten\n    HR_PLURAL: Stunden\n    WK_PLURAL: Wochen\n    MO_PLURAL: Monate\n    YR_PLURAL: Jahre\n    DEC_PLURAL: Jahrzehnten\n  FORM:\n    VALIDATION_FAIL: '<b>Überprüfung fehlgeschlagen:</b>'\n    INVALID_INPUT: 'Ungültige Eingabe in'\n    MISSING_REQUIRED_FIELD: 'Erforderliches Feld fehlt:'\n    XSS_ISSUES: \"Potenzielle XSS-Probleme im Feld '%s' erkannt\"\n  MONTHS_OF_THE_YEAR:\n    - 'Januar'\n    - 'Februar'\n    - 'März'\n    - 'April'\n    - 'Mai'\n    - 'Juni'\n    - 'Juli'\n    - 'August'\n    - 'September'\n    - 'Oktober'\n    - 'November'\n    - 'Dezember'\n  DAYS_OF_THE_WEEK:\n    - 'Montag'\n    - 'Dienstag'\n    - 'Mittwoch'\n    - 'Donnerstag'\n    - 'Freitag'\n    - 'Samstag'\n    - 'Sonntag'\n  YES: \"Ja\"\n  NO: \"Nein\"\n  CRON:\n    EVERY: jede\n    EVERY_HOUR: jede Stunde\n    EVERY_MINUTE: Jede Minute\n    EVERY_DAY_OF_WEEK: jeden Tag der Woche\n    EVERY_DAY_OF_MONTH: jeden Tag des Monats\n    EVERY_MONTH: jeden Monat\n    TEXT_PERIOD: Alle <b />\n    TEXT_MINS: ' bei <b /> Minuten nach der vollen Stunde (n)'\n    TEXT_TIME: ' bei <b />:<b />'\n    TEXT_DOW: ' auf <b />'\n    TEXT_MONTH: ' von <b />'\n    TEXT_DOM: ' auf <b />'\n    ERROR1: Der Tag %s wird nicht unterstützt!\n    ERROR2: Ungültige Anzahl von Elementen\n    ERROR3: jquery_element sollte in den jqCron Einstellungen gesetzt werden\n    ERROR4: Unbekannter Ausdruck\n"
  },
  {
    "path": "system/languages/el.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nΤίτλος: %1$s\\n---\\n\\n# Σφάλμα: Μη έγκυρη διαδρομή Frontmatter\\n\\nΔιαδρομή: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'εξοπλισμός'\n    - 'πληροφοριες'\n    - 'rice'\n    - 'χρήματα'\n    - 'είδη'\n    - 'σειρές'\n    - 'ψάρι'\n    - 'πρόβατο'\n  INFLECTOR_IRREGULAR:\n    'person': 'άνθρωποι'\n    'man': 'άνδρες'\n    'child': 'παιδιά'\n    'sex': 'φύλο'\n    'move': 'κινήσεις'\n  INFLECTOR_ORDINALS:\n    'default': 'th'\n    'first': 'st'\n    'second': 'nd'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: Δεν δόθηκε καμία ημερομηνία\n    BAD_DATE: Εσφαλμένη ημερομηνία\n    AGO: πρίν\n    FROM_NOW: από τώρα\n    JUST_NOW: μόλις τώρα\n    SECOND: δευτερόλεπτο\n    MINUTE: λεπτό\n    HOUR: ώρα\n    DAY: ημέρα\n    WEEK: εβδομάδα\n    MONTH: μήνας\n    YEAR: έτος\n    DECADE: δεκαετία\n    SEC: δευτερόλεπτο\n    MIN: λεπτό\n    HR: ώρα\n    WK: εβδ\n    MO: μην\n    YR: έτος\n    DEC: δεκαετία\n    SECOND_PLURAL: δευτερόλεπτα\n    MINUTE_PLURAL: λεπτά\n    HOUR_PLURAL: ώρες\n    DAY_PLURAL: ημέρες\n    WEEK_PLURAL: εβδομάδες\n    MONTH_PLURAL: μήνες\n    YEAR_PLURAL: έτη\n    DECADE_PLURAL: δεκαετίες\n    SEC_PLURAL: δευτ.\n    MIN_PLURAL: λεπτά\n    HR_PLURAL: ώρες\n    WK_PLURAL: εβδομάδες\n    MO_PLURAL: μήνες\n    YR_PLURAL: έτη\n    DEC_PLURAL: δεκαετίες\n  FORM:\n    VALIDATION_FAIL: '<b>Η επικύρωση απέτυχε:</b>'\n    INVALID_INPUT: 'Μη έγκυρα δεδομένα σε'\n    MISSING_REQUIRED_FIELD: 'Λείπει το απαιτούμενο πεδίο:'\n  MONTHS_OF_THE_YEAR:\n    - 'Ιανουάριος'\n    - 'Φεβρουάριος'\n    - 'Μάρτιος'\n    - 'Απρίλιος'\n    - 'Μάιος'\n    - 'Ιούνιος'\n    - 'Ιούλιος'\n    - 'Αύγουστος'\n    - 'Σεπτέμβριος'\n    - 'Οκτώβριος'\n    - 'Νοέμβριος'\n    - 'Δεκέμβριος'\n  DAYS_OF_THE_WEEK:\n    - 'Δευτέρα'\n    - 'Τρίτη'\n    - 'Τετάρτη'\n    - 'Πέμπτη'\n    - 'Παρασκευή'\n    - 'Σάββατο'\n    - 'Κυριακή'\n  CRON:\n    EVERY: κάθε\n    EVERY_HOUR: κάθε ώρα\n    EVERY_MINUTE: κάθε λεπτό\n    EVERY_DAY_OF_WEEK: κάθε μέρα της εβδομάδος\n    EVERY_DAY_OF_MONTH: κάθε μέρα του μήνα\n    EVERY_MONTH: κάθε μήνα\n    TEXT_PERIOD: Κάθε <b />\n    TEXT_MINS: ' κατά <b /> λεπτό(ά) μετά την ώρα'\n    TEXT_TIME: ' στο <b />:<b />'\n    TEXT_DOW: ' στις <b />'\n    TEXT_MONTH: ' από <b />'\n    TEXT_DOM: ' στις <b />'\n    ERROR1: Η ετικέτα %s δεν υποστηρίζεται!\n    ERROR2: Μη έγκυρος αριθμός στοιχείων\n    ERROR3: Το jquery_element θα έπρεπε να οριστεί στις ρυθμίσεις του jqCron\n    ERROR4: Μη αναγνωρισμένη έκφραση\n"
  },
  {
    "path": "system/languages/en.yaml",
    "content": "---\nGRAV:\n    FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Error: Invalid Frontmatter\\n\\nPath: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n    INFLECTOR_PLURALS:\n        '/(quiz)$/i': '\\1zes'\n        '/^(ox)$/i': '\\1en'\n        '/([m|l])ouse$/i': '\\1ice'\n        '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n        '/(x|ch|ss|sh)$/i': '\\1es'\n        '/([^aeiouy]|qu)ies$/i': '\\1y'\n        '/([^aeiouy]|qu)y$/i': '\\1ies'\n        '/(hive)$/i': '\\1s'\n        '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n        '/sis$/i': 'ses'\n        '/([ti])um$/i': '\\1a'\n        '/(buffal|tomat)o$/i': '\\1oes'\n        '/(bu)s$/i': '\\1ses'\n        '/(alias|status)/i': '\\1es'\n        '/(octop|vir)us$/i': '\\1i'\n        '/(ax|test)is$/i': '\\1es'\n        '/s$/i': 's'\n        '/$/': 's'\n    INFLECTOR_SINGULAR:\n        '/(quiz)zes$/i': '\\1'\n        '/(matr)ices$/i': '\\1ix'\n        '/(vert|ind)ices$/i': '\\1ex'\n        '/^(ox)en/i': '\\1'\n        '/(alias|status)es$/i': '\\1'\n        '/([octop|vir])i$/i': '\\1us'\n        '/(cris|ax|test)es$/i': '\\1is'\n        '/(shoe)s$/i': '\\1'\n        '/(o)es$/i': '\\1'\n        '/(bus)es$/i': '\\1'\n        '/([m|l])ice$/i': '\\1ouse'\n        '/(x|ch|ss|sh)es$/i': '\\1'\n        '/(m)ovies$/i': '\\1ovie'\n        '/(s)eries$/i': '\\1eries'\n        '/([^aeiouy]|qu)ies$/i': '\\1y'\n        '/([lr])ves$/i': '\\1f'\n        '/(tive)s$/i': '\\1'\n        '/(hive)s$/i': '\\1'\n        '/([^f])ves$/i': '\\1fe'\n        '/(^analy)ses$/i': '\\1sis'\n        '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n        '/([ti])a$/i': '\\1um'\n        '/(n)ews$/i': '\\1ews'\n        '/s$/i': ''\n    INFLECTOR_UNCOUNTABLE: ['equipment', 'information', 'rice', 'money', 'species', 'series', 'fish', 'sheep']\n    INFLECTOR_IRREGULAR:\n        'person': 'people'\n        'man': 'men'\n        'child': 'children'\n        'sex': 'sexes'\n        'move': 'moves'\n    INFLECTOR_ORDINALS:\n        'default': 'th'\n        'first': 'st'\n        'second': 'nd'\n        'third': 'rd'\n    NICETIME:\n        NO_DATE_PROVIDED: No date provided\n        BAD_DATE: Bad date\n        AGO: ago\n        FROM_NOW: from now\n        JUST_NOW: just now\n        SECOND: second\n        MINUTE: minute\n        HOUR: hour\n        DAY: day\n        WEEK: week\n        MONTH: month\n        YEAR: year\n        DECADE: decade\n        SEC: sec\n        MIN: min\n        HR: hr\n        WK: wk\n        MO: mo\n        YR: yr\n        DEC: dec\n        SECOND_PLURAL: seconds\n        MINUTE_PLURAL: minutes\n        HOUR_PLURAL: hours\n        DAY_PLURAL: days\n        WEEK_PLURAL: weeks\n        MONTH_PLURAL: months\n        YEAR_PLURAL: years\n        DECADE_PLURAL: decades\n        SEC_PLURAL: secs\n        MIN_PLURAL: mins\n        HR_PLURAL: hrs\n        WK_PLURAL: wks\n        MO_PLURAL: mos\n        YR_PLURAL: yrs\n        DEC_PLURAL: decs\n    FORM:\n        VALIDATION_FAIL: '<b>Validation failed:</b>'\n        INVALID_INPUT: 'Invalid input in'\n        MISSING_REQUIRED_FIELD: 'Missing required field:'\n        XSS_ISSUES: \"Potential XSS issues detected in '%s' field\"\n    MONTHS_OF_THE_YEAR: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']\n    DAYS_OF_THE_WEEK: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']\n    YES: \"Yes\"\n    NO: \"No\"\n    CRON:\n        EVERY: every\n        EVERY_HOUR: every hour\n        EVERY_MINUTE: every minute\n        EVERY_DAY_OF_WEEK: every day of the week\n        EVERY_DAY_OF_MONTH: every day of the month\n        EVERY_MONTH: every month\n        TEXT_PERIOD: Every <b />\n        TEXT_MINS: ' at <b /> minute(s) past the hour'\n        TEXT_TIME: ' at <b />:<b />'\n        TEXT_DOW: ' on <b />'\n        TEXT_MONTH: ' of <b />'\n        TEXT_DOM: ' on <b />'\n        ERROR1: The tag %s is not supported!\n        ERROR2: Bad number of elements\n        ERROR3: The jquery_element should be set into jqCron settings\n        ERROR4: Unrecognized expression\n"
  },
  {
    "path": "system/languages/eo.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Eraro: Nevalida Frontmatter\\n\\nVojo: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/sis$/i': 'j'\n  NICETIME:\n    FROM_NOW: ekde nun\n    JUST_NOW: Ĝuste nun\n    SECOND: sekundo\n    MINUTE: minuto\n    HOUR: horo\n    DAY: tago\n    WEEK: semajno\n    MONTH: monato\n    YEAR: jaro\n    DECADE: jardeko\n    SEC: sek.\n    MIN: min.\n    HR: horo\n    SECOND_PLURAL: sekundoj\n    MINUTE_PLURAL: minutoj\n    HOUR_PLURAL: horoj\n    DAY_PLURAL: tagoj\n    WEEK_PLURAL: semajnoj\n    MONTH_PLURAL: monatoj\n    YEAR_PLURAL: jaroj\n    DECADE_PLURAL: jardekoj\n  MONTHS_OF_THE_YEAR:\n    - 'januaro'\n    - 'februaro'\n    - 'marto'\n    - 'aprilo'\n    - ''\n    - ''\n    - ''\n    - ''\n    - ''\n    - ''\n    - ''\n    - ''\n"
  },
  {
    "path": "system/languages/es.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntítulo: %1$s\\n---\\n\\n# Error: Prefacio no válido\\n\\nRuta: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1ios'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_UNCOUNTABLE:\n    - 'equipamiento'\n    - 'información'\n    - 'arroz'\n    - 'dinero'\n    - 'especies'\n    - 'series'\n    - 'pescado'\n    - 'oveja'\n  INFLECTOR_IRREGULAR:\n    'person': 'personas'\n    'man': 'hombres'\n    'child': 'niños'\n    'sex': 'sexos'\n    'move': 'movido'\n  INFLECTOR_ORDINALS:\n    'first': '.º'\n    'second': '.º'\n    'third': '.º'\n  NICETIME:\n    NO_DATE_PROVIDED: No se proporcionó fecha\n    BAD_DATE: Fecha errónea\n    AGO: antes\n    FROM_NOW: desde ahora\n    JUST_NOW: hace un momento\n    SECOND: segundo\n    MINUTE: minuto\n    HOUR: hora\n    DAY: día\n    WEEK: semana\n    MONTH: mes\n    YEAR: año\n    DECADE: década\n    SEC: seg\n    MIN: min\n    HR: h\n    WK: sem\n    MO: mes\n    YR: año\n    DEC: déc\n    SECOND_PLURAL: segundos\n    MINUTE_PLURAL: minutos\n    HOUR_PLURAL: horas\n    DAY_PLURAL: días\n    WEEK_PLURAL: semanas\n    MONTH_PLURAL: meses\n    YEAR_PLURAL: años\n    DECADE_PLURAL: décadas\n    SEC_PLURAL: segs\n    MIN_PLURAL: mins\n    HR_PLURAL: hs\n    WK_PLURAL: sem\n    MO_PLURAL: mes\n    YR_PLURAL: años\n    DEC_PLURAL: décadas\n  FORM:\n    VALIDATION_FAIL: '<b>Falló la validación: </b>'\n    INVALID_INPUT: 'Dato inválido en: '\n    MISSING_REQUIRED_FIELD: 'Falta el campo requerido: '\n    XSS_ISSUES: \"Se detectaron potenciales problemas XSS en el campo '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'Enero'\n    - 'Febrero'\n    - 'Marzo'\n    - 'Abril'\n    - 'Mayo'\n    - 'Junio'\n    - 'Julio'\n    - 'Agosto'\n    - 'Septiembre'\n    - 'Octubre'\n    - 'Noviembre'\n    - 'Diciembre'\n  DAYS_OF_THE_WEEK:\n    - 'Lunes'\n    - 'Martes'\n    - 'Miércoles'\n    - 'Jueves'\n    - 'Viernes'\n    - 'Sábado'\n    - 'Domingo'\n  YES: \"Sí\"\n  NO: \"No\"\n  CRON:\n    EVERY: cada\n    EVERY_HOUR: cada hora\n    EVERY_MINUTE: cada minuto\n    EVERY_DAY_OF_WEEK: cada día de la semana\n    EVERY_DAY_OF_MONTH: cada día del mes\n    EVERY_MONTH: cada mes\n    TEXT_PERIOD: Cada <b />\n    TEXT_MINS: ' a <b /> minuto(s) después de la hora'\n    TEXT_TIME: ' a <b />:<b />'\n    TEXT_DOW: ' en <b />'\n    TEXT_MONTH: ' de<b />'\n    TEXT_DOM: ' en<b />'\n    ERROR1: No se admite la etiqueta %s.\n    ERROR2: El número de elementos es erróneo\n    ERROR3: El jquery_element debería establecerse en la configuración del jqCron\n    ERROR4: Expresión no reconocida\n"
  },
  {
    "path": "system/languages/et.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\npealkiri: %1$s\\n---\\n\\n# Viga: vigane Frontmatter'i\\n\\nasukoht: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(octop|vir)us$/i': '\\1i'\n  INFLECTOR_SINGULAR:\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n  INFLECTOR_UNCOUNTABLE:\n    - ''\n    - 'informatsioon'\n    - 'riis'\n    - 'raha'\n    - ''\n    - ''\n    - 'kala'\n    - 'lammas'\n  INFLECTOR_IRREGULAR:\n    'person': 'inimesed'\n    'man': 'mees'\n    'child': 'lapsed'\n  INFLECTOR_ORDINALS:\n    'default': '.'\n    'first': '.'\n    'second': '.'\n    'third': '.'\n  NICETIME:\n    NO_DATE_PROVIDED: Kuupäev määramata\n    BAD_DATE: Vigane kuupäev\n    AGO: tagasi\n    FROM_NOW: praegusest\n    JUST_NOW: just nüüd\n    SECOND: sekund\n    MINUTE: minut\n    HOUR: tundi\n    DAY: päev\n    WEEK: nädal\n    MONTH: kuu\n    YEAR: aasta\n    DECADE: 10 aastat\n    SEC: sek\n    MIN: min\n    HR: t\n    WK: näd\n    MO: k.\n    YR: a.\n    DEC: dekaad\n    SECOND_PLURAL: sekundit\n    MINUTE_PLURAL: minutit\n    HOUR_PLURAL: tundi\n    DAY_PLURAL: päeva\n    WEEK_PLURAL: nädalat\n    MONTH_PLURAL: kuud\n    YEAR_PLURAL: aastat\n    DECADE_PLURAL: dekaadi\n    SEC_PLURAL: sekundit\n    MIN_PLURAL: min\n    HR_PLURAL: t\n    WK_PLURAL: näd\n    MO_PLURAL: kuud\n    YR_PLURAL: aastat\n    DEC_PLURAL: dek.\n  FORM:\n    VALIDATION_FAIL: '<b>Kinnitamine nurjus:</b>'\n    INVALID_INPUT: 'Vigane sisend:'\n    MISSING_REQUIRED_FIELD: 'Nõutud väli puudub:'\n    XSS_ISSUES: \"Tuvastasime '%s' väljal võimaliku XSS-riski\"\n  MONTHS_OF_THE_YEAR:\n    - 'jaanuar'\n    - 'veebruar'\n    - 'märts'\n    - 'aprill'\n    - 'mai'\n    - 'juuni'\n    - 'juuli'\n    - 'august'\n    - 'september'\n    - 'oktoober'\n    - 'november'\n    - 'detsember'\n  DAYS_OF_THE_WEEK:\n    - 'esmaspäev'\n    - 'teisipäev'\n    - 'kolmapäev'\n    - 'neljapäev'\n    - 'reede'\n    - 'laupäev'\n    - 'pühapäev'\n  YES: \"Jah\"\n  NO: \"Ei\"\n  CRON:\n    EVERY: iga\n    EVERY_HOUR: iga tund\n    EVERY_MINUTE: iga minut\n    EVERY_DAY_OF_WEEK: nädala igal päeval\n    EVERY_DAY_OF_MONTH: kuu igal päeval\n    EVERY_MONTH: iga kuu\n    TEXT_PERIOD: Iga <b />\n    ERROR1: Silt %s pole toetatud!\n    ERROR2: Vale elementide arv\n    ERROR3: jqCron seadetes peaks olema määratud jquery_element\n    ERROR4: Tundmatu väljend\n"
  },
  {
    "path": "system/languages/eu.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"--- title: %1$s --- # Errorea: Baliogabeko Frontmatter Bidea: `%2$s` **%3$s** ``` %4$s ```\"\n  NICETIME:\n    NO_DATE_PROVIDED: Ez da datarik ezarri\n    BAD_DATE: Okerreko data\n    AGO: ' duela'\n    FROM_NOW: oraindik aurrera\n    SECOND: segundo\n    MINUTE: minutu\n    HOUR: ordua\n    DAY: egun\n    WEEK: astea\n    MONTH: hilabetea\n    YEAR: urtea\n    DECADE: hamarkada\n    SEC: seg\n    HR: h\n    WK: ast\n    MO: hil\n    YR: urt\n    DEC: ham\n    SECOND_PLURAL: segundo\n    MINUTE_PLURAL: minutu\n    HOUR_PLURAL: ordu\n    DAY_PLURAL: egun\n    WEEK_PLURAL: aste\n    MONTH_PLURAL: hilabete\n    YEAR_PLURAL: urte\n    DECADE_PLURAL: hamarkada\n    SEC_PLURAL: segundo\n    MIN_PLURAL: minutu\n    HR_PLURAL: h\n    WK_PLURAL: ast\n    MO_PLURAL: hil\n    YR_PLURAL: urt\n    DEC_PLURAL: ham\n  FORM:\n    VALIDATION_FAIL: '<b>Balidazioak huts egin du</b>'\n    INVALID_INPUT: 'Baliogabeko sarrera'\n    MISSING_REQUIRED_FIELD: 'Derrigorrezko eremua bete gabe:'\n  MONTHS_OF_THE_YEAR:\n    - 'Urtarrila'\n    - 'Otsaila'\n    - 'Martxoa'\n    - 'Apirila'\n    - 'Maiatza'\n    - 'Ekaina'\n    - 'Uztaila'\n    - 'Abuztua'\n    - 'Iraila'\n    - 'Urria'\n    - 'Azaroa'\n    - 'Abendua'\n  DAYS_OF_THE_WEEK:\n    - 'Astelehena'\n    - 'Asteartea'\n    - 'Azteazkena'\n    - 'Osteguna'\n    - 'Ostirala'\n    - 'Larunbata'\n    - 'Igandea'\n"
  },
  {
    "path": "system/languages/fa.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nعنوان: %1$s\\n---\\n\\n# خطا: Frontmatter غلط\\n\\nمسیر: %2$s\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  NICETIME:\n    NO_DATE_PROVIDED: تاریخی ارائه نشده\n    BAD_DATE: تاریخ اشتباه\n    AGO: قبل\n    FROM_NOW: از حالا\n    SECOND: ثانیه\n    MINUTE: دقیقه\n    HOUR: ساعت\n    DAY: روز\n    WEEK: هفته\n    MONTH: ماه\n    YEAR: سال\n    DECADE: دهه\n    SEC: ثانیه\n    MIN: دقیقه\n    HR: ساعت\n    WK: هفته\n    MO: ماه\n    YR: سال\n    DEC: دهه\n    SECOND_PLURAL: ثانیه\n    MINUTE_PLURAL: دقیقه\n    HOUR_PLURAL: ساعت\n    DAY_PLURAL: روز\n    WEEK_PLURAL: هفته\n    MONTH_PLURAL: ماه\n    YEAR_PLURAL: سال\n    DECADE_PLURAL: دهه\n    SEC_PLURAL: ثانیه\n    MIN_PLURAL: دقیقه\n    HR_PLURAL: ساعت\n    WK_PLURAL: هفته\n    YR_PLURAL: سال\n    DEC_PLURAL: دهه\n  FORM:\n    VALIDATION_FAIL: '<b>سنجش اعتبار ناموفق بود</b>'\n    INVALID_INPUT: 'ورودی نامعتبر در'\n    MISSING_REQUIRED_FIELD: 'قسمت ضروری جا افتاده:'\n  MONTHS_OF_THE_YEAR:\n    - 'ژانویه'\n    - 'فوریه'\n    - 'مارس'\n    - 'آوریل'\n    - 'می'\n    - 'ژوئن'\n    - 'ژوئیه'\n    - 'اوت'\n    - 'سپتامبر'\n    - 'اکتبر'\n    - 'نوامبر'\n    - 'دسامبر'\n  DAYS_OF_THE_WEEK:\n    - 'دوشنبه'\n    - 'سه‌ شنبه'\n    - 'چهارشنبه'\n    - 'پنج شنبه'\n    - 'جمعه'\n    - 'شنبه'\n    - 'یک‌شنبه'\n"
  },
  {
    "path": "system/languages/fi.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\notsikko: %1$s\\n---\\n\\n# Virhe: Virheellinen Frontmatter\\n\\nPolku: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - ''\n    - ''\n    - 'riisi'\n    - 'raha'\n    - 'lajit'\n    - ''\n    - 'kala'\n    - 'lammas'\n  INFLECTOR_IRREGULAR:\n    'person': 'ihmiset'\n    'man': 'miehet'\n    'child': 'lapset'\n    'sex': 'sukupuoli'\n  INFLECTOR_ORDINALS:\n    'default': '.'\n    'first': '.'\n    'second': '.'\n    'third': '.'\n  NICETIME:\n    NO_DATE_PROVIDED: Päivämäärää ei annettu\n    BAD_DATE: Virheellinen päivämäärä\n    AGO: sitten\n    FROM_NOW: tästä lähtien\n    JUST_NOW: juuri nyt\n    SECOND: sekuntti\n    MINUTE: minuutti\n    HOUR: tunti\n    DAY: päivä\n    WEEK: viikko\n    MONTH: kuukausi\n    YEAR: vuosi\n    DECADE: vuosikymmen\n    SEC: sek\n    MIN: min\n    HR: h\n    WK: vk\n    MO: kk\n    YR: v\n    DEC: vuosikymmen\n    SECOND_PLURAL: sekuntia\n    MINUTE_PLURAL: minuuttia\n    HOUR_PLURAL: tuntia\n    DAY_PLURAL: päivää\n    WEEK_PLURAL: viikkoa\n    MONTH_PLURAL: kuukautta\n    YEAR_PLURAL: vuotta\n    DECADE_PLURAL: vuosikymmentä\n    SEC_PLURAL: sek\n    MIN_PLURAL: min\n    HR_PLURAL: h\n    WK_PLURAL: v\n    MO_PLURAL: kk\n    YR_PLURAL: v\n    DEC_PLURAL: vuosikymmentä\n  FORM:\n    VALIDATION_FAIL: '<b>Vahvistus epäonnistui:</b>'\n    INVALID_INPUT: 'Syöte ei kelpaa'\n    MISSING_REQUIRED_FIELD: 'Puuttuva pakollinen kenttä:'\n  MONTHS_OF_THE_YEAR:\n    - 'Tammikuu'\n    - 'Helmikuu'\n    - 'Maaliskuu'\n    - 'Huhtikuu'\n    - 'Toukokuu'\n    - 'Kesäkuuta'\n    - 'Heinäkuu'\n    - 'Elokuu'\n    - 'Syyskuu'\n    - 'Lokakuu'\n    - 'Marraskuu'\n    - 'Joulukuu'\n  DAYS_OF_THE_WEEK:\n    - 'Maanantai'\n    - 'Tiistai'\n    - 'Keskiviikko'\n    - 'Torstai'\n    - 'Perjantai'\n    - 'Lauantai'\n    - 'Sunnuntai'\n  CRON:\n    EVERY: joka\n    EVERY_HOUR: joka tunti\n    EVERY_MINUTE: joka minuutti\n    EVERY_DAY_OF_WEEK: viikon jokaisena päivänä\n    EVERY_DAY_OF_MONTH: kuukauden jokaisena päivänä\n    EVERY_MONTH: joka kuukausi\n    TEXT_PERIOD: Joka <b />\n"
  },
  {
    "path": "system/languages/fr.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitre: %1$s\\n---\\n\\n# Erreur : Frontmatter invalide\\n\\nChemin: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1es'\n    '/(bu)s$/i': 'Bus'\n    '/(alias|status)/i': 'alias|status'\n    '/(octop|vir)us$/i': 'virus'\n    '/(ax|test)is$/i': '\\1s'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ouvelles'\n  INFLECTOR_UNCOUNTABLE:\n    - 'équipement'\n    - 'information'\n    - 'riz'\n    - 'argent'\n    - 'espèces'\n    - 'séries'\n    - 'poisson'\n    - 'mouton'\n  INFLECTOR_IRREGULAR:\n    'person': 'personnes'\n    'man': 'hommes'\n    'child': 'enfants'\n    'sex': 'sexes'\n    'move': 'déplacements'\n  INFLECTOR_ORDINALS:\n    'default': 'ème'\n    'first': 'er'\n    'second': 'ème'\n    'third': 'ème'\n  NICETIME:\n    NO_DATE_PROVIDED: Aucune date fournie\n    BAD_DATE: Date erronée\n    AGO: plus tôt\n    FROM_NOW: à partir de maintenant\n    JUST_NOW: à l'instant\n    SECOND: seconde\n    MINUTE: minute\n    HOUR: heure\n    DAY: jour\n    WEEK: semaine\n    MONTH: mois\n    YEAR: année\n    DECADE: décennie\n    SEC: sec.\n    MIN: min.\n    HR: hr.\n    WK: sem.\n    MO: m\n    YR: an\n    DEC: déc\n    SECOND_PLURAL: secondes\n    MINUTE_PLURAL: minutes\n    HOUR_PLURAL: heures\n    DAY_PLURAL: jours\n    WEEK_PLURAL: semaines\n    MONTH_PLURAL: mois\n    YEAR_PLURAL: années\n    DECADE_PLURAL: décennies\n    SEC_PLURAL: s\n    MIN_PLURAL: m\n    HR_PLURAL: h\n    WK_PLURAL: sem\n    MO_PLURAL: mois\n    YR_PLURAL: a\n    DEC_PLURAL: décs\n  FORM:\n    VALIDATION_FAIL: '<b>La validation a échoué :</b>'\n    INVALID_INPUT: 'Saisie non valide'\n    MISSING_REQUIRED_FIELD: 'Champ obligatoire manquant :'\n    XSS_ISSUES: \"Erreurs XSS probablement détectées dans le champ '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'janvier'\n    - 'février'\n    - 'mars'\n    - 'avril'\n    - 'mai'\n    - 'juin'\n    - 'juillet'\n    - 'août'\n    - 'septembre'\n    - 'octobre'\n    - 'novembre'\n    - 'décembre'\n  DAYS_OF_THE_WEEK:\n    - 'lundi'\n    - 'mardi'\n    - 'mercredi'\n    - 'jeudi'\n    - 'vendredi'\n    - 'samedi'\n    - 'dimanche'\n  YES: \"Oui\"\n  NO: \"Non\"\n  CRON:\n    EVERY: chaque\n    EVERY_HOUR: toutes les heures\n    EVERY_MINUTE: chaque minute\n    EVERY_DAY_OF_WEEK: tous les jours de la semaine\n    EVERY_DAY_OF_MONTH: tous les jours du mois\n    EVERY_MONTH: chaque mois\n    TEXT_PERIOD: Chaque<b/>\n    TEXT_MINS: ' à <b /> minute(s) après l''heure'\n    TEXT_TIME: ' à<b/>:<b/>'\n    TEXT_DOW: ' sur <b/>'\n    TEXT_MONTH: ' de <b />'\n    TEXT_DOM: ' sur <b/>'\n    ERROR1: La balise %s n'est pas prise en charge !\n    ERROR2: Nombre invalide d'éléments\n    ERROR3: L'élément jquery_element doit être défini dans les paramètres jqCron\n    ERROR4: Expression non reconnue\n"
  },
  {
    "path": "system/languages/gl.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntítulo: %1$s\\n---\\n\\n# Erro: Limiar incorrecto\\n\\nRuta: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1'\n    '/(octop|vir)us$/i': '\\1'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1ces'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1'\n    '/(cris|ax|test)es$/i': '\\1es'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1se'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2se'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'equipo'\n    - 'información'\n    - 'arroz'\n    - 'diñeiro'\n    - 'especies'\n    - 'series'\n    - 'peixe'\n    - 'ovella'\n  INFLECTOR_IRREGULAR:\n    'person': 'xente'\n    'man': 'home'\n    'child': 'neno'\n    'sex': 'sexos'\n    'move': 'move'\n  INFLECTOR_ORDINALS:\n    'default': 'º'\n    'first': 'º'\n    'second': 'º'\n    'third': 'º'\n  NICETIME:\n    NO_DATE_PROVIDED: Non fornece unha data\n    BAD_DATE: Data errada\n    AGO: hai\n    FROM_NOW: dende agora\n    JUST_NOW: xusto agora\n    SECOND: segundo\n    MINUTE: minuto\n    HOUR: hora\n    DAY: día\n    WEEK: semana\n    MONTH: mes\n    YEAR: ano\n    DECADE: década\n    SEC: seg\n    MIN: min\n    HR: hr \n    WK: Sem\n    MO: m\n    YR: a\n    DEC: dec\n    SECOND_PLURAL: segundos\n    MINUTE_PLURAL: minutos\n    HOUR_PLURAL: horas\n    DAY_PLURAL: días\n    WEEK_PLURAL: semanas\n    MONTH_PLURAL: meses\n    YEAR_PLURAL: anos\n    DECADE_PLURAL: décadas\n    SEC_PLURAL: segs\n    MIN_PLURAL: mins\n    HR_PLURAL: hrs\n    WK_PLURAL: sem\n    MO_PLURAL: mes\n    YR_PLURAL: a\n    DEC_PLURAL: deca\n  FORM:\n    VALIDATION_FAIL: '<b>Fallou a validación:</b>'\n    INVALID_INPUT: 'Entrada incorrecta en'\n    MISSING_REQUIRED_FIELD: 'Falta un campo requirido:'\n    XSS_ISSUES: \"Detectáronse posibles problemas XSS no campo '% s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'xaneiro'\n    - 'febreiro'\n    - 'marzo'\n    - 'abril'\n    - 'maio'\n    - 'xuño'\n    - 'xullo'\n    - 'agosto'\n    - 'setembro'\n    - 'outubro'\n    - 'novembro'\n    - 'decembro'\n  DAYS_OF_THE_WEEK:\n    - 'luns'\n    - 'martes'\n    - 'mércores'\n    - 'xoves'\n    - 'venres'\n    - 'sábado'\n    - 'domingo'\n  YES: \"Si\"\n  NO: \"Non\"\n  CRON:\n    EVERY: cada\n    EVERY_HOUR: Cada hora\n    EVERY_MINUTE: Cada minuto\n    EVERY_DAY_OF_WEEK: cada día da semana\n    EVERY_DAY_OF_MONTH: cada día do mes\n    EVERY_MONTH: cada mes\n    TEXT_PERIOD: Cada <b />\n    TEXT_MINS: ' dentro de <b /> minuto(s) despois da hora'\n    TEXT_TIME: ' dentro <b />:<b />'\n    TEXT_DOW: ' o <b />'\n    TEXT_MONTH: ' de <b />'\n    TEXT_DOM: ' o <b />'\n    ERROR1: A etiqueta %s non é compatíbel!\n    ERROR2: Mal número de elementos\n    ERROR3: O jquery_element debería estar determinado na configuración de jqCron\n    ERROR4: Expresión non recoñecida\n"
  },
  {
    "path": "system/languages/he.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nכותרת: %1$s\\n---\\n# שגיאה: Fronmatter לא חוקי\\nנתיב: `%2$s`\\n**%3$s**\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'ציוד'\n    - 'מידע'\n    - 'אורז'\n    - 'כסף'\n    - 'מינים'\n    - 'סדרה'\n    - 'דג'\n    - 'כבשה'\n  INFLECTOR_IRREGULAR:\n    'person': 'אנשים'\n    'man': 'גברים'\n    'child': 'ילדים'\n    'sex': 'מינים'\n    'move': 'מהלכים'\n  NICETIME:\n    NO_DATE_PROVIDED: לא סופק תאריך\n    BAD_DATE: תאריך פגום\n    AGO: לפני\n    FROM_NOW: כרגע\n    JUST_NOW: כרגע\n    SECOND: שנייה\n    MINUTE: דקה\n    HOUR: שעה\n    DAY: יום\n    WEEK: שבוע\n    MONTH: חודש\n    YEAR: שנה\n    DECADE: עשור\n    SEC: שנ'\n    MIN: דק'\n    HR: ש'\n    WK: שב'\n    MO: חו'\n    YR: שני'\n    DEC: עש'\n    SECOND_PLURAL: שניות\n    MINUTE_PLURAL: דקות\n    HOUR_PLURAL: שעות\n    DAY_PLURAL: ימים\n    WEEK_PLURAL: שבועות\n    MONTH_PLURAL: חודשים\n    YEAR_PLURAL: שנים\n    DECADE_PLURAL: עשורים\n    SEC_PLURAL: שנ'\n    MIN_PLURAL: דק'\n    HR_PLURAL: ש'\n    WK_PLURAL: שב'\n    MO_PLURAL: חו'\n    YR_PLURAL: שני'\n    DEC_PLURAL: עש'\n  FORM:\n    VALIDATION_FAIL: '<b>האימות נכשל:</b>'\n    INVALID_INPUT: 'קלט לא חוקי'\n    MISSING_REQUIRED_FIELD: 'שדות חובה חסרים:'\n    XSS_ISSUES: \"בעיות XSS פוטנציאליות זוהו בשדה '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'ינואר'\n    - 'פברואר'\n    - 'מרץ'\n    - 'אפריל'\n    - 'מאי'\n    - 'יוני'\n    - 'יולי'\n    - 'אוגוסט'\n    - 'ספטמבר'\n    - 'אוקטובר'\n    - 'נובמבר'\n    - 'דצמבר'\n  DAYS_OF_THE_WEEK:\n    - 'שני'\n    - 'שלישי'\n    - 'רביעי'\n    - 'חמישי'\n    - 'שישי'\n    - 'שבת'\n    - 'ראשון'\n  YES: \"כן\"\n  NO: \"לא\"\n  CRON:\n    EVERY: בכל\n    EVERY_HOUR: בכל שעה\n    EVERY_MINUTE: כל דקה\n    EVERY_DAY_OF_WEEK: כל יום בשבוע\n    EVERY_DAY_OF_MONTH: בכל יום בחודש\n    EVERY_MONTH: כל חודש\n    TEXT_PERIOD: כל  <b />\n    TEXT_MINS: 'ב <b /> דקות אחרי השעה'\n    TEXT_TIME: 'ב  <b />:<b />'\n    TEXT_DOW: 'ב  <b />'\n    TEXT_MONTH: 'של  <b />'\n    TEXT_DOM: 'ב  <b />'\n    ERROR1: התגית %s אינו נתמכת\n    ERROR2: מספר לא חוקי של משתנים.\n    ERROR3: יש להגדיר את ה-jquery_element להגדרות jqCron\n    ERROR4: ביטוי לא מזוהה\n"
  },
  {
    "path": "system/languages/hr.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nnaslov: %1$s\\n---\\n\\n# Pogreška: nevažeći frontmatter\\n\\nPutanja datoteke: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'oprema'\n    - 'informacija'\n    - 'riža'\n    - 'novac'\n    - 'vrsta'\n    - 'serija'\n    - 'riba'\n    - 'ovca'\n  INFLECTOR_IRREGULAR:\n    'person': 'osobe'\n    'man': 'ljudi'\n    'child': 'djeca'\n    'sex': 'spolovi'\n    'move': 'Pomakni'\n  INFLECTOR_ORDINALS:\n    'default': '.'\n    'first': '.'\n    'second': '.'\n    'third': '.'\n  NICETIME:\n    NO_DATE_PROVIDED: Datum nije upisan\n    BAD_DATE: Pogrešan datum\n    AGO: prije\n    FROM_NOW: od sada\n    JUST_NOW: upravo sad\n    SECOND: sekunda\n    MINUTE: minuta\n    HOUR: sat\n    DAY: dan\n    WEEK: tjedan\n    MONTH: mjesec\n    YEAR: godina\n    DECADE: desetljeće\n    SEC: sek\n    MIN: min\n    HR: sat\n    WK: t\n    MO: m\n    YR: g\n    DEC: des\n    SECOND_PLURAL: sekundi\n    MINUTE_PLURAL: minuta\n    HOUR_PLURAL: sati\n    DAY_PLURAL: dan\n    WEEK_PLURAL: tjedana\n    MONTH_PLURAL: mjeseci\n    YEAR_PLURAL: godina\n    DECADE_PLURAL: desetljeća\n    SEC_PLURAL: sek\n    MIN_PLURAL: min\n    HR_PLURAL: sat\n    WK_PLURAL: t\n    MO_PLURAL: m\n    YR_PLURAL: g\n    DEC_PLURAL: des\n  FORM:\n    VALIDATION_FAIL: '<b>Validacija nije uspjela:</b>'\n    INVALID_INPUT: 'Pogrešan unos u'\n    MISSING_REQUIRED_FIELD: 'Nedostaje obavezno polje:'\n    XSS_ISSUES: \"Potencijalni XSS problemi otkriveni u polju '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'Siječanj'\n    - 'Veljača'\n    - 'Ožujak'\n    - 'Travanj'\n    - 'Svibanj'\n    - 'Lipanj'\n    - 'Srpanj'\n    - 'Kolovoz'\n    - 'Rujan'\n    - 'Listopad'\n    - 'Studeni'\n    - 'Prosinac'\n  DAYS_OF_THE_WEEK:\n    - 'Ponedjeljak'\n    - 'Utorak'\n    - 'Srijeda'\n    - 'Četvrtak'\n    - 'Petak'\n    - 'Subota'\n    - 'Nedjelja'\n  YES: \"Da\"\n  NO: \"Ne\"\n  CRON:\n    EVERY: svaki\n    EVERY_HOUR: svaki sat\n    EVERY_MINUTE: svake minute\n    EVERY_DAY_OF_WEEK: svaki dan u tjednu\n    EVERY_DAY_OF_MONTH: svaki dan u mjesecu\n    EVERY_MONTH: svaki mjesec\n    TEXT_PERIOD: Svakih <b />\n    TEXT_MINS: ' u <b /> minut(e) nakon sata'\n    TEXT_TIME: ' u <b />:<b />'\n    TEXT_DOW: ' na <b />'\n    TEXT_MONTH: '  <b />'\n    TEXT_DOM: ' na <b />'\n    ERROR1: Oznaka %s nije podržana!\n    ERROR2: Pogrešan broj elemenata.\n    ERROR3: jquery_element treba postaviti u postavke jqCron\n    ERROR4: Izraz nije prepoznat\n"
  },
  {
    "path": "system/languages/hu.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ncím: %1$s\\n---\\n\\n# Hiba: Érvénytelen Frontmatter\\n\\nElérési út: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'felszerelés'\n    - 'információ'\n    - 'rizs'\n    - 'pénz'\n    - 'fajok'\n    - 'sorozat'\n    - 'hal'\n    - 'juh'\n  INFLECTOR_IRREGULAR:\n    'person': 'személyek'\n    'man': 'férfiak'\n    'child': 'gyerekek'\n    'sex': 'nemek'\n    'move': 'lépések'\n  INFLECTOR_ORDINALS:\n    'default': '.'\n    'first': '.'\n    'second': '.'\n    'third': '.'\n  NICETIME:\n    NO_DATE_PROVIDED: Nincs dátum megadva\n    BAD_DATE: Hibás dátum\n    AGO: elteltével\n    FROM_NOW: mostantól\n    JUST_NOW: épp most\n    SECOND: másodperc\n    MINUTE: perc\n    HOUR: óra\n    DAY: nap\n    WEEK: hét\n    MONTH: hónap\n    YEAR: év\n    DECADE: évtized\n    SEC: mp\n    MIN: p\n    HR: ó\n    WK: hét\n    MO: hó\n    YR: év\n    DEC: évt\n    SECOND_PLURAL: másodperc\n    MINUTE_PLURAL: perc\n    HOUR_PLURAL: óra\n    DAY_PLURAL: nap\n    WEEK_PLURAL: hét\n    MONTH_PLURAL: hónap\n    YEAR_PLURAL: év\n    DECADE_PLURAL: évtized\n    SEC_PLURAL: mp\n    MIN_PLURAL: perc\n    HR_PLURAL: ó\n    WK_PLURAL: hét\n    MO_PLURAL: hó\n    YR_PLURAL: év\n    DEC_PLURAL: évt\n  FORM:\n    VALIDATION_FAIL: '<b>Érvényesítés nem sikerült:</b>'\n    INVALID_INPUT: 'A megadott érték érvénytelen:'\n    MISSING_REQUIRED_FIELD: 'Ez a kötelező mező nincs kitöltve:'\n  MONTHS_OF_THE_YEAR:\n    - 'január'\n    - 'február'\n    - 'március'\n    - 'április'\n    - 'május'\n    - 'június'\n    - 'július'\n    - 'augusztus'\n    - 'szeptember'\n    - 'október'\n    - 'november'\n    - 'december'\n  DAYS_OF_THE_WEEK:\n    - 'hétfő'\n    - 'kedd'\n    - 'szerda'\n    - 'csütörtök'\n    - 'péntek'\n    - 'szombat'\n    - 'vasárnap'\n  CRON:\n    EVERY: minden\n    EVERY_HOUR: óránként\n    EVERY_MINUTE: percenként\n    EVERY_DAY_OF_WEEK: a hét minden napján\n    EVERY_DAY_OF_MONTH: a hónap minden napján\n    EVERY_MONTH: minden hónapban\n    TEXT_PERIOD: Minden <b />\n    TEXT_MINS: '<b /> perccel az óra elteltével'\n    ERROR1: A %s címke nem engedélyezett!\n    ERROR2: Hibás elemszám\n    ERROR3: A jquery_element-et a jqCron beállítsokban kell meghatározni\n    ERROR4: Ismeretlen kifejezés\n"
  },
  {
    "path": "system/languages/id.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Error: Frontmatter tidak valid\\n\\nLokasi: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'Peralatan'\n    - 'Informasi '\n    - 'Nasi'\n    - 'Uang'\n    - 'Jenis'\n    - 'Seri'\n    - 'Ikan'\n    - 'Domba'\n  INFLECTOR_IRREGULAR:\n    'person': 'Orang-orang'\n    'man': 'Pria'\n    'child': 'Balita'\n    'sex': 'Jenis Kelamin'\n    'move': 'pindahkan'\n  INFLECTOR_ORDINALS:\n    'default': 'ke'\n    'first': 'pertama'\n    'second': 'nd'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: Tidak ada tanggal yang disediakan\n    BAD_DATE: Format tanggal salah\n    AGO: yang lalu\n    FROM_NOW: dari sekarang\n    JUST_NOW: baru saja\n    SECOND: detik\n    MINUTE: menit\n    HOUR: jam\n    DAY: hari\n    WEEK: pekan\n    MONTH: bulan\n    YEAR: tahun\n    DECADE: dekade\n    SEC: detik\n    MIN: menit\n    HR: ' jam'\n    WK: minggu\n    MO: bulan\n    YR: tahun\n    DEC: desimal\n    SECOND_PLURAL: detik\n    MINUTE_PLURAL: menit\n    HOUR_PLURAL: jam\n    DAY_PLURAL: hari\n    WEEK_PLURAL: pekan\n    MONTH_PLURAL: bulan\n    YEAR_PLURAL: tahun\n    DECADE_PLURAL: dekade\n    SEC_PLURAL: detik\n    MIN_PLURAL: menit\n    HR_PLURAL: jam\n    WK_PLURAL: minggu\n    MO_PLURAL: bulan\n    YR_PLURAL: tahun\n    DEC_PLURAL: dekade\n  FORM:\n    VALIDATION_FAIL: '<b>Validasi gagal:</b>'\n    INVALID_INPUT: 'Input tidak valid di'\n    MISSING_REQUIRED_FIELD: 'Data yang diperlukan belum terisi:'\n    XSS_ISSUES: \"Isu berpotensial XSS terdeteksi dalam baris %s\"\n  MONTHS_OF_THE_YEAR:\n    - 'Januari'\n    - 'Februari'\n    - 'Maret'\n    - 'April'\n    - 'Mei'\n    - 'Juni'\n    - 'Juli'\n    - 'Agustus'\n    - 'September'\n    - 'Oktober'\n    - 'November'\n    - 'Desember'\n  DAYS_OF_THE_WEEK:\n    - 'Senin'\n    - 'Selasa'\n    - 'Rabu'\n    - 'Kamis'\n    - 'Jum''at'\n    - 'Sabtu'\n    - 'Minggu'\n  YES: \"Ya\"\n  NO: \"Tidak\"\n  CRON:\n    EVERY: Setiap\n    EVERY_HOUR: Setiap jam\n    EVERY_MINUTE: Setiap menit\n    EVERY_DAY_OF_WEEK: Setiap hari selama seminggu\n    EVERY_DAY_OF_MONTH: Setiap hari dalam sebulan\n    EVERY_MONTH: setiap bulan\n    TEXT_PERIOD: Setiap <b />\n    TEXT_MINS: 'dalam <b />  menit setelah jam yang lalu'\n    TEXT_TIME: ' pada <b />:<b />'\n    TEXT_DOW: ' pada <b />'\n    TEXT_MONTH: ' pada <b />'\n    TEXT_DOM: ' pada <b />'\n    ERROR1: Tag %s tidak didukung!\n    ERROR2: Jumlah elemen yang buruk\n    ERROR3: jquery_element harus diatur ke dalam pengaturan jqCron\n    ERROR4: Ekspresi tidak dikenal\n"
  },
  {
    "path": "system/languages/is.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitill: %1$s\\n---\\n\\n# Villa: Ógilt efni á forsíðu\\n\\nSlóð: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - ''\n    - 'upplýsingar'\n    - ''\n    - ''\n    - ''\n    - ''\n    - ''\n    - ''\n  NICETIME:\n    NO_DATE_PROVIDED: Engin dagsetning gefin\n    BAD_DATE: Röng dagsetning\n    AGO: síðan\n    JUST_NOW: í þessu\n    SECOND: sekúndu\n    MINUTE: mínútu\n    HOUR: klukkustund\n    DAY: degi\n    WEEK: viku\n    MONTH: mánuði\n    YEAR: ári\n    DECADE: áratug\n    SEC: sek\n    MIN: mín\n    HR: klst\n    WK: vk\n    MO: mán\n    YR: ár\n    DEC: árat\n    SECOND_PLURAL: sekúndum\n    MINUTE_PLURAL: mínútum\n    HOUR_PLURAL: klukkustundum\n    DAY_PLURAL: dögum\n    WEEK_PLURAL: vikum\n    MONTH_PLURAL: mánuðum\n    YEAR_PLURAL: árum\n    DECADE_PLURAL: áratugum\n    SEC_PLURAL: sek\n    MIN_PLURAL: mín\n    HR_PLURAL: klst\n    WK_PLURAL: vik\n    MO_PLURAL: mán\n    YR_PLURAL: árum\n    DEC_PLURAL: árat\n  FORM:\n    VALIDATION_FAIL: '<b>Sannvottun mistókst:</b>'\n    INVALID_INPUT: 'Ógilt inntak í'\n    MISSING_REQUIRED_FIELD: 'Vantar nauðsynlegan reit:'\n  MONTHS_OF_THE_YEAR:\n    - 'janúar'\n    - 'Febrúar'\n    - 'Mars'\n    - 'Apríl'\n    - 'Maí'\n    - 'Júní'\n    - 'Júlí'\n    - 'Ágúst'\n    - 'September'\n    - 'Október'\n    - 'Nóvember'\n    - 'Desember'\n  DAYS_OF_THE_WEEK:\n    - 'Mánudagur'\n    - 'Þriðjudagur'\n    - 'Miðvikudagur'\n    - 'Fimmtudagur'\n    - 'Föstudagur'\n    - 'Laugardagur'\n    - 'Sunnudagur'\n  CRON:\n    TEXT_TIME: ' á <b />:<b />'\n    TEXT_DOW: ' á <b />'\n    TEXT_MONTH: ' af <b />'\n    TEXT_DOM: ' á <b />'\n    ERROR1: Merkið %s er ekki stutt!\n    ERROR3: Það ætti að setja jquery_element inn í stillingar jqCron\n    ERROR4: Óþekkt segð\n"
  },
  {
    "path": "system/languages/it.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---Titolo: %1$s---# Errore: Frontmatter non valido: '%2$s' * *%3$s * * ' '%4$s ' '\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'dotazione'\n    - 'informazione'\n    - 'riso'\n    - 'denaro'\n    - 'specie'\n    - 'serie'\n    - 'pesce'\n    - 'pecora'\n  INFLECTOR_IRREGULAR:\n    'person': 'persone'\n    'man': 'uomini'\n    'child': 'bambino'\n    'sex': 'sessi'\n    'move': 'sposta'\n  INFLECTOR_ORDINALS:\n    'default': '°'\n    'first': '°'\n    'second': 'o'\n    'third': 'o'\n  NICETIME:\n    NO_DATE_PROVIDED: Nessuna data fornita\n    BAD_DATE: Data non valida\n    AGO: fa\n    FROM_NOW: da adesso\n    JUST_NOW: ora\n    SECOND: secondo\n    MINUTE: minuto\n    HOUR: ora\n    DAY: giorno\n    WEEK: settimana\n    MONTH: mese\n    YEAR: anno\n    DECADE: decennio\n    SEC: sec\n    MIN: min\n    HR: ora\n    WK: settimana\n    MO: mese\n    YR: anno\n    DEC: decennio\n    SECOND_PLURAL: secondi\n    MINUTE_PLURAL: minuti\n    HOUR_PLURAL: ore\n    DAY_PLURAL: giorni\n    WEEK_PLURAL: settimane\n    MONTH_PLURAL: mesi\n    YEAR_PLURAL: anni\n    DECADE_PLURAL: decadi\n    SEC_PLURAL: secondi\n    MIN_PLURAL: minuti\n    HR_PLURAL: ore\n    WK_PLURAL: settimane\n    MO_PLURAL: mesi\n    YR_PLURAL: anni\n    DEC_PLURAL: decenni\n  FORM:\n    VALIDATION_FAIL: '<b>Validazione fallita:</b>'\n    INVALID_INPUT: 'Input non valido in'\n    MISSING_REQUIRED_FIELD: 'Campo richiesto mancante:'\n    XSS_ISSUES: \"Rilevati potenziali problemi di XSS nel campo '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'Gennaio'\n    - 'Febbraio'\n    - 'Marzo'\n    - 'Aprile'\n    - 'Maggio'\n    - 'Giugno'\n    - 'Luglio'\n    - 'Agosto'\n    - 'Settembre'\n    - 'Ottobre'\n    - 'Novembre'\n    - 'Dicembre'\n  DAYS_OF_THE_WEEK:\n    - 'Lunedì'\n    - 'Martedì'\n    - 'Mercoledì'\n    - 'Giovedì'\n    - 'Venerdì'\n    - 'Sabato'\n    - 'Domenica'\n  YES: \"Sì\"\n  NO: \"No\"\n  CRON:\n    EVERY: ogni\n    EVERY_HOUR: ogni ora\n    EVERY_MINUTE: ogni minuto\n    EVERY_DAY_OF_WEEK: ogni giorno della settimana\n    EVERY_DAY_OF_MONTH: ogni giorno del mese\n    EVERY_MONTH: ogni mese\n    TEXT_PERIOD: Ogni <b />\n    TEXT_MINS: ' a <b /> minuto(i) dall''inizio dell''ora'\n    TEXT_TIME: ' alle <b />:<b />'\n    TEXT_DOW: ' su <b />'\n    TEXT_MONTH: ' di <b />'\n    TEXT_DOM: ' di <b />'\n    ERROR1: Il tag %s non è supportato!\n    ERROR2: Numero di elementi non valido\n    ERROR3: Il jquery_element deve essere impostato nelle impostazioni di jqCron\n    ERROR4: Espressione non riconosciuta\n"
  },
  {
    "path": "system/languages/ja.yaml",
    "content": "---\nGRAV:\n  INFLECTOR_UNCOUNTABLE:\n    - ''\n    - '情報'\n    - ''\n    - 'お金'\n    - ''\n    - ''\n    - '魚'\n    - 'ヒツジ'\n  INFLECTOR_IRREGULAR:\n    'person': 'みんな'\n    'man': '人'\n    'child': '子供'\n    'sex': '性別'\n    'move': '移動'\n  INFLECTOR_ORDINALS:\n    'first': '番目'\n  NICETIME:\n    NO_DATE_PROVIDED: 日付が設定されていません\n    BAD_DATE: 不正な日付\n    AGO: 前\n    SECOND: 秒\n    MINUTE: 分\n    HOUR: 時\n    DAY: 日\n    WEEK: 週\n    MONTH: 月\n    YEAR: 年\n    DECADE: 10年\n    SEC: 秒\n    MIN: 分\n    HR: 時\n    WK: 週\n    MO: 月\n    YR: 年\n    SECOND_PLURAL: 秒\n    MINUTE_PLURAL: 分\n    HOUR_PLURAL: 時\n    DAY_PLURAL: 日\n    WEEK_PLURAL: 週\n    MONTH_PLURAL: 月\n    YEAR_PLURAL: 年\n    DECADE_PLURAL: 10年\n    SEC_PLURAL: 秒\n    MIN_PLURAL: 分\n    HR_PLURAL: 時\n    WK_PLURAL: 週\n    MO_PLURAL: 月\n    YR_PLURAL: 年\n    DEC_PLURAL: 10年\n  FORM:\n    VALIDATION_FAIL: '<b>バリデーション失敗 :</b>'\n    INVALID_INPUT: '不正な入力：'\n    MISSING_REQUIRED_FIELD: '必須項目が入力されていません:'\n  MONTHS_OF_THE_YEAR:\n    - '1月'\n    - '2月'\n    - '3月'\n    - '4月'\n    - '5月'\n    - '6月'\n    - '7月'\n    - '8月'\n    - '9月'\n    - '10月'\n    - '11月'\n    - '12月'\n  DAYS_OF_THE_WEEK:\n    - '月'\n    - '火'\n    - '水'\n    - '木'\n    - '金'\n    - '土'\n    - '日'\n  CRON:\n    EVERY: 毎\n    EVERY_MONTH: 毎月\n    ERROR1: 共有タイプ %s はサポートされていません\n"
  },
  {
    "path": "system/languages/ko.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# 오류: 무효의 Frontmatter\\n\\n경로: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - '장비'\n    - '정보'\n    - ''\n    - ''\n    - ''\n    - '시리즈'\n    - '물고기'\n    - ''\n  INFLECTOR_IRREGULAR:\n    'person': '사람들'\n  NICETIME:\n    NO_DATE_PROVIDED: 제공된 날짜가 없습니다\n    BAD_DATE: 잘못된 날짜\n    AGO: 전\n    FROM_NOW: 후\n    JUST_NOW: 방금\n    SECOND: 초\n    MINUTE: 분\n    HOUR: 시간\n    DAY: 일\n    WEEK: 주\n    MONTH: 개월\n    YEAR: 년\n    DECADE: 년간\n    SEC: 초\n    MIN: 분\n    HR: 시간\n    WK: 주\n    MO: 개월\n    YR: 년\n    DEC: 년간\n    SECOND_PLURAL: 초\n    MINUTE_PLURAL: 분\n    HOUR_PLURAL: 시간\n    DAY_PLURAL: 일\n    WEEK_PLURAL: 주\n    MONTH_PLURAL: 개월\n    YEAR_PLURAL: 년\n    DECADE_PLURAL: 년간\n    SEC_PLURAL: 초\n    MIN_PLURAL: 분\n    HR_PLURAL: 시간\n    WK_PLURAL: 주\n    MO_PLURAL: 개월\n    YR_PLURAL: 년\n    DEC_PLURAL: 년간\n  FORM:\n    VALIDATION_FAIL: '<b>유효성 검사 실패:</b>'\n    INVALID_INPUT: '잘못된 입력'\n    MISSING_REQUIRED_FIELD: '누락 된 필수 필드:'\n    XSS_ISSUES: \"'%s' 필드에서 잠재적인 XSS 문제가 감지되었습니다.\"\n  MONTHS_OF_THE_YEAR:\n    - '일월'\n    - '이월'\n    - '삼월'\n    - '사월'\n    - '오월'\n    - '유월'\n    - '칠월'\n    - '팔월'\n    - '구월'\n    - '시월'\n    - '십일월'\n    - '십이월'\n  DAYS_OF_THE_WEEK:\n    - '월요일'\n    - '화요일'\n    - '수요일'\n    - '목요일'\n    - '금요일'\n    - '토요일'\n    - '일요일'\n  YES: \"네\"\n  NO: \"아니요\"\n  CRON:\n    EVERY: 모두\n    EVERY_HOUR: 매 시간\n    EVERY_MINUTE: 매 분\n    EVERY_DAY_OF_WEEK: 일주일간 매일\n    EVERY_DAY_OF_MONTH: 일개월간 매일\n    EVERY_MONTH: 매달\n    TEXT_PERIOD: 모든 <b />\n    ERROR1: '%s 태그는 지원되지 않습니다. '\n    ERROR2: 잘못된 요소 수\n    ERROR3: jquery_element는 jqCron 설정에서 설정할 수 있습니다.\n    ERROR4: 인식할 수 없는 표현 \n"
  },
  {
    "path": "system/languages/lt.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Klaida: klaidinga įžanginė konfigūracija\\n\\nPath: `%2$s`\\n\\n**%3$s**\\n\\n```\\n %4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - ''\n    - ''\n    - 'ryžiai'\n    - 'pinigai'\n    - 'prieskoniai'\n    - 'serijos'\n    - 'žuvis'\n    - 'avis'\n  INFLECTOR_IRREGULAR:\n    'person': 'žmonės'\n    'man': 'žmogus'\n    'child': 'vaikai'\n    'sex': 'lytys'\n    'move': 'juda'\n  NICETIME:\n    NO_DATE_PROVIDED: Nenurodyta data\n    BAD_DATE: Neteisinga data\n    AGO: prieš\n    FROM_NOW: nuo dabar\n    SECOND: sekundė\n    MINUTE: minutė\n    HOUR: valanda\n    DAY: diena\n    WEEK: savaitė\n    MONTH: mėnuo\n    YEAR: metai\n    DECADE: dešimtmetis\n    SEC: sek.\n    MIN: min.\n    HR: val.\n    WK: sav.\n    MO: mėn.\n    YR: m.\n    DEC: dešimtmetis\n    SECOND_PLURAL: sekundės\n    MINUTE_PLURAL: minutės\n    HOUR_PLURAL: valandos\n    DAY_PLURAL: dienos\n    WEEK_PLURAL: savaitės\n    MONTH_PLURAL: mėnesiai\n    YEAR_PLURAL: metai\n    DECADE_PLURAL: dešimtmečiai\n    SEC_PLURAL: sek.\n    MIN_PLURAL: min.\n    HR_PLURAL: val.\n    WK_PLURAL: sav.\n    MO_PLURAL: mėn.\n    YR_PLURAL: m.\n    DEC_PLURAL: dešimtmečiai\n  FORM:\n    VALIDATION_FAIL: '<b>Patvirtinimas nepavyko:</b>'\n    INVALID_INPUT: 'Neteisingai įvesta į'\n    MISSING_REQUIRED_FIELD: 'Būtina užpildyti laukelį:'\n  MONTHS_OF_THE_YEAR:\n    - 'Sausis'\n    - 'Vasaris'\n    - 'Kovas'\n    - 'Balandis'\n    - 'Gegužė'\n    - 'Birželis'\n    - 'Liepa'\n    - 'Rugpjūtis'\n    - 'Rugsėjis'\n    - 'Spalis'\n    - 'Lakpritis'\n    - 'Gruodis'\n  DAYS_OF_THE_WEEK:\n    - 'Pirmadienis'\n    - 'Antradienis'\n    - 'Trečiadienis'\n    - 'Ketvirtadienis'\n    - 'Penktadienis'\n    - 'Šeštadienis'\n    - 'Sekmadienis'\n"
  },
  {
    "path": "system/languages/lv.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nNosaukums: %1$s\\n---\\n\\n# Kļūda: Nederīgs Frontmatter\\n\\nCeļš: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_ORDINALS:\n    'default': '.'\n    'first': '.'\n    'second': '.'\n    'third': '.'\n  NICETIME:\n    NO_DATE_PROVIDED: Nav norādīts datums\n    BAD_DATE: Nederīgs datums\n    AGO: iepriekš\n    FROM_NOW: no šī brīža\n    JUST_NOW: tikko\n    SECOND: sekundes\n    MINUTE: minūte\n    HOUR: stunda\n    DAY: diena\n    WEEK: nedēļa\n    MONTH: mēnesis\n    YEAR: gads\n    DECADE: dekāde\n    SEC: s\n    MIN: m\n    HR: st\n    WK: ned\n    MO: mēn.\n    YR: g.\n    DEC: dec\n    SECOND_PLURAL: sekundes\n    MINUTE_PLURAL: minūtes\n    HOUR_PLURAL: stundas\n    DAY_PLURAL: dienas\n    WEEK_PLURAL: nedēļas\n    MONTH_PLURAL: mēneši\n    YEAR_PLURAL: gadi\n    DECADE_PLURAL: desmitgades\n    SEC_PLURAL: s\n    MIN_PLURAL: m\n    HR_PLURAL: st.\n    WK_PLURAL: ned.\n    MO_PLURAL: mēn.\n    YR_PLURAL: g.\n    DEC_PLURAL: d\n  FORM:\n    VALIDATION_FAIL: '<b>Validācija neizdevās:</b>'\n    INVALID_INPUT: 'Nederīga ievade'\n    MISSING_REQUIRED_FIELD: 'Laukā trūkst datu'\n    XSS_ISSUES: \"Atrastas iespējamas XSS problēmas laukā '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'Janvāris'\n    - 'Februāris'\n    - 'Marts'\n    - 'Aprīlis'\n    - 'Maijs'\n    - 'Jūnijs'\n    - 'Jūlijs'\n    - 'Augusts'\n    - 'Septembris'\n    - 'Oktobris'\n    - 'Novembris'\n    - 'Decembris'\n  DAYS_OF_THE_WEEK:\n    - 'Pirmdiena'\n    - 'Otrdiena'\n    - 'Trešdiena'\n    - 'Ceturtdiena'\n    - 'Piektdiena'\n    - 'Sestdiena'\n    - 'Svētdiena'\n  YES: \"Jā\"\n  NO: \"Nē\"\n  CRON:\n    EVERY: katru\n    EVERY_HOUR: katru stundu\n    EVERY_MINUTE: katru minūti\n    EVERY_DAY_OF_WEEK: katru nedēļas dienu\n    EVERY_DAY_OF_MONTH: katru mēneša dienu\n    EVERY_MONTH: katru mēnesi\n    TEXT_PERIOD: Katru <b />\n    ERROR1: Marķieris %s nav atbalstīts!\n    ERROR2: Nederīgs elementu skaits\n    ERROR3: jquery_element nevajadzētu definēt jqCron iestatījumos\n    ERROR4: Neatpazīta izteiksme\n"
  },
  {
    "path": "system/languages/mn.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nГарчиг: %1$s\\n---\\n\\n# Алдаа: Буруу Формат\\n\\nЗам: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1зүүд'\n    '/^(ox)$/i': '\\1ууд'\n    '/([m|l])ouse$/i': '\\1ууд'\n    '/(matr|vert|ind)ix|ex$/i': '\\1иксүүд'\n    '/(x|ch|ss|sh)$/i': '\\1үүд'\n    '/([^aeiouy]|qu)ies$/i': '\\1үүд'\n    '/([^aeiouy]|qu)y$/i': '\\1үүд'\n    '/(hive)$/i': '\\1үүд'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2үүд'\n    '/sis$/i': 'үүд'\n    '/([ti])um$/i': '\\1үүд'\n    '/(buffal|tomat)o$/i': '\\1үүд'\n    '/(bu)s$/i': '\\1үүд'\n    '/(alias|status)/i': '\\1үүд'\n    '/(octop|vir)us$/i': '\\1үүд'\n    '/(ax|test)is$/i': '\\1үүд'\n    '/s$/i': 'үүд'\n    '/$/': 'үүд'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1икс'\n    '/(vert|ind)ices$/i': '\\1икс'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1'\n    '/(cris|ax|test)es$/i': '\\1'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1'\n    '/(s)eries$/i': '\\1'\n    '/([^aeiouy]|qu)ies$/i': '\\1үүд'\n    '/([lr])ves$/i': '\\1'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1'\n    '/(^analy)ses$/i': '\\1'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2үүд'\n    '/([ti])a$/i': '\\1'\n    '/(n)ews$/i': '\\1'\n  INFLECTOR_UNCOUNTABLE:\n    - 'тоног төхөөрөмж'\n    - 'Мэдээлэл'\n    - 'будаа'\n    - 'мөнгө'\n    - 'төрөл зүйл'\n    - 'цуврал'\n    - 'загас'\n    - 'хонь'\n  INFLECTOR_IRREGULAR:\n    'person': 'хүмүүс'\n    'man': 'эрчүүд'\n    'child': 'хүүхэд'\n    'sex': 'хүйс'\n    'move': 'хөдөлгөөн'\n  INFLECTOR_ORDINALS:\n    'default': 'th'\n    'first': 'st'\n    'second': 'nd'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: Огноо алга\n    BAD_DATE: Буруу огноо\n    AGO: өмнө \n    FROM_NOW: одооноос\n    JUST_NOW: дөнгөж сая\n    SECOND: секунд\n    MINUTE: минут\n    HOUR: цаг\n    DAY: өдөр\n    WEEK: долоо хоног\n    MONTH: сар\n    YEAR: он\n    DECADE: арван жил\n    SEC: сек\n    MIN: мин\n    HR: цаг\n    WK: д.х.\n    MO: сар\n    YR: он\n    DEC: арван жил\n    SECOND_PLURAL: секунд\n    MINUTE_PLURAL: минут\n    HOUR_PLURAL: цаг\n    DAY_PLURAL: өдрүүд\n    WEEK_PLURAL: долоо хоногууд\n    MONTH_PLURAL: сарууд\n    YEAR_PLURAL: онууд\n    DECADE_PLURAL: арван жилүүд\n    SEC_PLURAL: сек.-үүд\n    MIN_PLURAL: мин.-ууд\n    HR_PLURAL: цагууд\n    WK_PLURAL: д.х.-ууд\n    MO_PLURAL: сарууд\n    YR_PLURAL: жилүүд\n    DEC_PLURAL: арван жилүүд\n  FORM:\n    VALIDATION_FAIL: '<b>Баталгаажуулалт амжилтгүй боллоо:</b>'\n    INVALID_INPUT: 'Буруу өгөгдөл дараахид'\n    MISSING_REQUIRED_FIELD: 'Шаардлагатай талбар дутуу байна:'\n    XSS_ISSUES: \"'%s' талбарт XSS -ийн болзошгүй асуудлууд илэрсэн\"\n  MONTHS_OF_THE_YEAR:\n    - '1-р сар'\n    - '2-р сар'\n    - '3-р сар'\n    - '4-р сар'\n    - '5 сар'\n    - '6 сар'\n    - '7 сар'\n    - '8 сар'\n    - '9 сар'\n    - '10 сар'\n    - '11 сар'\n    - '12 сар'\n  DAYS_OF_THE_WEEK:\n    - 'Даваа гараг'\n    - 'Мягмар гараг'\n    - 'Лхагва гараг'\n    - 'Пүрэв гараг'\n    - 'Баасан гараг'\n    - 'Бямба гараг'\n    - 'Ням гараг'\n  YES: \"Тийм\"\n  NO: \"Үгүй\"\n  CRON:\n    EVERY: бүрийн\n    EVERY_HOUR: цаг бүрийн\n    EVERY_MINUTE: минут бүрийн\n    EVERY_DAY_OF_WEEK: долоо хоногийн өдөр болгонд\n    EVERY_DAY_OF_MONTH: сарын өдөр болгонд\n    EVERY_MONTH: сар болгон\n    TEXT_PERIOD: Бүрийн  <b />\n    TEXT_MINS: '  <b /> энэ сүүлийн цагийн минутад'\n    TEXT_TIME: '  <b />:<b /> -д'\n    TEXT_DOW: '  <b /> -д'\n    TEXT_MONTH: '  <b /> -ын'\n    TEXT_DOM: '  <b /> -т'\n    ERROR1: '%s -н утга нь дэмжигддэггүй!'\n    ERROR2: Элементүүдийн тоо хэмжээ буруу\n    ERROR3: jquery_element нь jqCron тохиргоонд хийгдсэн байх ёстой\n    ERROR4: Танигдаагүй илэрхийлэл\n"
  },
  {
    "path": "system/languages/my.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nခေါင်းစဥ်: %1$s\\n---\\n\\n# အမှား - Frontmatter မမှန်ကန်ပါ\\n\\nလမ်းကြောင်း `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'ကိရိယာ'\n    - 'အချက်အလက်'\n    - 'ဆန်'\n    - 'ငွေ'\n    - 'မျိုးစိတ်'\n    - 'အတွဲများ'\n    - 'ငါး'\n    - 'သိုးများ'\n  INFLECTOR_IRREGULAR:\n    'person': 'လူ'\n    'man': 'ယောက်ျား'\n    'child': 'ကလေးများ'\n    'sex': 'လိင်'\n    'move': 'ရွှေ့ခြင်း'\n  INFLECTOR_ORDINALS:\n    'default': 'th'\n    'first': 'st'\n    'second': 'nd'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: နေ့စွဲ မသတ်မှတ်ထား\n    BAD_DATE: ရက်စွဲမမှန်ပါ\n    AGO: လွန်ခဲ့တဲ့\n    FROM_NOW: ယခုမှ\n    JUST_NOW: အခုပဲ\n    SECOND: ဒုတိယ\n    MINUTE: မိနစ်\n    HOUR: နာရီ\n    DAY: နေ့\n    WEEK: တစ်ပတ်\n    MONTH: လ\n    YEAR: နှစ်\n    DECADE: ဆယ်စုနှစ်\n    SEC: စက္ကန့်\n    MIN: မိနစ်\n    HR: နာရီ\n    WK: တစ်ပတ်\n    MO: လ\n    YR: နှစ်\n    DEC: ဒီဇင်ဘာ\n    SECOND_PLURAL: စက္ကန့်\n    MINUTE_PLURAL: မိနစ်\n    HOUR_PLURAL: နာရီ\n    DAY_PLURAL: နေ့\n    WEEK_PLURAL: ရက်သတ္တပတ်\n    MONTH_PLURAL: လ\n    YEAR_PLURAL: နှစ်\n    DECADE_PLURAL: ဆယ်စုနှစ်များစွ\n    SEC_PLURAL: စက္ကန့်\n    MIN_PLURAL: မိနစ်\n    HR_PLURAL: နာရီ\n    WK_PLURAL: အပတ်\n    MO_PLURAL: လ\n    YR_PLURAL: နှစ်\n    DEC_PLURAL: ဆယ်စုနှစ်\n  FORM:\n    VALIDATION_FAIL: '<b> အတည်ပြုခြင်းမအောင်မြင်ပါ: </b>'\n    INVALID_INPUT: 'ထည့်သွင်းမှုမမှန်ပါ'\n    MISSING_REQUIRED_FIELD: 'လိုအပ်သောအကွက်ပျောက်နေသည်'\n    XSS_ISSUES: \"XSS ပြဿနာ ဖြစ်နိုင်ချေ ကို '%s' အကွက်တွင် တွေ့\"\n  MONTHS_OF_THE_YEAR:\n    - 'ဇန်နဝါရီ'\n    - 'ဖေဖော်ဝါရီ'\n    - 'မတ်'\n    - 'ဧပြီ'\n    - 'မေ'\n    - 'ဇွန်'\n    - 'ဇူလိုင်'\n    - 'သြဂုတ်'\n    - 'စက်တင်ဘာ'\n    - 'အောက်တိုဘာ'\n    - 'နိုဝင်ဘာ'\n    - 'ဒီဇင်ဘာ'\n  DAYS_OF_THE_WEEK:\n    - 'တနင်္လာ'\n    - ' အင်္ဂါ'\n    - 'ဗုဒ္ဓဟူး'\n    - 'ကြာသပတေး'\n    - 'သောကြာ'\n    - 'စနေ'\n    - 'တနင်္ဂနွေ'\n  YES: \"လုပ်\"\n  NO: \"မလုပ်\"\n  CRON:\n    EVERY: အမြဲတမ်း\n    EVERY_HOUR: နာရီတိုင်း\n    EVERY_MINUTE: မိနစ်တိုင်း\n    EVERY_DAY_OF_WEEK: တစ်ပတ်လုံး နေ့တိုင်း\n    EVERY_DAY_OF_MONTH: တစ်လလုံး နေ့တိုင်း\n    EVERY_MONTH: လစဉ်လတိုင်း\n    TEXT_PERIOD: </b>တိုင်း\n    TEXT_MINS: 'နာရီ ကျော်ပြီး <b /> မိနစ် တွင်'\n    TEXT_TIME: ' <b />:<b /> တွင် '\n    TEXT_DOW: '<b /> ပေါ်တွင်  '\n    TEXT_MONTH: '<b />၏ '\n    TEXT_DOM: '<b /> တွင် '\n    ERROR1: ဤ %s တက် ကိုပံ့ပိုးမထားပါ။\n    ERROR2: လိုအပ်သောထည့်သွင်း နာပတ် အမှားဖြစ်နေသည်\n    ERROR3: jquery_element ကို jqCron ဆက်တင် တွင်ထားရမည်\n    ERROR4: အသိအမှတ်မပြုသော အသုံးအနှုန်း\n"
  },
  {
    "path": "system/languages/nb.yaml",
    "content": "---\nGRAV:\n  MONTHS_OF_THE_YEAR: ['januar', 'februar', 'mars', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'desember']\n  DAYS_OF_THE_WEEK: ['mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag', 'søndag']\n"
  },
  {
    "path": "system/languages/nl.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitel: %1$s\\n---\\n\\n# Fout: ongeldige frontmatter\\n\\nPad: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'uitrusting'\n    - 'informatie'\n    - 'rijst'\n    - 'geld'\n    - 'soorten'\n    - 'reeks'\n    - 'vis'\n    - 'schaap'\n  INFLECTOR_IRREGULAR:\n    'person': 'personen'\n    'man': 'mensen'\n    'child': 'kinderen'\n    'sex': 'geslacht'\n    'move': 'verplaatsen'\n  INFLECTOR_ORDINALS:\n    'default': 'th'\n    'first': 'st'\n    'second': 'nd'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: geen datum opgegeven\n    BAD_DATE: Datumformaat onjuist\n    AGO: geleden\n    FROM_NOW: vanaf nu\n    JUST_NOW: zojuist\n    SECOND: seconde\n    MINUTE: minuut\n    HOUR: uur\n    DAY: dag\n    WEEK: week\n    MONTH: maand\n    YEAR: jaar\n    DECADE: decennium\n    SEC: s\n    MIN: min\n    HR: u\n    WK: week\n    MO: ma\n    YR: j\n    DEC: decennia\n    SECOND_PLURAL: seconden\n    MINUTE_PLURAL: minuten\n    HOUR_PLURAL: uren\n    DAY_PLURAL: dagen\n    WEEK_PLURAL: weken\n    MONTH_PLURAL: maanden\n    YEAR_PLURAL: jaren\n    DECADE_PLURAL: decennia\n    SEC_PLURAL: seconden\n    MIN_PLURAL: minuten\n    HR_PLURAL: uren\n    WK_PLURAL: weken\n    MO_PLURAL: maanden\n    YR_PLURAL: jaren\n    DEC_PLURAL: decennia\n  FORM:\n    VALIDATION_FAIL: '<b>Validatie mislukt:</b>'\n    INVALID_INPUT: 'Ongeldige invoer in'\n    MISSING_REQUIRED_FIELD: 'Ontbrekend verplicht veld:'\n    XSS_ISSUES: \"Mogelijke XSS-problemen ontdekt in '%s' veld\"\n  MONTHS_OF_THE_YEAR:\n    - 'Januari'\n    - 'Februari'\n    - 'Maart'\n    - 'April'\n    - 'Mei'\n    - 'Juni'\n    - 'Juli'\n    - 'Augustus'\n    - 'September'\n    - 'Oktober'\n    - 'November'\n    - 'December'\n  DAYS_OF_THE_WEEK:\n    - 'Maandag'\n    - 'Dinsdag'\n    - 'Woensdag'\n    - 'Donderdag'\n    - 'Vrijdag'\n    - 'Zaterdag'\n    - 'Zondag'\n  YES: \"Ja\"\n  NO: \"Nee\"\n  CRON:\n    EVERY: elke\n    EVERY_HOUR: elk uur\n    EVERY_MINUTE: elke minuut\n    EVERY_DAY_OF_WEEK: elke dag van de week\n    EVERY_DAY_OF_MONTH: elke dag van de maand\n    EVERY_MONTH: elke maand\n    TEXT_PERIOD: Elke <b />\n    TEXT_MINS: ' <b /> minuten te laat'\n    TEXT_TIME: ' op <b />:<b />'\n    TEXT_DOW: ' op <b />'\n    TEXT_MONTH: ' van <b />'\n    TEXT_DOM: ' op <b />'\n    ERROR1: De tag %s wordt niet ondersteund!\n    ERROR2: Slecht aantal elementen\n    ERROR3: Het jquery_element moet ingesteld worden in de jqCron instellingen\n    ERROR4: Onbekende expressie\n"
  },
  {
    "path": "system/languages/no.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nTittel: %1$s\\n---\\n\\n# Feilmelding: Ugyldig Frontmatter\\n\\nSti: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'utstyr'\n    - 'informasjon'\n    - 'ris'\n    - 'penger'\n    - 'arter'\n    - 'serier'\n    - 'fisk'\n    - 'sau'\n  INFLECTOR_IRREGULAR:\n    'person': 'folk'\n    'man': 'menn'\n    'child': 'barn'\n    'sex': 'kjønn'\n    'move': 'trekk'\n  NICETIME:\n    NO_DATE_PROVIDED: Ingen dato gitt\n    BAD_DATE: Ugyldig dato\n    AGO: siden\n    FROM_NOW: fra nå\n    JUST_NOW: akkurat nå\n    SECOND: sekund\n    MINUTE: minutt\n    HOUR: time\n    DAY: dag\n    WEEK: uke\n    MONTH: måned\n    YEAR: år\n    DECADE: tiår\n    SEC: sek\n    HR: t\n    WK: uke\n    MO: må\n    YR: år\n    DEC: tiår\n    SECOND_PLURAL: sekunder\n    MINUTE_PLURAL: minutter\n    HOUR_PLURAL: timer\n    DAY_PLURAL: dager\n    WEEK_PLURAL: uker\n    MONTH_PLURAL: måneder\n    YEAR_PLURAL: år\n    DECADE_PLURAL: tiår\n    SEC_PLURAL: sek\n    MIN_PLURAL: min\n    HR_PLURAL: timer\n    WK_PLURAL: uker\n    MO_PLURAL: md\n    YR_PLURAL: år\n    DEC_PLURAL: årtier\n  FORM:\n    VALIDATION_FAIL: '<b>Godkjenning mislyktes:</b>'\n    INVALID_INPUT: 'Ugyldig innhold i'\n    MISSING_REQUIRED_FIELD: 'Mangler påkrevd felt:'\n  MONTHS_OF_THE_YEAR:\n    - 'januar'\n    - 'februar'\n    - 'mars'\n    - 'april'\n    - 'mai'\n    - 'juni'\n    - 'juli'\n    - 'august'\n    - 'september'\n    - 'oktober'\n    - 'november'\n    - 'desember'\n  DAYS_OF_THE_WEEK:\n    - 'mandag'\n    - 'tirsdag'\n    - 'onsdag'\n    - 'torsdag'\n    - 'fredag'\n    - 'lørdag'\n    - 'søndag'\n  CRON:\n    EVERY: hver\n    EVERY_HOUR: hver time\n    EVERY_MINUTE: hvert minutt\n"
  },
  {
    "path": "system/languages/pl.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Error: Nieprawidłowy Frontmatter\\n\\nPath: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_SINGULAR:\n    '/(alias|status)es$/i': '\\1'\n  INFLECTOR_UNCOUNTABLE:\n    - 'wyposażenie'\n    - 'informacja'\n    - ''\n    - 'pieniądze'\n    - ''\n    - ''\n    - 'ryba'\n    - 'owca'\n  INFLECTOR_IRREGULAR:\n    'person': 'człowiek'\n    'man': 'mężczyźni'\n    'child': 'dzieci'\n    'sex': 'płci'\n  INFLECTOR_ORDINALS:\n    'first': 'pierwszy'\n    'second': 'drugi'\n    'third': 'trzeci'\n  NICETIME:\n    NO_DATE_PROVIDED: Nie podano daty\n    BAD_DATE: Zła data\n    AGO: temu\n    FROM_NOW: od teraz\n    JUST_NOW: właśnie teraz\n    SECOND: sekunda\n    MINUTE: minuta\n    HOUR: godzina\n    DAY: dzień\n    WEEK: tydzień\n    MONTH: miesiąc\n    YEAR: rok\n    DECADE: dekada\n    SEC: sek\n    MIN: minuta\n    HR: godz\n    WK: tydz\n    MO: m-c\n    YR: rok\n    DEC: dekada\n    SECOND_PLURAL: sekund\n    MINUTE_PLURAL: minut\n    HOUR_PLURAL: godzin\n    DAY_PLURAL: dni\n    WEEK_PLURAL: tygodnie\n    MONTH_PLURAL: miesięcy\n    YEAR_PLURAL: lat\n    DECADE_PLURAL: dekad\n    SEC_PLURAL: sek\n    MIN_PLURAL: min\n    HR_PLURAL: godz\n    WK_PLURAL: tyg\n    MO_PLURAL: m-ce\n    YR_PLURAL: lat\n    DEC_PLURAL: dekad\n  FORM:\n    VALIDATION_FAIL: '<b>Weryfikacja nie powiodła się:</b>'\n    INVALID_INPUT: 'Nieprawidłowe dane wejściowe'\n    MISSING_REQUIRED_FIELD: 'Opuszczono wymagane pole:'\n    XSS_ISSUES: \"Potencjalne problemy XSS wykryte w polu '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'Styczeń'\n    - 'Luty'\n    - 'Marzec'\n    - 'Kwiecień'\n    - 'Maj'\n    - 'Czerwiec'\n    - 'Lipiec'\n    - 'Sierpień'\n    - 'Wrzesień'\n    - 'Październik'\n    - 'Listopad'\n    - 'Grudzień'\n  DAYS_OF_THE_WEEK:\n    - 'Poniedziałek'\n    - 'Wtorek'\n    - 'Środa'\n    - 'Czwartek'\n    - 'Piątek'\n    - 'Sobota'\n    - 'Niedziela'\n  YES: \"Tak\"\n  NO: \"Nie\"\n  CRON:\n    EVERY: każdy\n    EVERY_HOUR: każdą godzinę\n    EVERY_MINUTE: każdą minutę\n    EVERY_DAY_OF_WEEK: każdego dnia tygodnia\n    EVERY_DAY_OF_MONTH: każdego dnia miesiące\n    EVERY_MONTH: każdego miesiąca\n    TEXT_PERIOD: Każdego <b />\n    TEXT_MINS: 'o <b /> minut po godzinie'\n    TEXT_TIME: 'o <b />:<b />'\n    ERROR1: Znacznik %s nie jest wspierany!\n    ERROR2: Nieprawidłowa liczba elementów\n    ERROR4: Wyrażenie nierozpoznane\n"
  },
  {
    "path": "system/languages/pt.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Erro: Frontmatter Inválido\\n\\nLocalização: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'equipamento'\n    - 'informação'\n    - 'arroz'\n    - 'dinheiro'\n    - 'espécie'\n    - 'série'\n    - 'peixe'\n    - 'ovelha'\n  INFLECTOR_IRREGULAR:\n    'person': 'pessoas'\n    'man': 'homens'\n    'child': 'crianças'\n    'sex': 'sexos'\n    'move': 'movimentos'\n  INFLECTOR_ORDINALS:\n    'default': 'º'\n    'first': 'º'\n    'second': 'º'\n    'third': 'º'\n  NICETIME:\n    NO_DATE_PROVIDED: Nenhuma data fornecida\n    BAD_DATE: Data inválida\n    AGO: há\n    FROM_NOW: a partir de agora\n    JUST_NOW: mesmo agora\n    SECOND: segundo\n    MINUTE: minuto\n    HOUR: hora\n    DAY: dia\n    WEEK: semana\n    MONTH: mês\n    YEAR: ano\n    DECADE: década\n    SEC: seg\n    MIN: min\n    HR: hora\n    WK: semana\n    MO: mês\n    YR: ano\n    DEC: década\n    SECOND_PLURAL: segundos\n    MINUTE_PLURAL: minutos\n    HOUR_PLURAL: horas\n    DAY_PLURAL: dias\n    WEEK_PLURAL: semanas\n    MONTH_PLURAL: meses\n    YEAR_PLURAL: anos\n    DECADE_PLURAL: décadas\n    SEC_PLURAL: segs\n    MIN_PLURAL: mins\n    HR_PLURAL: hrs\n    WK_PLURAL: sems\n    MO_PLURAL: meses\n    YR_PLURAL: anos\n    DEC_PLURAL: décadas\n  FORM:\n    VALIDATION_FAIL: '<b>Falha na validação:</b>'\n    INVALID_INPUT: 'Dados inseridos são inválidos em'\n    MISSING_REQUIRED_FIELD: 'Campo obrigatório em falta:'\n    XSS_ISSUES: \"Potenciais problemas de XSS detectados no campo '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'Janeiro'\n    - 'Fevereiro'\n    - 'Março'\n    - 'Abril'\n    - 'Maio'\n    - 'Junho'\n    - 'Julho'\n    - 'Agosto'\n    - 'Setembro'\n    - 'Outubro'\n    - 'Novembro'\n    - 'Dezembro'\n  DAYS_OF_THE_WEEK:\n    - 'Segunda-feira'\n    - 'Terça-feira'\n    - 'Quarta-feira'\n    - 'Quinta-feira'\n    - 'Sexta-feira'\n    - 'Sábado'\n    - 'Domingo'\n  YES: \"Sim\"\n  NO: \"Não\"\n  CRON:\n    EVERY: cada\n    EVERY_HOUR: cada hora\n    EVERY_MINUTE: cada minuto\n    EVERY_DAY_OF_WEEK: todos os dias da semana\n    EVERY_DAY_OF_MONTH: todos os dias do mês\n    EVERY_MONTH: todos os meses\n    TEXT_PERIOD: Cada <b />\n    TEXT_MINS: ' em <b /> minuto(s) após a hora'\n    TEXT_TIME: ' em <b />:<b />'\n    TEXT_DOW: ' em <b />'\n    TEXT_MONTH: ' de <b />'\n    TEXT_DOM: ' em <b />'\n    ERROR1: A tag %s não é suportada!\n    ERROR2: Número de elementos inválido\n    ERROR3: O jquery_element deve ser definido nas configurações do jqCron\n    ERROR4: Expressão não reconhecida\n"
  },
  {
    "path": "system/languages/ro.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nTitlu: %1$s\\n---\\n# Eroare: Frontmatter este invalid\\n\\nCalea: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'echipament'\n    - 'informaţie'\n    - 'orez'\n    - 'bani'\n    - 'specii'\n    - 'serii'\n    - 'peşte'\n    - 'oaie'\n  INFLECTOR_IRREGULAR:\n    'person': 'persoane'\n    'man': 'bărbați'\n    'child': 'copii'\n    'sex': 'sexe'\n    'move': 'mutări'\n  NICETIME:\n    NO_DATE_PROVIDED: Nu există o dată prevăzută\n    BAD_DATE: Dată incorectă\n    AGO: în urmă\n    FROM_NOW: de acum\n    JUST_NOW: chiar acum\n    SECOND: secundă\n    MINUTE: minut\n    HOUR: oră\n    DAY: zi\n    WEEK: săptămână\n    MONTH: lună\n    YEAR: an\n    DECADE: decadă\n    SEC: secunde\n    MIN: minute\n    HR: oră\n    WK: săpt\n    MO: lună\n    YR: an\n    DEC: decadă\n    SECOND_PLURAL: secunde\n    MINUTE_PLURAL: minute\n    HOUR_PLURAL: ore\n    DAY_PLURAL: zile\n    WEEK_PLURAL: săptămâni\n    MONTH_PLURAL: luni\n    YEAR_PLURAL: ani\n    DECADE_PLURAL: decade\n    SEC_PLURAL: sec\n    MIN_PLURAL: min\n    HR_PLURAL: ore\n    WK_PLURAL: săpt\n    MO_PLURAL: luni\n    YR_PLURAL: ani\n    DEC_PLURAL: decenii\n  FORM:\n    VALIDATION_FAIL: '<b>Validare nereușită</b>'\n    INVALID_INPUT: 'Date incorecte în'\n    MISSING_REQUIRED_FIELD: 'Câmp obligatoriu lipsă:'\n  MONTHS_OF_THE_YEAR:\n    - 'Ianuarie'\n    - 'Februarie'\n    - 'Martie'\n    - 'Aprilie'\n    - 'Mai'\n    - 'Iunie'\n    - 'Iulie'\n    - 'August'\n    - 'Septembrie'\n    - 'Octombrie'\n    - 'Noiembrie'\n    - 'Decembrie'\n  DAYS_OF_THE_WEEK:\n    - 'Luni'\n    - 'Marți'\n    - 'Miercuri'\n    - 'Joi'\n    - 'Vineri'\n    - 'Sâmbătă'\n    - 'Duminică'\n  CRON:\n    EVERY: la fiecare\n    EVERY_HOUR: la fiecare oră\n    EVERY_MINUTE: la fiecare minut\n    EVERY_DAY_OF_WEEK: fiecare zi a săptămânii\n    EVERY_DAY_OF_MONTH: fiecare zi a lunii\n    EVERY_MONTH: fiecare lună\n    TEXT_PERIOD: Fiecare <b />\n    TEXT_MINS: ' la <b /> minut(e) ale fiecărei ore'\n    TEXT_TIME: ' la <b />:<b />'\n    TEXT_DOW: ' pe <b />'\n    TEXT_MONTH: 'al(e) <b />'\n    TEXT_DOM: ' pe <b />'\n    ERROR1: Eticheta %s nu este acceptată!\n    ERROR2: Număr nevalid de elemente\n    ERROR3: jquery_element ar trebui setat în opțiunile jqCron\n    ERROR4: Expresie necunoscută\n"
  },
  {
    "path": "system/languages/ru.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Ошибка: недопустимое содержимое Frontmatter\\n\\nПуть: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_SINGULAR:\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': \"\\\\1\\n\"\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n  INFLECTOR_UNCOUNTABLE:\n    - 'экипировка'\n    - 'информация'\n    - 'рис'\n    - 'деньги'\n    - 'виды'\n    - 'серии'\n    - 'рыба'\n    - 'овца'\n  INFLECTOR_IRREGULAR:\n    'person': 'люди'\n    'man': 'человек'\n    'child': 'дети'\n    'sex': 'пол'\n    'move': 'движется'\n  INFLECTOR_ORDINALS:\n    'default': 'й'\n    'first': 'й'\n    'second': 'й'\n    'third': 'й'\n  NICETIME:\n    NO_DATE_PROVIDED: Дата не указана\n    BAD_DATE: Неверная дата\n    AGO: назад\n    FROM_NOW: теперь\n    JUST_NOW: только что\n    SECOND: секунда\n    MINUTE: минута\n    HOUR: час\n    DAY: день\n    WEEK: неделя\n    MONTH: месяц\n    YEAR: год\n    DECADE: десятилетие\n    SEC: сек\n    MIN: мин\n    HR: ч\n    WK: нед\n    MO: мес\n    YR: г\n    DEC: дстлт\n    SECOND_PLURAL: сек\n    MINUTE_PLURAL: мин\n    HOUR_PLURAL: ч\n    DAY_PLURAL: д\n    WEEK_PLURAL: нед\n    MONTH_PLURAL: мес\n    YEAR_PLURAL: г\n    DECADE_PLURAL: дстлт\n    SEC_PLURAL: сек\n    MIN_PLURAL: мин\n    HR_PLURAL: ч\n    WK_PLURAL: нед\n    MO_PLURAL: мес\n    YR_PLURAL: г\n    DEC_PLURAL: дстлт\n  FORM:\n    VALIDATION_FAIL: '<b>Проверка не удалась:</b>'\n    INVALID_INPUT: 'Неверный ввод в'\n    MISSING_REQUIRED_FIELD: 'Отсутствует необходимое поле:'\n    XSS_ISSUES: \"Обнаружены потенциальные XSS проблемы в поле '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'январь'\n    - 'февраль'\n    - 'март'\n    - 'апрель'\n    - 'май'\n    - 'июнь'\n    - 'июль'\n    - 'август'\n    - 'сентябрь'\n    - 'октябрь'\n    - 'ноябрь'\n    - 'декабрь'\n  DAYS_OF_THE_WEEK:\n    - 'понедельник'\n    - 'вторник'\n    - 'среда'\n    - 'четверг'\n    - 'пятница'\n    - 'суббота'\n    - 'воскресенье'\n  YES: \"Да\"\n  NO: \"Нет\"\n  CRON:\n    EVERY: раз в\n    EVERY_HOUR: раз в час\n    EVERY_MINUTE: раз в минуту\n    EVERY_DAY_OF_WEEK: каждый день недели\n    EVERY_DAY_OF_MONTH: каждый день недели\n    EVERY_MONTH: раз в месяц\n    TEXT_PERIOD: Каждый <b />\n    TEXT_MINS: ' в <b /> минуте(ах) за час'\n    TEXT_TIME: ' в <b />:<b />'\n    TEXT_DOW: ' на <b />'\n    TEXT_MONTH: ' из <b />'\n    TEXT_DOM: ' на <b />'\n    ERROR1: Тег %s не поддерживается!\n    ERROR2: Неверное количество элементов\n    ERROR3: jquery_element должен быть установлен в настройки jqCron\n    ERROR4: Выражение не распознано\n"
  },
  {
    "path": "system/languages/si.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nමාතෘකාව: %1$s\\n---\\n\\n# දෝෂය: වලංගු නොවන ඉදිරිපස\\n\\nමාර්ගය: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/([m|l])ouse$/i': '\\1අයිස්'\n    '/(matr|vert|ind)ix|ex$/i': '\\1අයිස්'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2වෙස්'\n    '/([ti])um$/i': '\\1අ'\n    '/(buffal|tomat)o$/i': '\\1ඕඑස්'\n    '/(bu)s$/i': '\\1සෙස්'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1 අප'\n    '/(cris|ax|test)es$/i': '\\1 වේ'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1 භාවිතා කරන්න'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ඕවී'\n    '/(s)eries$/i': '\\1මාලා'\n    '/(^analy)ses$/i': '\\1සිස්'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2සිස්'\n    '/([ti])a$/i': '\\1ම්'\n  INFLECTOR_UNCOUNTABLE:\n    - 'උපකරණ'\n    - 'විස්තර'\n    - 'සහල්'\n    - 'මුදල'\n    - 'විශේෂ'\n    - 'මාලාවක්'\n    - 'මාළු'\n    - 'බැටළුවන්'\n  INFLECTOR_IRREGULAR:\n    'person': 'මහජන'\n    'man': 'මිනිසුන්'\n    'child': 'දරුවන්'\n    'sex': 'ලිංගිකත්වය'\n    'move': 'චලනය කරයි'\n  INFLECTOR_ORDINALS:\n    'first': 'ශාන්ත'\n  NICETIME:\n    NO_DATE_PROVIDED: දිනයක් සපයා නැත\n    BAD_DATE: නරක දිනය\n    AGO: පෙර\n    FROM_NOW: මෙතැන් සිට\n    JUST_NOW: මේ දැන්\n    SECOND: දෙවැනි\n    MINUTE: මිනිත්තුව\n    HOUR: පැය\n    DAY: දින\n    WEEK: සතිය\n    MONTH: මස\n    YEAR: වර්ෂය\n    DECADE: දශකය\n    SEC: තත්පර\n    MIN: මිනි\n    HR: පැය\n    YR: වසර\n    DEC: දෙසැ\n    SECOND_PLURAL: තත්පර\n    MINUTE_PLURAL: මිනිත්තු\n    HOUR_PLURAL: පැය\n    DAY_PLURAL: දින\n    WEEK_PLURAL: සති\n    MONTH_PLURAL: මාස\n    YEAR_PLURAL: වසර\n    DECADE_PLURAL: දශක\n    SEC_PLURAL: තත්පර\n    MIN_PLURAL: මිනිත්තු\n    HR_PLURAL: පැය\n    WK_PLURAL: සති\n    YR_PLURAL: වසර\n    DEC_PLURAL: දෙසැ\n  FORM:\n    VALIDATION_FAIL: '<b>වලංගු කිරීම අසාර්ථක විය:</b>'\n    INVALID_INPUT: 'වලංගු නොවන ආදානය'\n    MISSING_REQUIRED_FIELD: 'අවශ්‍ය ක්ෂේත්‍රය අස්ථානගත වී ඇත:'\n    XSS_ISSUES: \"විභව XSS ගැටළු '%s' ක්ෂේත්‍රයේ අනාවරණය විය\"\n  MONTHS_OF_THE_YEAR:\n    - 'ජනවාරි'\n    - 'පෙබරවාරි'\n    - 'මාර්තු'\n    - 'අප්රේල්'\n    - 'මැයි'\n    - 'ජූනි'\n    - 'ජුලි'\n    - 'අගෝස්තු'\n    - 'සැප්තැම්බර්'\n    - 'ඔක්තෝම්බර්'\n    - 'නොවැම්බර්'\n    - 'දෙසැම්බර්'\n  DAYS_OF_THE_WEEK:\n    - 'සඳුදා'\n    - 'අඟහරුවාදා'\n    - 'බදාදා'\n    - 'බ්රහස්පතින්දා'\n    - 'සිකුරාදා'\n    - 'සෙනසුරාදා'\n    - 'ඉරිදා'\n  YES: \"ඔව්\"\n  NO: \"නැත\"\n  CRON:\n    EVERY: සෑම\n    EVERY_HOUR: සෑම පැයකටම\n    EVERY_MINUTE: සෑම විනාඩියකටම\n    EVERY_DAY_OF_WEEK: සතියේ සෑම දිනකම\n    EVERY_DAY_OF_MONTH: මාසයේ සෑම දිනකම\n    EVERY_MONTH: සෑම මාසයකම\n    TEXT_PERIOD: සෑම <b />\n    TEXT_MINS: ' පැයට පසු විනාඩි <b /> කින්'\n    TEXT_TIME: ' <b />:<b />ට'\n    TEXT_DOW: ' <b />මත'\n    TEXT_MONTH: ' <b />'\n    TEXT_DOM: ' <b />මත'\n    ERROR1: ටැගය %s සහාය නොදක්වයි!\n    ERROR2: නරක මූලද්රව්ය සංඛ්යාව\n    ERROR3: jquery_element jqCron සැකසුම් වලට සැකසිය යුතුය\n    ERROR4: හඳුනා නොගත් ප්‍රකාශනය\n"
  },
  {
    "path": "system/languages/sk.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Chyba: Chybný frontmatter\\n\\nPath: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'vybavenie'\n    - 'informácie'\n    - 'ryža'\n    - 'peniaze'\n    - 'druhy'\n    - 'séria'\n    - 'ryba'\n    - 'ovce'\n  INFLECTOR_IRREGULAR:\n    'person': 'ľudia'\n    'man': 'muži'\n    'child': 'deti'\n    'sex': 'pohlavia'\n    'move': 'pohyby'\n  INFLECTOR_ORDINALS:\n    'default': '.'\n    'first': '.'\n    'second': '.'\n    'third': '.'\n  NICETIME:\n    NO_DATE_PROVIDED: Neposkytnutý žiaden dátum\n    BAD_DATE: Nesprávny dátum\n    AGO: pred\n    FROM_NOW: odteraz\n    JUST_NOW: práve teraz\n    SECOND: sekunda\n    MINUTE: minúta\n    HOUR: hodina\n    DAY: deň\n    WEEK: týždeň\n    MONTH: mesiac\n    YEAR: rok\n    DECADE: desaťročie\n    SEC: sek\n    MIN: min\n    HR: hod\n    WK: t\n    MO: m\n    YR: r\n    DEC: dec\n    SECOND_PLURAL: sekúnd\n    MINUTE_PLURAL: minút\n    HOUR_PLURAL: hodín\n    DAY_PLURAL: dní\n    WEEK_PLURAL: týždňov\n    MONTH_PLURAL: mesiacov\n    YEAR_PLURAL: rokov\n    DECADE_PLURAL: dekád\n    SEC_PLURAL: sek\n    MIN_PLURAL: min\n    HR_PLURAL: hod\n    WK_PLURAL: t\n    MO_PLURAL: mes.\n    YR_PLURAL: rokov\n    DEC_PLURAL: dekád\n  FORM:\n    VALIDATION_FAIL: '<b>Overenie zlyhalo:</b>'\n    INVALID_INPUT: 'Neplatný vstup v'\n    MISSING_REQUIRED_FIELD: 'Chýba vyžadované pole:'\n  MONTHS_OF_THE_YEAR:\n    - 'Január'\n    - 'Február'\n    - 'Marec'\n    - 'Apríl'\n    - 'Máj'\n    - 'Jún'\n    - 'Júl'\n    - 'August'\n    - 'September'\n    - 'Október'\n    - 'November'\n    - 'December'\n  DAYS_OF_THE_WEEK:\n    - 'Pondelok'\n    - 'Utorok'\n    - 'Streda'\n    - 'Štvrtok'\n    - 'Piatok'\n    - 'Sobota'\n    - 'Nedeľa'\n  CRON:\n    EVERY: každý\n    EVERY_HOUR: každú hodinu\n    EVERY_MINUTE: každú minútu\n    EVERY_DAY_OF_WEEK: každý deň v týždni\n    EVERY_DAY_OF_MONTH: každý deň v mesiaci\n    EVERY_MONTH: každý mesiac\n    TEXT_PERIOD: Každý <b />\n    TEXT_MINS: ' at <b /> minute(s) past the hour'\n    TEXT_TIME: ' at <b />:<b />'\n    TEXT_DOW: ' on <b />'\n    TEXT_MONTH: ' of <b />'\n    TEXT_DOM: ' on <b />'\n    ERROR1: Tag %s nieje podporovaný!\n    ERROR2: Chybný počet položiek\n    ERROR3: jquery_element musí byť nastavený v nastaveniach pre jqCron\n    ERROR4: Neznámy výraz\n"
  },
  {
    "path": "system/languages/sl.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Napaka: Neveljavna Frontmatter\\n\\nPath: `%2$s`\\n\\n**%3$s ** \\n\\n```\\n%4$s \\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'oprema'\n    - 'informacija'\n    - 'riž'\n    - 'denar'\n    - 'vrste'\n    - 'serija'\n    - 'riba'\n    - 'ovca'\n  INFLECTOR_IRREGULAR:\n    'person': 'ljudje'\n  NICETIME:\n    NO_DATE_PROVIDED: Datum ni na voljo\n    BAD_DATE: Neveljaven datum\n    AGO: pred\n    FROM_NOW: od zdaj\n    SECOND: sekunda\n    MINUTE: minuta\n    HOUR: ura\n    DAY: dan\n    WEEK: teden\n    MONTH: mesec\n    YEAR: leto\n    DECADE: desetletje\n    SEC: sek\n    HR: ur\n    WK: T.\n    MO: m\n    YR: l\n    DEC: des\n    SECOND_PLURAL: sekund\n    MINUTE_PLURAL: minut\n    HOUR_PLURAL: ure\n    DAY_PLURAL: dnevi\n    WEEK_PLURAL: tednov\n    MONTH_PLURAL: mesecev\n    YEAR_PLURAL: leta\n    DECADE_PLURAL: desetletja\n    SEC_PLURAL: s\n    MIN_PLURAL: min\n    HR_PLURAL: ur\n    WK_PLURAL: t\n    MO_PLURAL: m\n    YR_PLURAL: l\n    DEC_PLURAL: des\n  FORM:\n    VALIDATION_FAIL: '<b>Preverjanje veljavnosti ni uspelo:</b>'\n    INVALID_INPUT: 'Neveljaven vnos v'\n    MISSING_REQUIRED_FIELD: 'Manjka obvezno polje:'\n  MONTHS_OF_THE_YEAR:\n    - 'Januar'\n    - 'Februar'\n    - 'Marec'\n    - 'april'\n    - 'Maj'\n    - 'Junij'\n    - 'Julij'\n    - 'Avgust'\n    - 'september'\n    - 'Oktober'\n    - 'november'\n    - 'december'\n  DAYS_OF_THE_WEEK:\n    - 'Ponedeljek'\n    - 'Torek'\n    - 'Sreda'\n    - 'Četrtek'\n    - 'Petek'\n    - 'Sobota'\n    - 'Nedelja'\n  YES: \"Da\"\n  NO: \"Ne\"\n  CRON:\n    EVERY: vsak\n    EVERY_HOUR: vsako uro\n    EVERY_MINUTE: vsako minuto\n    EVERY_DAY_OF_WEEK: vsak dan v tednu\n    EVERY_DAY_OF_MONTH: vsak dan v mesecu\n    EVERY_MONTH: vsak mesec\n    ERROR1: Oznaka %s ni podprta!\n    ERROR2: Napačno število elementov.\n    ERROR4: Neznan izraz\n"
  },
  {
    "path": "system/languages/sr.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nнаслов: %1$s\\n---\\n\\n# Грешка: неисправан Frontmatter\\n\\nПутања: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'опрема'\n    - 'информација'\n    - 'пиринач'\n    - 'новац'\n    - 'врсте'\n    - 'серије'\n    - 'риба'\n    - 'овца'\n  INFLECTOR_IRREGULAR:\n    'person': 'особе'\n    'man': 'људи'\n    'child': 'деца'\n    'sex': 'полови'\n    'move': 'помери'\n  INFLECTOR_ORDINALS:\n    'default': 'ти'\n    'first': 'први'\n    'second': 'други'\n    'third': 'трећи'\n  NICETIME:\n    NO_DATE_PROVIDED: Нема датума\n    BAD_DATE: Погрешан датум\n    AGO: од пре\n    FROM_NOW: од сада\n    JUST_NOW: управо сада\n    SECOND: секунда\n    MINUTE: минута\n    HOUR: сат\n    DAY: дан\n    WEEK: недеља\n    MONTH: месец\n    YEAR: година\n    DECADE: декада\n    SEC: сек\n    MIN: мин\n    HR: сат\n    WK: нед\n    MO: мес\n    YR: год\n    DEC: дек\n    SECOND_PLURAL: секунди\n    MINUTE_PLURAL: минута\n    HOUR_PLURAL: сати\n    DAY_PLURAL: дана\n    WEEK_PLURAL: недеља\n    MONTH_PLURAL: месеци\n    YEAR_PLURAL: године(а)\n    DECADE_PLURAL: декаде(а)\n    SEC_PLURAL: сек\n    MIN_PLURAL: мин\n    HR_PLURAL: сати\n    WK_PLURAL: недеља\n    MO_PLURAL: месеци\n    YR_PLURAL: година\n    DEC_PLURAL: декада\n  FORM:\n    VALIDATION_FAIL: '<b>Провера неуспела:</b>'\n    INVALID_INPUT: 'Неисправан унос у'\n    MISSING_REQUIRED_FIELD: 'Недостаје обавезн поље:'\n    XSS_ISSUES: \"Потенцијална грешка у XSS-у детектована у пољу '%s' \"\n  MONTHS_OF_THE_YEAR:\n    - 'Јануар'\n    - 'Фебруар'\n    - 'Март'\n    - 'Април'\n    - 'Мај'\n    - 'Јуни'\n    - 'Јули'\n    - 'Август'\n    - 'Септембар'\n    - 'Октобар'\n    - 'Новембар'\n    - 'Децембар'\n  DAYS_OF_THE_WEEK:\n    - 'Понедељак'\n    - 'Уторак'\n    - 'Среда'\n    - 'Четвртак'\n    - 'Петак'\n    - 'Субота'\n    - 'Недеља'\n  YES: \"Да\"\n  NO: \"Не\"\n  CRON:\n    EVERY: сваки\n    EVERY_HOUR: сваки сат\n    EVERY_MINUTE: сваки минут\n    EVERY_DAY_OF_WEEK: сваки дан у недељи\n    EVERY_DAY_OF_MONTH: сваки дан у месецу\n    EVERY_MONTH: сваки месец\n    TEXT_PERIOD: Сваки <b />\n    TEXT_MINS: ' у <b /> минути(а) прошлог сата'\n    TEXT_TIME: ' у <b />:<b />'\n    TEXT_DOW: ' на <b />'\n    TEXT_MONTH: ' од <b />'\n    TEXT_DOM: ' на <b />'\n    ERROR1: Таг %s није подржан!\n    ERROR2: Погрешан број елемената\n    ERROR3: јquery_element би требао да буде постављен у jqCron подешавању\n    ERROR4: Непрепознат израз\n"
  },
  {
    "path": "system/languages/sv.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"--- titel: %1$s --- # Fel: Ogiltig Frontmatter-sökväg: `%2$s` **%3$s** ``` %4$s ```\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'utrustning'\n    - 'information'\n    - 'ris'\n    - 'pengar'\n    - 'arter'\n    - 'serier'\n    - 'fisk'\n    - 'får'\n  INFLECTOR_IRREGULAR:\n    'person': 'personer'\n    'man': 'män'\n    'child': 'barn'\n    'sex': 'kön'\n    'move': 'flytta'\n  INFLECTOR_ORDINALS:\n    'default': ':e'\n    'first': ':a'\n    'second': ':a'\n    'third': ':e'\n  NICETIME:\n    NO_DATE_PROVIDED: Inget datum har angivits\n    BAD_DATE: Ogiltigt datum\n    AGO: sedan\n    FROM_NOW: fr.o.m nu\n    JUST_NOW: just nu\n    SECOND: sekund\n    MINUTE: minut\n    HOUR: timme\n    DAY: dag\n    WEEK: vecka\n    MONTH: månad\n    YEAR: år\n    DECADE: årtionde\n    SEC: sek\n    MIN: min\n    HR: t\n    WK: v\n    MO: m\n    YR: år\n    DEC: dec\n    SECOND_PLURAL: sekunder\n    MINUTE_PLURAL: minuter\n    HOUR_PLURAL: timmar\n    DAY_PLURAL: dagar\n    WEEK_PLURAL: veckor\n    MONTH_PLURAL: månader\n    YEAR_PLURAL: år\n    DECADE_PLURAL: årtionden\n    SEC_PLURAL: sek\n    MIN_PLURAL: min\n    HR_PLURAL: t\n    WK_PLURAL: v\n    MO_PLURAL: må\n    YR_PLURAL: år\n    DEC_PLURAL: dec\n  FORM:\n    VALIDATION_FAIL: '<b>Kontrollen misslyckades:</b>'\n    INVALID_INPUT: 'Ogiltig indata i'\n    MISSING_REQUIRED_FIELD: 'Obligatoriskt fält måste fyllas i:'\n  MONTHS_OF_THE_YEAR:\n    - 'Januari'\n    - 'Februari'\n    - 'Mars'\n    - 'April'\n    - 'Maj'\n    - 'Juni'\n    - 'Juli'\n    - 'Augusti'\n    - 'September'\n    - 'Oktober'\n    - 'November'\n    - 'December'\n  DAYS_OF_THE_WEEK:\n    - 'Måndag'\n    - 'Tisdag'\n    - 'Onsdag'\n    - 'Torsdag'\n    - 'Fredag'\n    - 'Lördag'\n    - 'Söndag'\n  CRON:\n    EVERY: varje\n    EVERY_HOUR: varje timme\n    EVERY_MINUTE: varje minut\n    EVERY_DAY_OF_WEEK: varje veckodag\n    EVERY_DAY_OF_MONTH: alla månadens dagar\n    EVERY_MONTH: varje månad\n    TEXT_PERIOD: Varje <b />\n    TEXT_MINS: ' timmens <b />:e minut'\n    TEXT_TIME: ' kl <b />:<b />'\n    TEXT_DOW: ' <b />'\n    TEXT_MONTH: ' <b />'\n    TEXT_DOM: ' <b />'\n    ERROR1: Taggen %s stöds inte!\n    ERROR2: Ogiltigt antal element\n    ERROR4: Uttrycket känns inte igen\n"
  },
  {
    "path": "system/languages/sw.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nkichwa: %1$s\\n---\\n\\n# Kosa: Mbele ya Mbele\\n\\nNjia: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'vifaa'\n    - 'habari'\n    - 'mchele'\n    - 'pesa'\n    - 'spishi'\n    - 'mfululizo'\n    - 'samaki'\n    - 'kondoo'\n  INFLECTOR_IRREGULAR:\n    'person': 'watu'\n    'man': 'wanaume'\n    'child': 'watoto'\n    'sex': 'jinsia'\n    'move': 'songa'\n  INFLECTOR_ORDINALS:\n    'default': 'th'\n    'first': 'st'\n    'second': 'nd'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: Hakuna tarehe iliyotolewa\n    BAD_DATE: Tarehe mbaya\n    AGO: zilizopita\n    FROM_NOW: kuanzia sasa\n    JUST_NOW: sasa hivi\n    SECOND: pili\n    MINUTE: dakika\n    HOUR: saa\n    DAY: siku\n    WEEK: wiki\n    MONTH: mwezi\n    YEAR: mwaka\n    DECADE: muongo\n    SEC: sec\n    MIN: min\n    HR: hr\n    WK: wk\n    MO: mo\n    YR: yr\n    DEC: dec\n    SECOND_PLURAL: sekunde\n    MINUTE_PLURAL: dakika\n    HOUR_PLURAL: masaa\n    DAY_PLURAL: siku\n    WEEK_PLURAL: wiki\n    MONTH_PLURAL: miezi\n    YEAR_PLURAL: miaka\n    DECADE_PLURAL: miongo\n    SEC_PLURAL: secs\n    MIN_PLURAL: mins\n    HR_PLURAL: hrs\n    WK_PLURAL: wks\n    MO_PLURAL: mos\n    YR_PLURAL: yrs\n    DEC_PLURAL: decs\n  FORM:\n    VALIDATION_FAIL: '<b> Uthibitishaji umeshindwa: </b>'\n    INVALID_INPUT: 'Ingizo batili katika'\n    MISSING_REQUIRED_FIELD: 'Sehemu inayokosekana inahitajika:'\n    XSS_ISSUES: \"Masuala yanayowezekana ya XSS yamegunduliwa katika uwanja wa '% s\"\n  MONTHS_OF_THE_YEAR:\n    - 'Januari'\n    - 'Februari'\n    - 'Machi'\n    - 'Aprili'\n    - 'Mei'\n    - 'Juni'\n    - 'Julai'\n    - 'Agosti'\n    - 'Septemba'\n    - 'Oktoba'\n    - 'Novemba'\n    - 'Desemba'\n  DAYS_OF_THE_WEEK:\n    - 'Jumatatu'\n    - 'Jumanne'\n    - 'Jumatano'\n    - 'Alhamisi'\n    - 'Ijumaa'\n    - 'Jumamosi'\n    - 'Jumapili'\n  YES: \"Ndiyo\"\n  NO: \"Hapana\"\n  CRON:\n    EVERY: kila\n    EVERY_HOUR: kila saa\n    EVERY_MINUTE: kila dakika\n    EVERY_DAY_OF_WEEK: kila siku ya juma\n    EVERY_DAY_OF_MONTH: kila siku ya mwezi\n    EVERY_MONTH: kila mwezi\n    TEXT_PERIOD: Kila <b />\n    TEXT_MINS: ' saa <b /> dakika (saa) zilizopita saa'\n    TEXT_TIME: ' saa <b />: <b />'\n    TEXT_DOW: ' kwenye <b />'\n    TEXT_MONTH: ' ya <b />'\n    TEXT_DOM: ' kwenye <b />'\n    ERROR1: Lebo% s haitumiki!\n    ERROR2: Idadi mbaya ya vitu\n    ERROR3: Jquery_element inapaswa kuwekwa kwenye mipangilio ya jqCron\n    ERROR4: Maneno yasiyotambulika\n"
  },
  {
    "path": "system/languages/th.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Error: Invalid Frontmatter\\n\\nPath: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - 'อุปกรณ์'\n    - 'ข้อมูล'\n    - 'ข้าว'\n    - 'เงิน'\n    - 'สายพันธุ์'\n    - 'ซีรีส์'\n    - 'ปลา'\n    - 'แกะ'\n  INFLECTOR_IRREGULAR:\n    'person': 'คน'\n    'man': 'ผู้ชาย'\n    'child': 'เด็กเด็ก'\n    'sex': 'เพศ'\n    'move': 'ย้าย'\n  INFLECTOR_ORDINALS:\n    'default': 'th'\n    'first': 'st'\n    'second': 'nd'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: ไม่มีวันที่ให้\n    BAD_DATE: รูปแบบวันที่ผิด\n    AGO: ที่ผ่านมา\n    FROM_NOW: จากตอนนี้\n    JUST_NOW: เมื่อกี้\n    SECOND: วินาที\n    MINUTE: นาที\n    HOUR: ชั่วโมง\n    DAY: วัน\n    WEEK: สัปดาห์\n    MONTH: เดือน\n    YEAR: ปี\n    DECADE: ทศวรรษที่ผ่านมา\n    SEC: วิ\n    MIN: นาที\n    HR: ชม.\n    WK: wk\n    MO: mo\n    YR: yr\n    DEC: dec\n    SECOND_PLURAL: วินาที\n    MINUTE_PLURAL: นาที\n    HOUR_PLURAL: ชั่วโมง\n    DAY_PLURAL: วัน\n    WEEK_PLURAL: สัปดาห์\n    MONTH_PLURAL: เดือน\n    YEAR_PLURAL: ปี\n    DECADE_PLURAL: ทศวรรษที่ผ่านมา\n    SEC_PLURAL: วินาที\n    MIN_PLURAL: นาที\n    HR_PLURAL: ชั่วโมง\n    WK_PLURAL: wks\n    MO_PLURAL: mos\n    YR_PLURAL: ปี\n    DEC_PLURAL: decs\n  FORM:\n    VALIDATION_FAIL: '<b>ตรวจสอบล้มเหลว: </b>'\n    INVALID_INPUT: 'ป้อนข้อมูลไม่ถูกต้องใน'\n    MISSING_REQUIRED_FIELD: 'ขาดข้อมูลที่จำเป็น:'\n    XSS_ISSUES: \"ตรวจพบปัญหา XSS ที่เป็นไปได้ในฟิลด์ '%s'\"\n  MONTHS_OF_THE_YEAR:\n    - 'มกราคม'\n    - 'กุมภาพันธ์'\n    - 'มีนาคม'\n    - 'เมษายน'\n    - 'พฤษภาคม'\n    - 'มิถุนายน'\n    - 'กรกฏาคม'\n    - 'สิงหาคม'\n    - 'กันยายน'\n    - 'ตุลาคม'\n    - 'พฤศจิกายน'\n    - 'ธันวาคม'\n  DAYS_OF_THE_WEEK:\n    - 'จันทร์'\n    - 'อังคาร'\n    - 'พุธ'\n    - 'พฤหัสบดี'\n    - 'ศุกร์'\n    - 'เสาร์'\n    - 'อาทิตย์'\n  YES: \"ใช่\"\n  NO: \"ไม่\"\n  CRON:\n    EVERY: ทุก ๆ\n    EVERY_HOUR: ทุกชั่วโมง\n    EVERY_MINUTE: ทุกนาที\n    EVERY_DAY_OF_WEEK: ทุกวันในสัปดาห์\n    EVERY_DAY_OF_MONTH: ทุกวันของเดือน\n    EVERY_MONTH: ทุกเดือน\n    TEXT_PERIOD: ทุก ๆ <b />\n    TEXT_MINS: ' ที่ <b /> นาทีที่ผ่านไปแล้ว'\n    TEXT_TIME: ' เวลา <b />:<b />'\n    TEXT_DOW: ' บน <b />'\n    TEXT_MONTH: ' จาก <b />'\n    TEXT_DOM: ' บน <b />'\n    ERROR1: ไม่รองรับแท็ก %s!\n    ERROR2: จำนวนองค์ประกอบไม่ดี\n    ERROR3: ควรตั้งค่า jquery_element เป็นการตั้งค่า jqCron\n    ERROR4: นิพจน์ที่ไม่รู้จัก\n"
  },
  {
    "path": "system/languages/tr.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\nBaşlık: %1$s\\n---\\n\\n# Hata: Geçersiz Önbölüm\\n\\nYol: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_UNCOUNTABLE:\n    - 'ekipman'\n    - 'bilgi'\n    - 'pirinç'\n    - 'para'\n    - 'türler'\n    - 'seriler'\n    - 'balık'\n    - 'koyun'\n  INFLECTOR_IRREGULAR:\n    'person': 'kişi'\n    'man': 'erkek'\n    'child': 'çocuklar'\n    'sex': 'cinsiyet'\n    'move': 'taşınmış'\n  INFLECTOR_ORDINALS:\n    'default': '#F'\n    'first': ' 1.'\n    'second': ' 2.'\n    'third': ' 3.'\n  NICETIME:\n    NO_DATE_PROVIDED: Sağlanan tarih yok\n    BAD_DATE: Yanlış tarih\n    AGO: önce\n    FROM_NOW: şu andan itibaren\n    JUST_NOW: şimdi\n    SECOND: saniye\n    MINUTE: dakika\n    HOUR: saat\n    DAY: gün\n    WEEK: hafta\n    MONTH: ay\n    YEAR: yıl\n    DECADE: onyıl\n    SEC: sn\n    MIN: dk\n    HR: sa\n    WK: hft\n    MO: ay\n    YR: yl\n    DEC: onyl\n    SECOND_PLURAL: saniye\n    MINUTE_PLURAL: dakika\n    HOUR_PLURAL: saat\n    DAY_PLURAL: gün\n    WEEK_PLURAL: hafta\n    MONTH_PLURAL: ay\n    YEAR_PLURAL: yıl\n    DECADE_PLURAL: onyıl\n    SEC_PLURAL: sn\n    MIN_PLURAL: dk\n    HR_PLURAL: sa\n    WK_PLURAL: hft\n    MO_PLURAL: ay\n    YR_PLURAL: yıl\n    DEC_PLURAL: onyl\n  FORM:\n    VALIDATION_FAIL: '<b>Doğrulama başarısız:</b>'\n    INVALID_INPUT: 'Geçersiz bilgi girişi'\n    MISSING_REQUIRED_FIELD: 'Gerekli alan eksik:'\n  MONTHS_OF_THE_YEAR:\n    - 'Ocak'\n    - 'Şubat'\n    - 'Mart'\n    - 'Nisan'\n    - 'Mayıs'\n    - 'Haziran'\n    - 'Temmuz'\n    - 'Ağustos'\n    - 'Eylül'\n    - 'Ekim'\n    - 'Kasım'\n    - 'Aralık'\n  DAYS_OF_THE_WEEK:\n    - 'Pazartesi'\n    - 'Salı'\n    - 'Çarşamba'\n    - 'Perşembe'\n    - 'Cuma'\n    - 'Cumartesi'\n    - 'Pazar'\n  YES: \"Evet\"\n  NO: \"Hayır\"\n  CRON:\n    EVERY: her\n    EVERY_HOUR: saatte bir\n    EVERY_MINUTE: dakikada bir\n    EVERY_DAY_OF_WEEK: haftanın her günü\n    EVERY_DAY_OF_MONTH: ayın her günü\n    EVERY_MONTH: her ay\n    TEXT_PERIOD: Her <b />\n    TEXT_MINS: ' saatin <b /> dakikasında'\n    TEXT_TIME: ' da'\n    ERROR1: Etiket %s desteklenmiyor!\n    ERROR2: Kötü eleman sayısı\n    ERROR3: jquery_element jqCron ayarları içinde tanımlanmalı\n    ERROR4: Tanınmayan ifade\n"
  },
  {
    "path": "system/languages/uk.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# Помилка: Недопустимий вміст\\n\\nPath: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  NICETIME:\n    NO_DATE_PROVIDED: Не вказана дата\n    BAD_DATE: Невірна дата\n    AGO: назад\n    FROM_NOW: відтепер\n    SECOND: секунда\n    MINUTE: хвилина\n    HOUR: година\n    DAY: день\n    WEEK: тиждень\n    MONTH: місяць\n    YEAR: рік\n    DECADE: десятиріччя\n    SEC: с\n    MIN: хв\n    HR: год\n    WK: тиж.\n    MO: міс.\n    YR: р.\n    DEC: рр.\n    SECOND_PLURAL: секунди\n    MINUTE_PLURAL: хвилини\n    HOUR_PLURAL: години\n    DAY_PLURAL: дні\n    WEEK_PLURAL: тижні\n    MONTH_PLURAL: місяці\n    YEAR_PLURAL: роки\n    DECADE_PLURAL: десятиріччя\n    SEC_PLURAL: с\n    MIN_PLURAL: хв\n    HR_PLURAL: год\n    WK_PLURAL: тиж.\n    MO_PLURAL: міс.\n    YR_PLURAL: рр.\n    DEC_PLURAL: рр.\n  FORM:\n    VALIDATION_FAIL: '<b>Перевірка не вдалася:</b>'\n    INVALID_INPUT: 'Невірне введення в'\n    MISSING_REQUIRED_FIELD: 'Відсутнє обов''язкове поле:'\n  MONTHS_OF_THE_YEAR:\n    - 'Січень'\n    - 'Лютий'\n    - 'Березень'\n    - 'Квітень'\n    - 'Травень'\n    - 'Червень'\n    - 'Липень'\n    - 'Серпень'\n    - 'Вересень'\n    - 'Жовтень'\n    - 'Листопад'\n    - 'Грудень'\n  DAYS_OF_THE_WEEK:\n    - 'Понеділок'\n    - 'Вівторок'\n    - 'Середа'\n    - 'Четвер'\n    - 'П''ятниця'\n    - 'Субота'\n    - 'Неділя'\n"
  },
  {
    "path": "system/languages/vi.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntiêu đề: %1$s\\n---\\n\\n# Error: Trang không hợp lệ\\n\\nĐường dẫn: `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  NICETIME:\n    NO_DATE_PROVIDED: Không có ngày được cung cấp\n    BAD_DATE: Ngày không hợp lệ\n    AGO: cách đây\n    FROM_NOW: từ bây giờ\n    SECOND: giây\n    MINUTE: phút\n    HOUR: giờ\n    DAY: ngày\n    WEEK: tuần\n    MONTH: tháng\n    YEAR: năm\n    DECADE: thập kỷ\n    SEC: giây\n    MIN: phút\n    HR: giờ\n    WK: tuần\n    MO: tháng\n    YR: năm\n    DEC: thập kỷ\n    SECOND_PLURAL: giây\n    MINUTE_PLURAL: phút\n    HOUR_PLURAL: giờ\n    DAY_PLURAL: ngày\n    WEEK_PLURAL: tuần\n    MONTH_PLURAL: tháng\n    YEAR_PLURAL: năm\n    DECADE_PLURAL: thập kỷ\n    SEC_PLURAL: giây\n    MIN_PLURAL: phút\n    HR_PLURAL: giờ\n    WK_PLURAL: tuần\n    MO_PLURAL: tháng\n    YR_PLURAL: năm\n    DEC_PLURAL: thập kỷ\n  FORM:\n    VALIDATION_FAIL: '<b>Xác nhận thất bại:</b>'\n    INVALID_INPUT: 'Dữ liệu nhập không hợp lệ cho'\n    MISSING_REQUIRED_FIELD: 'Thiếu trường bắt buộc:'\n  MONTHS_OF_THE_YEAR:\n    - 'Tháng 1'\n    - 'Tháng 2'\n    - 'Tháng 3'\n    - 'Tháng 4'\n    - 'Tháng 5'\n    - 'Tháng 6'\n    - 'Tháng 7'\n    - 'Tháng 8'\n    - 'Tháng 9'\n    - 'Tháng 10'\n    - 'Tháng 11'\n    - 'Tháng 12'\n  DAYS_OF_THE_WEEK:\n    - 'Thứ 2'\n    - 'Thứ 3'\n    - 'Thứ 4'\n    - 'Thứ 5'\n    - 'Thứ 6'\n    - 'Thứ 7'\n    - 'Chủ Nhật'\n"
  },
  {
    "path": "system/languages/zh-cn.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\n标题: %1$s\\n---\\n\\n# 错误：无效参数\\n\\n位置： `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - '装备'\n    - '信息'\n    - '大米'\n    - '钱'\n    - '物种'\n    - '系列'\n    - '鱼'\n    - '羊'\n  INFLECTOR_IRREGULAR:\n    'person': '人员'\n    'man': '男人'\n    'child': '儿童'\n    'sex': '性别'\n    'move': '移动'\n  INFLECTOR_ORDINALS:\n    'default': 'th'\n    'first': 'st'\n    'second': 'md'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: 无日期信息\n    BAD_DATE: 无效日期\n    AGO: 前\n    FROM_NOW: 距今\n    JUST_NOW: 刚刚\n    SECOND: 秒\n    MINUTE: 分钟\n    HOUR: 小时\n    DAY: 天\n    WEEK: 周\n    MONTH: 月\n    YEAR: 年\n    DECADE: 十年\n    SEC: 秒\n    MIN: 分钟\n    HR: 小时\n    WK: 周\n    MO: 月\n    YR: 年\n    DEC: 年代\n    SECOND_PLURAL: 秒\n    MINUTE_PLURAL: 分\n    HOUR_PLURAL: 小时\n    DAY_PLURAL: 天\n    WEEK_PLURAL: 周\n    MONTH_PLURAL: 月\n    YEAR_PLURAL: 年\n    DECADE_PLURAL: 十年\n    SEC_PLURAL: 秒\n    MIN_PLURAL: 分\n    HR_PLURAL: 时\n    WK_PLURAL: 周\n    MO_PLURAL: 月\n    YR_PLURAL: 年\n    DEC_PLURAL: 年代\n  FORM:\n    VALIDATION_FAIL: '<b>验证失败：</b>'\n    INVALID_INPUT: '无效输入'\n    MISSING_REQUIRED_FIELD: '必填字段缺失：'\n  MONTHS_OF_THE_YEAR:\n    - '1月'\n    - '2月'\n    - '3月'\n    - '4月'\n    - '5月'\n    - '6月'\n    - '7月'\n    - '8月'\n    - '9月'\n    - '10月'\n    - '11月'\n    - '12月'\n  DAYS_OF_THE_WEEK:\n    - '星期一'\n    - '星期二'\n    - '星期三'\n    - '星期四'\n    - '星期五'\n    - '星期六'\n    - '星期日'\n  YES: \"是\"\n  NO: \"否\"\n  CRON:\n    EVERY: 每隔\n    EVERY_HOUR: 每小时\n    EVERY_MINUTE: 每分钟\n    EVERY_DAY_OF_WEEK: 一周中的每一天\n    EVERY_DAY_OF_MONTH: 月份中的每一天\n    EVERY_MONTH: 每月\n    TEXT_PERIOD: 所有 <b />\n    TEXT_MINS: ' 在 <b /> 小时过后的分钟'\n    TEXT_TIME: ' 在 <b />:<b />'\n    TEXT_DOW: ' on <b />'\n    TEXT_MONTH: ' of <b />'\n    TEXT_DOM: ' on <b />'\n    ERROR1: 不支持分享类型 %s\n    ERROR2: 无效数字\n    ERROR3: 请在 jqCron 设置中设定 jquery_element\n    ERROR4: 无法识别表达式\n"
  },
  {
    "path": "system/languages/zh-tw.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\ntitle: %1$s\\n---\\n\\n# 錯誤： 不正確的 Frontmatter\\n\\n路徑： `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  NICETIME:\n    NO_DATE_PROVIDED: 沒有提供日期\n    BAD_DATE: 錯誤日期\n    AGO: 之前\n    FROM_NOW: 之後\n    JUST_NOW: 剛剛\n    SECOND: 秒\n    MINUTE: 分\n    HOUR: 小時\n    DAY: 天\n    WEEK: 週\n    MONTH: 月\n    YEAR: 年\n    DECADE: 十年\n    SEC: 秒\n    MIN: 分\n    HR: 小時\n    WK: 週\n    MO: 月\n    YR: 年\n    DEC: 十年\n    SECOND_PLURAL: 秒\n    MINUTE_PLURAL: 分\n    HOUR_PLURAL: 小時\n    DAY_PLURAL: 天\n    WEEK_PLURAL: 週\n    MONTH_PLURAL: 月\n    YEAR_PLURAL: 年\n    DECADE_PLURAL: 十年\n    SEC_PLURAL: 秒\n    MIN_PLURAL: 分\n    HR_PLURAL: 時\n    WK_PLURAL: 週\n    MO_PLURAL: 月\n    YR_PLURAL: 年\n    DEC_PLURAL: 十年\n  FORM:\n    VALIDATION_FAIL: '<b>確驗證失敗：</b>'\n    INVALID_INPUT: '無效輸入：'\n    MISSING_REQUIRED_FIELD: '遺漏必填欄位：'\n  MONTHS_OF_THE_YEAR:\n    - '一月'\n    - '二月'\n    - '三月'\n    - '四月'\n    - '五月'\n    - '六月'\n    - '七月'\n    - '八月'\n    - '九月'\n    - '十月'\n    - '十一月'\n    - '十二月'\n  DAYS_OF_THE_WEEK:\n    - '星期一'\n    - '星期二'\n    - '星期三'\n    - '星期四'\n    - '星期五'\n    - '星期六'\n    - '星期日'\n  YES: \"是\"\n  NO: \"否\"\n  CRON:\n    EVERY: 每\n    EVERY_HOUR: 每小時\n    EVERY_MINUTE: 每分鐘\n    EVERY_DAY_OF_WEEK: 每一天\n    EVERY_DAY_OF_MONTH: 每一天\n    EVERY_MONTH: 每個月\n    TEXT_PERIOD: 每 <b />\n    TEXT_MINS: ' 的 <b /> 分'\n    TEXT_TIME: ' <b />:<b />'\n    TEXT_DOW: ' 的 <b />'\n    TEXT_MONTH: ' 的 <b />'\n    TEXT_DOM: ' 的 <b />'\n"
  },
  {
    "path": "system/languages/zh.yaml",
    "content": "---\nGRAV:\n  FRONTMATTER_ERROR_PAGE: \"---\\n标题: %1$s\\n---\\n\\n# 错误：无效参数\\n\\n位置： `%2$s`\\n\\n**%3$s**\\n\\n```\\n%4$s\\n```\"\n  INFLECTOR_PLURALS:\n    '/(quiz)$/i': '\\1zes'\n    '/^(ox)$/i': '\\1en'\n    '/([m|l])ouse$/i': '\\1ice'\n    '/(matr|vert|ind)ix|ex$/i': '\\1ices'\n    '/(x|ch|ss|sh)$/i': '\\1es'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([^aeiouy]|qu)y$/i': '\\1ies'\n    '/(hive)$/i': '\\1s'\n    '/(?:([^f])fe|([lr])f)$/i': '\\1\\2ves'\n    '/sis$/i': 'ses'\n    '/([ti])um$/i': '\\1a'\n    '/(buffal|tomat)o$/i': '\\1oes'\n    '/(bu)s$/i': '\\1ses'\n    '/(alias|status)/i': '\\1es'\n    '/(octop|vir)us$/i': '\\1i'\n    '/(ax|test)is$/i': '\\1es'\n    '/s$/i': 's'\n    '/$/': 's'\n  INFLECTOR_SINGULAR:\n    '/(quiz)zes$/i': '\\1'\n    '/(matr)ices$/i': '\\1ix'\n    '/(vert|ind)ices$/i': '\\1ex'\n    '/^(ox)en/i': '\\1'\n    '/(alias|status)es$/i': '\\1'\n    '/([octop|vir])i$/i': '\\1us'\n    '/(cris|ax|test)es$/i': '\\1is'\n    '/(shoe)s$/i': '\\1'\n    '/(o)es$/i': '\\1'\n    '/(bus)es$/i': '\\1'\n    '/([m|l])ice$/i': '\\1ouse'\n    '/(x|ch|ss|sh)es$/i': '\\1'\n    '/(m)ovies$/i': '\\1ovie'\n    '/(s)eries$/i': '\\1eries'\n    '/([^aeiouy]|qu)ies$/i': '\\1y'\n    '/([lr])ves$/i': '\\1f'\n    '/(tive)s$/i': '\\1'\n    '/(hive)s$/i': '\\1'\n    '/([^f])ves$/i': '\\1fe'\n    '/(^analy)ses$/i': '\\1sis'\n    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\\1\\2sis'\n    '/([ti])a$/i': '\\1um'\n    '/(n)ews$/i': '\\1ews'\n  INFLECTOR_UNCOUNTABLE:\n    - '装备'\n    - '信息'\n    - '大米'\n    - '钱'\n    - '物种'\n    - '系列'\n    - '鱼'\n    - '羊'\n  INFLECTOR_IRREGULAR:\n    'person': '人员'\n    'man': '男人'\n    'child': '儿童'\n    'sex': '性别'\n    'move': '移动'\n  INFLECTOR_ORDINALS:\n    'default': 'th'\n    'first': 'st'\n    'second': 'md'\n    'third': 'rd'\n  NICETIME:\n    NO_DATE_PROVIDED: 无日期信息\n    BAD_DATE: 无效日期\n    AGO: 前\n    FROM_NOW: 距今\n    JUST_NOW: 刚刚\n    SECOND: 秒\n    MINUTE: 分钟\n    HOUR: 小时\n    DAY: 天\n    WEEK: 周\n    MONTH: 月\n    YEAR: 年\n    DECADE: 十年\n    SEC: 秒\n    MIN: 分钟\n    HR: 小时\n    WK: 周\n    MO: 月\n    YR: 年\n    DEC: 年代\n    SECOND_PLURAL: 秒\n    MINUTE_PLURAL: 分\n    HOUR_PLURAL: 小时\n    DAY_PLURAL: 天\n    WEEK_PLURAL: 周\n    MONTH_PLURAL: 月\n    YEAR_PLURAL: 年\n    DECADE_PLURAL: 十年\n    SEC_PLURAL: 秒\n    MIN_PLURAL: 分\n    HR_PLURAL: 时\n    WK_PLURAL: 周\n    MO_PLURAL: 月\n    YR_PLURAL: 年\n    DEC_PLURAL: 年代\n  FORM:\n    VALIDATION_FAIL: '<b>验证失败：</b>'\n    INVALID_INPUT: '无效输入'\n    MISSING_REQUIRED_FIELD: '必填字段缺失：'\n  MONTHS_OF_THE_YEAR:\n    - '1月'\n    - '2月'\n    - '3月'\n    - '4月'\n    - '5月'\n    - '6月'\n    - '7月'\n    - '8月'\n    - '9月'\n    - '10月'\n    - '11月'\n    - '12月'\n  DAYS_OF_THE_WEEK:\n    - '星期一'\n    - '星期二'\n    - '星期三'\n    - '星期四'\n    - '星期五'\n    - '星期六'\n    - '星期日'\n  YES: \"是\"\n  NO: \"否\"\n  CRON:\n    EVERY: 每隔\n    EVERY_HOUR: 每小时\n    EVERY_MINUTE: 每分钟\n    EVERY_DAY_OF_WEEK: 一周中的每一天\n    EVERY_DAY_OF_MONTH: 月份中的每一天\n    EVERY_MONTH: 每月\n    TEXT_PERIOD: 所有 <b />\n    TEXT_MINS: ' 在 <b /> 小时过后的分钟'\n    TEXT_TIME: ' 在 <b />:<b />'\n    TEXT_DOW: ' on <b />'\n    TEXT_MONTH: ' of <b />'\n    TEXT_DOM: ' on <b />'\n    ERROR1: 不支持分享类型 %s\n    ERROR2: 无效数字\n    ERROR3: 请在 jqCron 设置中设定 jquery_element\n    ERROR4: 无法识别表达式\n"
  },
  {
    "path": "system/pages/notfound.md",
    "content": "---\ntitle: Not Found\nroutable: false\nnotfound: true\nexpires: 0\n---\n"
  },
  {
    "path": "system/router.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Core\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nif (PHP_SAPI !== 'cli-server') {\n    die('This script cannot be run from browser. Run it from a CLI.');\n}\n\n$_SERVER['PHP_CLI_ROUTER'] = true;\n\n$root = $_SERVER['DOCUMENT_ROOT'];\n$path = $_SERVER['SCRIPT_NAME'];\nif ($path !== '/index.php' && is_file($root . $path)) {\n    if (!(\n        // Block all direct access to files and folders beginning with a dot\n        strpos($path, '/.') !== false\n        // Block all direct access for these folders\n        || preg_match('`^/(\\.git|cache|bin|logs|backup|webserver-configs|tests)/`ui', $path)\n        // Block access to specific file types for these system folders\n        || preg_match('`^/(system|vendor)/(.*)\\.(txt|xml|md|html|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$`ui', $path)\n        // Block access to specific file types for these user folders\n        || preg_match('`^/(user)/(.*)\\.(txt|md|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$`ui', $path)\n        // Block all direct access to .md files\n        || preg_match('`\\.md$`ui', $path)\n        // Block access to specific files in the root folder\n        || preg_match('`^/(LICENSE\\.txt|composer\\.lock|composer\\.json|\\.htaccess)$`ui', $path)\n    )) {\n        return false;\n    }\n}\n\n$grav_index = 'index.php';\n\n/* Check the GRAV_BASEDIR environment variable and use if set */\n\n$grav_basedir = getenv('GRAV_BASEDIR') ?: '';\nif ($grav_basedir) {\n    $grav_index = ltrim($grav_basedir, '/') . DIRECTORY_SEPARATOR . $grav_index;\n    $grav_basedir = DIRECTORY_SEPARATOR . trim($grav_basedir, DIRECTORY_SEPARATOR);\n    define('GRAV_ROOT', str_replace(DIRECTORY_SEPARATOR, '/', getcwd()) . $grav_basedir);\n}\n\n$_SERVER = array_merge($_SERVER, $_ENV);\n$_SERVER['SCRIPT_FILENAME'] = $_SERVER['DOCUMENT_ROOT'] . $grav_basedir .DIRECTORY_SEPARATOR . 'index.php';\n$_SERVER['SCRIPT_NAME'] = $grav_basedir . DIRECTORY_SEPARATOR . 'index.php';\n$_SERVER['PHP_SELF'] = $grav_basedir . DIRECTORY_SEPARATOR . 'index.php';\n\nerror_log(sprintf('%s:%d [%d]: %s', $_SERVER['REMOTE_ADDR'], $_SERVER['REMOTE_PORT'], http_response_code(), $_SERVER['REQUEST_URI']), 4);\n\nrequire $grav_index;\n"
  },
  {
    "path": "system/src/DOMLettersIterator.php",
    "content": "<?php\n\n/**\n * Iterates individual characters (Unicode codepoints) of DOM text and CDATA nodes\n * while keeping track of their position in the document.\n *\n * Example:\n *\n *  $doc = new DOMDocument();\n *  $doc->load('example.xml');\n *  foreach(new DOMLettersIterator($doc) as $letter) echo $letter;\n *\n * NB: If you only need characters without their position\n *     in the document, use DOMNode->textContent instead.\n *\n * @author porneL http://pornel.net\n * @license Public Domain\n * @url https://github.com/antoligy/dom-string-iterators\n *\n * @implements Iterator<int,string>\n */\nfinal class DOMLettersIterator implements Iterator\n{\n    /** @var DOMElement */\n    private $start;\n    /** @var DOMElement|null */\n    private $current;\n    /** @var int */\n    private $offset = -1;\n    /** @var int|null */\n    private $key;\n    /** @var array<int,string>|null */\n    private $letters;\n\n    /**\n     * expects DOMElement or DOMDocument (see DOMDocument::load and DOMDocument::loadHTML)\n     *\n     * @param DOMNode $el\n     */\n    public function __construct(DOMNode $el)\n    {\n        if ($el instanceof DOMDocument) {\n            $el = $el->documentElement;\n        }\n\n        if (!$el instanceof DOMElement) {\n            throw new InvalidArgumentException('Invalid arguments, expected DOMElement or DOMDocument');\n        }\n\n        $this->start = $el;\n    }\n\n    /**\n     * Returns position in text as DOMText node and character offset.\n     * (it's NOT a byte offset, you must use mb_substr() or similar to use this offset properly).\n     * node may be NULL if iterator has finished.\n     *\n     * @return array\n     */\n    public function currentTextPosition(): array\n    {\n        return [$this->current, $this->offset];\n    }\n\n    /**\n     * Returns DOMElement that is currently being iterated or NULL if iterator has finished.\n     *\n     * @return DOMElement|null\n     */\n    public function currentElement(): ?DOMElement\n    {\n        return $this->current ? $this->current->parentNode : null;\n    }\n\n    // Implementation of Iterator interface\n\n    /**\n     * @return int|null\n     */\n    public function key(): ?int\n    {\n        return $this->key;\n    }\n\n    /**\n     * @return void\n     */\n    public function next(): void\n    {\n        if (null === $this->current) {\n            return;\n        }\n\n        if ($this->current->nodeType === XML_TEXT_NODE || $this->current->nodeType === XML_CDATA_SECTION_NODE) {\n            if ($this->offset === -1) {\n                preg_match_all('/./us', $this->current->textContent, $m);\n                $this->letters = $m[0];\n            }\n\n            $this->offset++;\n            $this->key++;\n            if ($this->letters && $this->offset < count($this->letters)) {\n                return;\n            }\n\n            $this->offset = -1;\n        }\n\n        while ($this->current->nodeType === XML_ELEMENT_NODE && $this->current->firstChild) {\n            $this->current = $this->current->firstChild;\n            if ($this->current->nodeType === XML_TEXT_NODE || $this->current->nodeType === XML_CDATA_SECTION_NODE) {\n                $this->next();\n                return;\n            }\n        }\n\n        while (!$this->current->nextSibling && $this->current->parentNode) {\n            $this->current = $this->current->parentNode;\n            if ($this->current === $this->start) {\n                $this->current = null;\n                return;\n            }\n        }\n\n        $this->current = $this->current->nextSibling;\n\n        $this->next();\n    }\n\n    /**\n     * Return the current element\n     * @link https://php.net/manual/en/iterator.current.php\n     *\n     * @return string|null\n     */\n    public function current(): ?string\n    {\n        return $this->letters ? $this->letters[$this->offset] : null;\n    }\n\n    /**\n     * Checks if current position is valid\n     * @link https://php.net/manual/en/iterator.valid.php\n     *\n     * @return bool\n     */\n    public function valid(): bool\n    {\n        return (bool)$this->current;\n    }\n\n    /**\n     * @return void\n     */\n    public function rewind(): void\n    {\n        $this->current = $this->start;\n        $this->offset = -1;\n        $this->key = 0;\n        $this->letters = [];\n\n        $this->next();\n    }\n}\n\n"
  },
  {
    "path": "system/src/DOMWordsIterator.php",
    "content": "<?php\n\n/**\n * Iterates individual words of DOM text and CDATA nodes\n * while keeping track of their position in the document.\n *\n * Example:\n *\n *  $doc = new DOMDocument();\n *  $doc->load('example.xml');\n *  foreach(new DOMWordsIterator($doc) as $word) echo $word;\n *\n * @author pjgalbraith http://www.pjgalbraith.com\n * @author porneL http://pornel.net (based on DOMLettersIterator available at http://pornel.net/source/domlettersiterator.php)\n * @license Public Domain\n * @url https://github.com/antoligy/dom-string-iterators\n *\n * @implements Iterator<int,string>\n */\n\nfinal class DOMWordsIterator implements Iterator\n{\n    /** @var DOMElement */\n    private $start;\n    /** @var DOMElement|null */\n    private $current;\n    /** @var int */\n    private $offset = -1;\n    /** @var int|null */\n    private $key;\n    /** @var array<int,array<int,int|string>>|null */\n    private $words;\n\n    /**\n     * expects DOMElement or DOMDocument (see DOMDocument::load and DOMDocument::loadHTML)\n     *\n     * @param DOMNode $el\n     */\n    public function __construct(DOMNode $el)\n    {\n        if ($el instanceof DOMDocument) {\n            $el = $el->documentElement;\n        }\n\n        if (!$el instanceof DOMElement) {\n            throw new InvalidArgumentException('Invalid arguments, expected DOMElement or DOMDocument');\n        }\n\n        $this->start = $el;\n    }\n\n    /**\n     * Returns position in text as DOMText node and character offset.\n     * (it's NOT a byte offset, you must use mb_substr() or similar to use this offset properly).\n     * node may be NULL if iterator has finished.\n     *\n     * @return array\n     */\n    public function currentWordPosition(): array\n    {\n        return [$this->current, $this->offset, $this->words];\n    }\n\n    /**\n     * Returns DOMElement that is currently being iterated or NULL if iterator has finished.\n     *\n     * @return DOMElement|null\n     */\n    public function currentElement(): ?DOMElement\n    {\n        return $this->current ? $this->current->parentNode : null;\n    }\n\n    // Implementation of Iterator interface\n\n    /**\n     * Return the key of the current element\n     * @link https://php.net/manual/en/iterator.key.php\n     * @return int|null\n     */\n    public function key(): ?int\n    {\n        return $this->key;\n    }\n\n    /**\n     * @return void\n     */\n    public function next(): void\n    {\n        if (null === $this->current) {\n            return;\n        }\n\n        if ($this->current->nodeType === XML_TEXT_NODE || $this->current->nodeType === XML_CDATA_SECTION_NODE) {\n            if ($this->offset === -1) {\n                $this->words = preg_split(\"/[\\n\\r\\t ]+/\", $this->current->textContent, -1, PREG_SPLIT_NO_EMPTY|PREG_SPLIT_OFFSET_CAPTURE) ?: [];\n            }\n            $this->offset++;\n\n            if ($this->words && $this->offset < count($this->words)) {\n                $this->key++;\n                return;\n            }\n            $this->offset = -1;\n        }\n\n        while ($this->current->nodeType === XML_ELEMENT_NODE && $this->current->firstChild) {\n            $this->current = $this->current->firstChild;\n            if ($this->current->nodeType === XML_TEXT_NODE || $this->current->nodeType === XML_CDATA_SECTION_NODE) {\n                $this->next();\n                return;\n            }\n        }\n\n        while (!$this->current->nextSibling && $this->current->parentNode) {\n            $this->current = $this->current->parentNode;\n            if ($this->current === $this->start) {\n                $this->current = null;\n                return;\n            }\n        }\n\n        $this->current = $this->current->nextSibling;\n\n        $this->next();\n    }\n\n    /**\n     * Return the current element\n     * @link https://php.net/manual/en/iterator.current.php\n     * @return string|null\n     */\n    public function current(): ?string\n    {\n        return $this->words ? (string)$this->words[$this->offset][0] : null;\n    }\n\n    /**\n     * Checks if current position is valid\n     * @link https://php.net/manual/en/iterator.valid.php\n     * @return bool\n     */\n    public function valid(): bool\n    {\n        return (bool)$this->current;\n    }\n\n    public function rewind(): void\n    {\n        $this->current = $this->start;\n        $this->offset = -1;\n        $this->key = 0;\n        $this->words = [];\n\n        $this->next();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/BaseAsset.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Assets\\Traits\\AssetUtilsTrait;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Object\\PropertyObject;\nuse RocketTheme\\Toolbox\\File\\File;\nuse SplFileInfo;\n\n/**\n * Class BaseAsset\n * @package Grav\\Common\\Assets\n */\nabstract class BaseAsset extends PropertyObject\n{\n    use AssetUtilsTrait;\n\n    protected const CSS_ASSET = 1;\n    protected const JS_ASSET = 2;\n    protected const JS_MODULE_ASSET = 3;\n\n    /** @var string|false */\n    protected $asset;\n    /** @var string */\n    protected $asset_type;\n    /** @var int */\n    protected $order;\n    /** @var string */\n    protected $group;\n    /** @var string */\n    protected $position;\n    /** @var int */\n    protected $priority;\n    /** @var array */\n    protected $attributes = [];\n\n    /** @var string */\n    protected $timestamp;\n    /** @var int|false */\n    protected $modified;\n    /** @var bool */\n    protected $remote;\n    /** @var string */\n    protected $query = '';\n\n    // Private Bits\n    /** @var bool */\n    private $css_rewrite = false;\n    /** @var bool */\n    private $css_minify = false;\n\n    /**\n     * @return string\n     */\n    abstract function render();\n\n    /**\n     * BaseAsset constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        $base_config = [\n            'group' => 'head',\n            'position' => 'pipeline',\n            'priority' => 10,\n            'modified' => null,\n            'asset' => null\n        ];\n\n        // Merge base defaults\n        $elements = array_merge($base_config, $elements);\n\n        parent::__construct($elements, $key);\n    }\n\n    /**\n     * @param string|false $asset\n     * @param array $options\n     * @return $this|false\n     */\n    public function init($asset, $options)\n    {\n        if (!$asset) {\n            return false;\n        }\n\n        $config = Grav::instance()['config'];\n        $uri = Grav::instance()['uri'];\n\n        // set attributes\n        foreach ($options as $key => $value) {\n            if ($this->hasProperty($key)) {\n                $this->setProperty($key, $value);\n            } else {\n                $this->attributes[$key] = $value;\n            }\n        }\n\n        // Force priority to be an int\n        $this->priority = (int) $this->priority;\n\n        // Do some special stuff for CSS/JS (not inline)\n        if (!Utils::startsWith($this->getType(), 'inline')) {\n            $this->base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/';\n            $this->remote = static::isRemoteLink($asset);\n\n            // Move this to render?\n            if (!$this->remote) {\n                $asset_parts = parse_url($asset);\n                if (isset($asset_parts['query'])) {\n                    $this->query = $asset_parts['query'];\n                    unset($asset_parts['query']);\n                    $asset = Uri::buildUrl($asset_parts);\n                }\n\n                $locator = Grav::instance()['locator'];\n\n                if ($locator->isStream($asset)) {\n                    $path = $locator->findResource($asset, true);\n                } else {\n                    $path = GRAV_WEBROOT . $asset;\n                }\n\n                // If local file is missing return\n                if ($path === false) {\n                    return false;\n                }\n\n                $file = new SplFileInfo($path);\n\n                $asset = $this->buildLocalLink($file->getPathname());\n\n                $this->modified = $file->isFile() ? $file->getMTime() : false;\n            }\n        }\n\n        $this->asset = $asset;\n\n        return $this;\n    }\n\n    /**\n     * @return string|false\n     */\n    public function getAsset()\n    {\n        return $this->asset;\n    }\n\n    /**\n     * @return bool\n     */\n    public function getRemote()\n    {\n        return $this->remote;\n    }\n\n    /**\n     * @param string $position\n     * @return $this\n     */\n    public function setPosition($position)\n    {\n        $this->position = $position;\n\n        return $this;\n    }\n\n    /**\n     * Receive asset location and return the SRI integrity hash\n     *\n     * @param string $input\n     * @return string\n     */\n    public static function integrityHash($input)\n    {\n        $grav = Grav::instance();\n        $uri = $grav['uri'];\n\n        $assetsConfig = $grav['config']->get('system.assets');\n\n        if (!self::isRemoteLink($input) && !empty($assetsConfig['enable_asset_sri']) && $assetsConfig['enable_asset_sri']) {\n            $input = preg_replace('#^' . $uri->rootUrl() . '#', '', $input);\n            $asset = File::instance(GRAV_WEBROOT . $input);\n\n            if ($asset->exists()) {\n                $dataToHash = $asset->content();\n                $hash = hash('sha256', $dataToHash, true);\n                $hash_base64 = base64_encode($hash);\n\n                return ' integrity=\"sha256-' . $hash_base64 . '\"';\n            }\n        }\n\n        return '';\n    }\n\n\n    /**\n     *\n     * Get the last modification time of asset\n     *\n     * @param  string $asset    the asset string reference\n     *\n     * @return string           the last modifcation time or false on error\n     */\n//    protected function getLastModificationTime($asset)\n//    {\n//        $file = GRAV_WEBROOT . $asset;\n//        if (Grav::instance()['locator']->isStream($asset)) {\n//            $file = $this->buildLocalLink($asset, true);\n//        }\n//\n//        return file_exists($file) ? filemtime($file) : false;\n//    }\n\n    /**\n     *\n     * Build local links including grav asset shortcodes\n     *\n     * @param  string $asset    the asset string reference\n     *\n     * @return string|false     the final link url to the asset\n     */\n    protected function buildLocalLink($asset)\n    {\n        if ($asset) {\n            return $this->base_url . ltrim(Utils::replaceFirstOccurrence(GRAV_WEBROOT, '', $asset), '/');\n        }\n        return false;\n    }\n\n\n    /**\n     * Implements JsonSerializable interface.\n     *\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return ['type' => $this->getType(), 'elements' => $this->getElements()];\n    }\n\n    /**\n     * Placeholder for AssetUtilsTrait method\n     *\n     * @param string $file\n     * @param string $dir\n     * @param bool $local\n     * @return string\n     */\n    protected function cssRewrite($file, $dir, $local)\n    {\n        return '';\n    }\n\n    /**\n     * Finds relative JS urls() and rewrites the URL with an absolute one\n     *\n     * @param string $file the css source file\n     * @param string $dir local relative path to the css file\n     * @param bool $local is this a local or remote asset\n     * @return string\n     */\n    protected function jsRewrite($file, $dir, $local)\n    {\n        return '';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/BlockAssets.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Assets;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Framework\\ContentBlock\\HtmlBlock;\nuse function strlen;\n\n/**\n * Register block assets into Grav.\n */\nclass BlockAssets\n{\n    /**\n     * @param HtmlBlock $block\n     * @return void\n     */\n    public static function registerAssets(HtmlBlock $block): void\n    {\n        $grav = Grav::instance();\n\n        /** @var Assets $assets */\n        $assets = $grav['assets'];\n\n        $types = $block->getAssets();\n        foreach ($types as $type => $groups) {\n            switch ($type) {\n                case 'frameworks':\n                    static::registerFrameworks($assets, $groups);\n                    break;\n                case 'styles':\n                    static::registerStyles($assets, $groups);\n                    break;\n                case 'scripts':\n                    static::registerScripts($assets, $groups);\n                    break;\n                case 'links':\n                    static::registerLinks($assets, $groups);\n                    break;\n                case 'html':\n                    static::registerHtml($assets, $groups);\n                    break;\n            }\n        }\n    }\n\n    /**\n     * @param Assets $assets\n     * @param array $list\n     * @return void\n     */\n    protected static function registerFrameworks(Assets $assets, array $list): void\n    {\n        if ($list) {\n            throw new \\RuntimeException('Not Implemented');\n        }\n    }\n\n    /**\n     * @param Assets $assets\n     * @param array $groups\n     * @return void\n     */\n    protected static function registerStyles(Assets $assets, array $groups): void\n    {\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        foreach ($groups as $group => $styles) {\n            foreach ($styles as $style) {\n                switch ($style[':type']) {\n                    case 'file':\n                        $options = [\n                            'priority' => $style[':priority'],\n                            'group' => $group,\n                            'type' => $style['type'],\n                            'media' => $style['media']\n                        ] + $style['element'];\n\n                        $assets->addCss(static::getRelativeUrl($style['href'], $config->get('system.assets.css_pipeline')), $options);\n                        break;\n                    case 'inline':\n                        $options = [\n                            'priority' => $style[':priority'],\n                            'group' => $group,\n                            'type' => $style['type'],\n                        ] + $style['element'];\n\n                        $assets->addInlineCss($style['content'], $options);\n                        break;\n                }\n            }\n        }\n    }\n\n    /**\n     * @param Assets $assets\n     * @param array $groups\n     * @return void\n     */\n    protected static function registerScripts(Assets $assets, array $groups): void\n    {\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        foreach ($groups as $group => $scripts) {\n            $group = $group === 'footer' ? 'bottom' : $group;\n\n            foreach ($scripts as $script) {\n                switch ($script[':type']) {\n                    case 'file':\n                        $options = [\n                            'group' => $group,\n                            'priority' => $script[':priority'],\n                            'src' => $script['src'],\n                            'type' => $script['type'],\n                            'loading' => $script['loading'],\n                            'defer' => $script['defer'],\n                            'async' => $script['async'],\n                            'handle' => $script['handle']\n                        ] + $script['element'];\n\n                        $assets->addJs(static::getRelativeUrl($script['src'], $config->get('system.assets.js_pipeline')), $options);\n                        break;\n                    case 'inline':\n                        $options = [\n                            'priority' => $script[':priority'],\n                            'group' => $group,\n                            'type' => $script['type'],\n                            'loading' => $script['loading']\n                        ] + $script['element'];\n\n                        $assets->addInlineJs($script['content'], $options);\n                        break;\n                }\n            }\n        }\n    }\n\n    /**\n     * @param Assets $assets\n     * @param array $groups\n     * @return void\n     */\n    protected static function registerLinks(Assets $assets, array $groups): void\n    {\n        foreach ($groups as $group => $links) {\n            foreach ($links as $link) {\n                $href = $link['href'];\n                $options = [\n                    'group' => $group,\n                    'priority' => $link[':priority'],\n                    'rel' => $link['rel'],\n                ] + $link['element'];\n\n                $assets->addLink($href, $options);\n            }\n        }\n    }\n\n    /**\n     * @param Assets $assets\n     * @param array $groups\n     * @return void\n     */\n    protected static function registerHtml(Assets $assets, array $groups): void\n    {\n        if ($groups) {\n            throw new \\RuntimeException('Not Implemented');\n        }\n    }\n\n    /**\n     * @param string $url\n     * @param bool $pipeline\n     * @return string\n     */\n    protected static function getRelativeUrl($url, $pipeline)\n    {\n        $grav = Grav::instance();\n\n        $base = rtrim($grav['base_url'], '/') ?: '/';\n\n        if (strpos($url, $base) === 0) {\n            if ($pipeline) {\n                // Remove file timestamp if CSS pipeline has been enabled.\n                $url = preg_replace('|[?#].*|', '', $url);\n            }\n\n            return substr($url, strlen($base) - 1);\n        }\n        return $url;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/Css.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Utils;\n\n/**\n * Class Css\n * @package Grav\\Common\\Assets\n */\nclass Css extends BaseAsset\n{\n    /**\n     * Css constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        $base_options = [\n            'asset_type' => 'css',\n            'attributes' => [\n                'type' => 'text/css',\n                'rel' => 'stylesheet'\n            ]\n        ];\n\n        $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);\n\n        parent::__construct($merged_attributes, $key);\n    }\n\n    /**\n     * @return string\n     */\n    public function render()\n    {\n        if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') {\n            $buffer = $this->gatherLinks([$this], self::CSS_ASSET);\n            return \"<style>\\n\" . trim($buffer) . \"\\n</style>\\n\";\n        }\n\n        return '<link href=\"' . trim($this->asset) . $this->renderQueryString() . '\"' . $this->renderAttributes() . $this->integrityHash($this->asset) . \">\\n\";\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/InlineCss.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Utils;\n\n/**\n * Class InlineCss\n * @package Grav\\Common\\Assets\n */\nclass InlineCss extends BaseAsset\n{\n    /**\n     * InlineCss constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        $base_options = [\n            'asset_type' => 'css',\n            'position' => 'after'\n        ];\n\n        $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);\n\n        parent::__construct($merged_attributes, $key);\n    }\n\n    /**\n     * @return string\n     */\n    public function render()\n    {\n        return '<style' . $this->renderAttributes(). \">\\n\" . trim($this->asset) . \"\\n</style>\\n\";\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/InlineJs.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Utils;\n\n/**\n * Class InlineJs\n * @package Grav\\Common\\Assets\n */\nclass InlineJs extends BaseAsset\n{\n    /**\n     * InlineJs constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        $base_options = [\n            'asset_type' => 'js',\n            'position' => 'after'\n        ];\n\n        $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);\n\n        parent::__construct($merged_attributes, $key);\n    }\n\n    /**\n     * @return string\n     */\n    public function render()\n    {\n        return '<script' . $this->renderAttributes(). \">\\n\" . trim($this->asset) . \"\\n</script>\\n\";\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/InlineJsModule.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Utils;\n\n/**\n * Class InlineJs\n * @package Grav\\Common\\Assets\n */\nclass InlineJsModule extends BaseAsset\n{\n    /**\n     * InlineJs constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        $base_options = [\n            'asset_type' => 'js_module',\n            'attributes' => ['type' => 'module'],\n            'position' => 'after'\n        ];\n\n        $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);\n\n        parent::__construct($merged_attributes, $key);\n    }\n\n    /**\n     * @return string\n     */\n    public function render()\n    {\n        return '<script' . $this->renderAttributes(). \">\\n\" . trim($this->asset) . \"\\n</script>\\n\";\n    }\n\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/Js.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Utils;\n\n/**\n * Class Js\n * @package Grav\\Common\\Assets\n */\nclass Js extends BaseAsset\n{\n    /**\n     * Js constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        $base_options = [\n            'asset_type' => 'js',\n        ];\n\n        $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);\n\n        parent::__construct($merged_attributes, $key);\n    }\n\n    /**\n     * @return string\n     */\n    public function render()\n    {\n        if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') {\n            $buffer = $this->gatherLinks([$this], self::JS_ASSET);\n            return '<script' . $this->renderAttributes() . \">\\n\" . trim($buffer) . \"\\n</script>\\n\";\n        }\n\n        return '<script src=\"' . trim($this->asset) . $this->renderQueryString() . '\"' . $this->renderAttributes() . $this->integrityHash($this->asset) . \"></script>\\n\";\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/JsModule.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Utils;\n\n/**\n * Class Js\n * @package Grav\\Common\\Assets\n */\nclass JsModule extends BaseAsset\n{\n    /**\n     * Js constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        $base_options = [\n            'asset_type' => 'js_module',\n            'attributes' => ['type' => 'module']\n        ];\n\n        $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);\n\n        parent::__construct($merged_attributes, $key);\n    }\n\n        /**\n     * @return string\n     */\n    public function render()\n    {\n        if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') {\n            $buffer = $this->gatherLinks([$this], self::JS_MODULE_ASSET);\n            return '<script' . $this->renderAttributes() . \">\\n\" . trim($buffer) . \"\\n</script>\\n\";\n        }\n\n        return '<script src=\"' . trim($this->asset) . $this->renderQueryString() . '\"' . $this->renderAttributes() . $this->integrityHash($this->asset) . \"></script>\\n\";\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/Link.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Utils;\n\n/**\n * Class Link\n * @package Grav\\Common\\Assets\n */\nclass Link extends BaseAsset\n{\n    /**\n     * Css constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        $base_options = [\n            'asset_type' => 'link',\n        ];\n\n        $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements);\n\n        parent::__construct($merged_attributes, $key);\n    }\n\n    /**\n     * @return string\n     */\n    public function render()\n    {\n        return '<link href=\"' . trim($this->asset) . $this->renderQueryString() . '\"' . $this->renderAttributes() . $this->integrityHash($this->asset) . \">\\n\";\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/Pipeline.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets;\n\nuse Grav\\Common\\Assets\\Traits\\AssetUtilsTrait;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Object\\PropertyObject;\nuse MatthiasMullie\\Minify\\CSS;\nuse MatthiasMullie\\Minify\\JS;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function array_key_exists;\n\n/**\n * Class Pipeline\n * @package Grav\\Common\\Assets\n */\nclass Pipeline extends PropertyObject\n{\n    use AssetUtilsTrait;\n\n    protected const CSS_ASSET = 1;\n    protected const JS_ASSET = 2;\n    protected const JS_MODULE_ASSET = 3;\n\n    /** @const Regex to match CSS urls */\n    protected const CSS_URL_REGEX = '{url\\(([\\'\\\"]?)(.*?)\\1\\)|(@import)\\s+([\\'\\\"])(.*?)\\4}';\n\n    /** @const Regex to match JS imports */\n    protected const JS_IMPORT_REGEX = '{import.+from\\s?[\\'|\\\"](.+?)[\\'|\\\"]}';\n\n    /** @const Regex to match CSS sourcemap comments */\n    protected const CSS_SOURCEMAP_REGEX = '{\\/\\*# (.*?) \\*\\/}';\n\n    protected const FIRST_FORWARDSLASH_REGEX = '{^\\/{1}\\w}';\n\n    // Following variables come from the configuration:\n    /** @var bool */\n    protected $css_minify = false;\n    /** @var bool */\n    protected $css_minify_windows = false;\n    /** @var bool */\n    protected $css_rewrite = false;\n    /** @var bool */\n    protected $css_pipeline_include_externals = true;\n    /** @var bool */\n    protected $js_minify = false;\n    /** @var bool */\n    protected $js_minify_windows = false;\n    /** @var bool */\n    protected $js_pipeline_include_externals = true;\n\n    /** @var string */\n    protected $assets_dir;\n    /** @var string */\n    protected $assets_url;\n    /** @var string */\n    protected $timestamp;\n    /** @var array */\n    protected $attributes;\n    /** @var string */\n    protected $query = '';\n    /** @var string */\n    protected $asset;\n\n    /**\n     * Pipeline constructor.\n     * @param array $elements\n     * @param string|null $key\n     */\n    public function __construct(array $elements = [], ?string $key = null)\n    {\n        parent::__construct($elements, $key);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n\n        /** @var Config $config */\n        $config = Grav::instance()['config'];\n\n        /** @var Uri $uri */\n        $uri = Grav::instance()['uri'];\n\n        $this->base_url = rtrim($uri->rootUrl($config->get('system.absolute_urls')), '/') . '/';\n        $this->assets_dir = $locator->findResource('asset://');\n        if (!$this->assets_dir) {\n            // Attempt to create assets folder if it doesn't exist yet.\n            $this->assets_dir = $locator->findResource('asset://', true, true);\n            Folder::mkdir($this->assets_dir);\n            $locator->clearCache();\n        }\n\n        $this->assets_url = $locator->findResource('asset://', false);\n    }\n\n    /**\n     * Minify and concatenate CSS\n     *\n     * @param array $assets\n     * @param string $group\n     * @param array $attributes\n     * @return bool|string     URL or generated content if available, else false\n     */\n    public function renderCss($assets, $group, $attributes = [])\n    {\n        // temporary list of assets to pipeline\n        $inline_group = false;\n\n        if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') {\n            $inline_group = true;\n            unset($attributes['loading']);\n        }\n\n        // Store Attributes\n        $this->attributes = array_merge(['type' => 'text/css', 'rel' => 'stylesheet'], $attributes);\n\n        // Compute uid based on assets and timestamp\n        $json_assets = json_encode($assets);\n        $uid = md5($json_assets . (int)$this->css_minify . (int)$this->css_rewrite . $group);\n        $file = $uid . '.css';\n        $relative_path = \"{$this->base_url}{$this->assets_url}/{$file}\";\n\n        $filepath = \"{$this->assets_dir}/{$file}\";\n        if (file_exists($filepath)) {\n            $buffer = file_get_contents($filepath) . \"\\n\";\n        } else {\n            //if nothing found get out of here!\n            if (empty($assets)) {\n                return false;\n            }\n\n            // Concatenate files\n            $buffer = $this->gatherLinks($assets, self::CSS_ASSET);\n\n            // Minify if required\n            if ($this->shouldMinify('css')) {\n                $minifier = new CSS();\n                $minifier->add($buffer);\n                $buffer = $minifier->minify();\n            }\n\n            // Write file\n            if (trim($buffer) !== '') {\n                file_put_contents($filepath, $buffer);\n            }\n        }\n\n        if ($inline_group) {\n            $output = \"<style>\\n\" . $buffer . \"\\n</style>\\n\";\n        } else {\n            $this->asset = $relative_path;\n            $output = '<link href=\"' . $relative_path . $this->renderQueryString() . '\"' . $this->renderAttributes() . BaseAsset::integrityHash($this->asset) . \">\\n\";\n        }\n\n        return $output;\n    }\n\n    /**\n     * Minify and concatenate JS files.\n     *\n     * @param array $assets\n     * @param string $group\n     * @param array $attributes\n     * @return bool|string     URL or generated content if available, else false\n     */\n    public function renderJs($assets, $group, $attributes = [], $type = self::JS_ASSET)\n    {\n        // temporary list of assets to pipeline\n        $inline_group = false;\n\n        if (array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline') {\n            $inline_group = true;\n            unset($attributes['loading']);\n        }\n\n        // Store Attributes\n        $this->attributes = $attributes;\n\n        // Compute uid based on assets and timestamp\n        $json_assets = json_encode($assets);\n        $uid = md5($json_assets . $this->js_minify . $group);\n        $file = $uid . '.js';\n        $relative_path = \"{$this->base_url}{$this->assets_url}/{$file}\";\n\n        $filepath = \"{$this->assets_dir}/{$file}\";\n        if (file_exists($filepath)) {\n            $buffer = file_get_contents($filepath) . \"\\n\";\n        } else {\n            //if nothing found get out of here!\n            if (empty($assets)) {\n                return false;\n            }\n\n            // Concatenate files\n            $buffer = $this->gatherLinks($assets, $type);\n\n            // Minify if required\n            if ($this->shouldMinify('js')) {\n                $minifier = new JS();\n                $minifier->add($buffer);\n                $buffer = $minifier->minify();\n            }\n\n            // Write file\n            if (trim($buffer) !== '') {\n                file_put_contents($filepath, $buffer);\n            }\n        }\n\n        if ($inline_group) {\n            $output = '<script' . $this->renderAttributes(). \">\\n\" . $buffer . \"\\n</script>\\n\";\n        } else {\n            $this->asset = $relative_path;\n            $output = '<script src=\"' . $relative_path . $this->renderQueryString() . '\"' . $this->renderAttributes() . BaseAsset::integrityHash($this->asset) . \"></script>\\n\";\n        }\n\n        return $output;\n    }\n\n        /**\n     * Minify and concatenate JS files.\n     *\n     * @param array $assets\n     * @param string $group\n     * @param array $attributes\n     * @return bool|string     URL or generated content if available, else false\n     */\n    public function renderJs_Module($assets, $group, $attributes = [])\n    {\n        $attributes['type'] = 'module';\n        return $this->renderJs($assets, $group, $attributes, self::JS_MODULE_ASSET);\n    }\n\n    /**\n     * Finds relative CSS urls() and rewrites the URL with an absolute one\n     *\n     * @param string $file the css source file\n     * @param string $dir , $local relative path to the css file\n     * @param bool $local is this a local or remote asset\n     * @return string\n     */\n    protected function cssRewrite($file, $dir, $local)\n    {\n        // Strip any sourcemap comments\n        $file = preg_replace(self::CSS_SOURCEMAP_REGEX, '', $file);\n\n        // Find any css url() elements, grab the URLs and calculate an absolute path\n        // Then replace the old url with the new one\n        $file = (string)preg_replace_callback(self::CSS_URL_REGEX, function ($matches) use ($dir, $local) {\n            $isImport = count($matches) > 3 && $matches[3] === '@import';\n\n            if ($isImport) {\n                $old_url = $matches[5];\n            } else {\n                $old_url = $matches[2];\n            }\n \n            // Ensure link is not rooted to web server, a data URL, or to a remote host\n            if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || Utils::startsWith($old_url, 'data:') || $this->isRemoteLink($old_url)) {\n                return $matches[0];\n            }\n\n            // clean leading /\n            $old_url = Utils::normalizePath($dir . '/' . $old_url);\n            if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) {\n                $old_url = ltrim($old_url, '/');\n            }\n\n            $new_url = ($local ? $this->base_url : '') . $old_url;\n\n            if ($isImport) {\n                return str_replace($matches[5], $new_url, $matches[0]);\n            } else {\n                return str_replace($matches[2], $new_url, $matches[0]);\n            }\n        }, $file);\n\n        return $file;\n    }\n\n    /**\n     * Finds relative JS urls() and rewrites the URL with an absolute one\n     *\n     * @param string $file the css source file\n     * @param string $dir local relative path to the css file\n     * @param bool $local is this a local or remote asset\n     * @return string\n     */\n    protected function jsRewrite($file, $dir, $local)\n    {\n        // Find any js import elements, grab the URLs and calculate an absolute path\n        // Then replace the old url with the new one\n        $file = (string)preg_replace_callback(self::JS_IMPORT_REGEX, function ($matches) use ($dir, $local) {\n\n            $old_url = $matches[1];\n\n            // Ensure link is not rooted to web server, a data URL, or to a remote host\n            if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || $this->isRemoteLink($old_url)) {\n                return $matches[0];\n            }\n\n            // clean leading /\n            $old_url = Utils::normalizePath($dir . '/' . $old_url);\n            $old_url = str_replace('/./', '/', $old_url);\n            if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) {\n                $old_url = ltrim($old_url, '/');\n            }\n\n            $new_url = ($local ? $this->base_url : '') . $old_url;\n\n            return str_replace($matches[1], $new_url, $matches[0]);\n        }, $file);\n\n        return $file;\n    }\n\n    /**\n     * @param string $type\n     * @return bool\n     */\n    private function shouldMinify($type = 'css')\n    {\n        $check = $type . '_minify';\n        $win_check = $type . '_minify_windows';\n\n        $minify = (bool) $this->$check;\n\n        // If this is a Windows server, and minify_windows is false (default value) skip the\n        // minification process because it will cause Apache to die/crash due to insufficient\n        // ThreadStackSize in httpd.conf - See: https://bugs.php.net/bug.php?id=47689\n        if (stripos(php_uname('s'), 'WIN') === 0 && !$this->{$win_check}) {\n            $minify = false;\n        }\n\n        return $minify;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\\Traits\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets\\Traits;\n\nuse Closure;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse function dirname;\nuse function in_array;\nuse function is_array;\n\n/**\n * Trait AssetUtilsTrait\n * @package Grav\\Common\\Assets\\Traits\n */\ntrait AssetUtilsTrait\n{\n    /**\n     * @var Closure|null\n     *\n     * Closure used by the pipeline to fetch assets.\n     *\n     * Useful when file_get_contents() function is not available in your PHP\n     * installation or when you want to apply any kind of preprocessing to\n     * your assets before they get pipelined.\n     *\n     * The closure will receive as the only parameter a string with the path/URL of the asset and\n     * it should return the content of the asset file as a string.\n     */\n    protected $fetch_command;\n\n    /** @var string */\n    protected $base_url;\n\n    /**\n     * Determine whether a link is local or remote.\n     * Understands both \"http://\" and \"https://\" as well as protocol agnostic links \"//\"\n     *\n     * @param  string $link\n     * @return bool\n     */\n    public static function isRemoteLink($link)\n    {\n        $base = Grav::instance()['uri']->rootUrl(true);\n\n        // Sanity check for local URLs with absolute URL's enabled\n        if (Utils::startsWith($link, $base)) {\n            return false;\n        }\n\n        return (0 === strpos($link, 'http://') || 0 === strpos($link, 'https://') || 0 === strpos($link, '//'));\n    }\n\n    /**\n     * Download and concatenate the content of several links.\n     *\n     * @param  array $assets\n     * @param  int $type\n     * @return string\n     */\n    protected function gatherLinks(array $assets, int $type = self::CSS_ASSET): string\n    {\n        $buffer = '';\n        foreach ($assets as $asset) {\n            $local = true;\n\n            $link = $asset->getAsset();\n            $relative_path = $link;\n\n            if (static::isRemoteLink($link)) {\n                $local = false;\n                if (0 === strpos($link, '//')) {\n                    $link = 'http:' . $link;\n                }\n                $relative_dir = dirname($relative_path);\n            } else {\n                // Fix to remove relative dir if grav is in one\n                if (($this->base_url !== '/') && Utils::startsWith($relative_path, $this->base_url)) {\n                    $base_url = '#' . preg_quote($this->base_url, '#') . '#';\n                    $relative_path = ltrim(preg_replace($base_url, '/', $link, 1), '/');\n                }\n\n                $relative_dir = dirname($relative_path);\n                $link = GRAV_ROOT . '/' . $relative_path;\n            }\n\n            // TODO: looks like this is not being used.\n            $file = $this->fetch_command instanceof Closure ? @$this->fetch_command->__invoke($link) : @file_get_contents($link);\n\n            // No file found, skip it...\n            if ($file === false) {\n                continue;\n            }\n\n            // Double check last character being\n            if ($type === self::JS_ASSET || $type === self::JS_MODULE_ASSET) {\n                $file = rtrim($file, ' ;') . ';';\n            }\n\n            // If this is CSS + the file is local + rewrite enabled\n            if ($type === self::CSS_ASSET && $this->css_rewrite) {\n                $file = $this->cssRewrite($file, $relative_dir, $local);\n            }\n\n            if ($type === self::JS_MODULE_ASSET) {\n                $file = $this->jsRewrite($file, $relative_dir, $local);\n            }\n\n            $file = rtrim($file) . PHP_EOL;\n            $buffer .= $file;\n        }\n\n        // Pull out @imports and move to top\n        if ($type === self::CSS_ASSET) {\n            $buffer = $this->moveImports($buffer);\n        }\n\n        return $buffer;\n    }\n\n    /**\n     * Moves @import statements to the top of the file per the CSS specification\n     *\n     * @param  string $file the file containing the combined CSS files\n     * @return string       the modified file with any @imports at the top of the file\n     */\n    protected function moveImports($file)\n    {\n        $regex = '{@import.*?[\"\\']([^\"\\']+)[\"\\'].*?;}';\n\n        $imports = [];\n\n        $file = (string)preg_replace_callback($regex, static function ($matches) use (&$imports) {\n            $imports[] = $matches[0];\n\n            return '';\n        }, $file);\n\n        return implode(\"\\n\", $imports) . \"\\n\\n\" . $file;\n    }\n\n    /**\n     *\n     * Build an HTML attribute string from an array.\n     *\n     * @return string\n     */\n    protected function renderAttributes()\n    {\n        $html = '';\n        $no_key = ['loading'];\n\n        foreach ($this->attributes as $key => $value) {\n            if ($value === null) {\n                continue;\n            }\n\n            if (is_numeric($key)) {\n                $key = $value;\n            }\n            if (is_array($value)) {\n                $value = implode(' ', $value);\n            }\n\n            if (in_array($key, $no_key, true)) {\n                $element = htmlentities($value, ENT_QUOTES, 'UTF-8', false);\n            } else {\n                $element = $key . '=\"' . htmlentities($value, ENT_QUOTES, 'UTF-8', false) . '\"';\n            }\n\n            $html .= ' ' . $element;\n        }\n\n        return $html;\n    }\n\n    /**\n     * Render Querystring\n     *\n     * @param string|null $asset\n     * @return string\n     */\n    protected function renderQueryString($asset = null)\n    {\n        $querystring = '';\n\n        $asset = $asset ?? $this->asset;\n        $attributes = $this->attributes;\n\n        if (!empty($this->query)) {\n            if (Utils::contains($asset, '?')) {\n                $querystring .=  '&' . $this->query;\n            } else {\n                $querystring .= '?' . $this->query;\n            }\n        }\n\n        if ($this->timestamp) {\n            if ($querystring || Utils::contains($asset, '?')) {\n                $querystring .=  '&' . $this->timestamp;\n            } else {\n                $querystring .= '?' . $this->timestamp;\n            }\n        }\n\n        return $querystring;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\\Traits\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets\\Traits;\n\nuse Grav\\Common\\Assets;\nuse function count;\nuse function is_array;\nuse function is_int;\n\n/**\n * Trait LegacyAssetsTrait\n * @package Grav\\Common\\Assets\\Traits\n */\ntrait LegacyAssetsTrait\n{\n    /**\n     * @param array $args\n     * @param string $type\n     * @return array\n     */\n    protected function unifyLegacyArguments($args, $type = Assets::CSS_TYPE)\n    {\n        // First argument is always the asset\n        array_shift($args);\n\n        if (count($args) === 0) {\n            return [];\n        }\n        // New options array format\n        if (count($args) === 1 && is_array($args[0])) {\n            return $args[0];\n        }\n        // Handle obscure case where options array is mixed with a priority\n        if (count($args) === 2 && is_array($args[0]) && is_int($args[1])) {\n            $arguments = $args[0];\n            $arguments['priority'] = $args[1];\n            return $arguments;\n        }\n\n        switch ($type) {\n            case (Assets::JS_TYPE):\n                $defaults = ['priority' => null, 'pipeline' => true, 'loading' => null, 'group' => null];\n                $arguments = $this->createArgumentsFromLegacy($args, $defaults);\n                break;\n\n            case (Assets::INLINE_JS_TYPE):\n                $defaults = ['priority' => null, 'group' => null, 'attributes' => null];\n                $arguments = $this->createArgumentsFromLegacy($args, $defaults);\n\n                // special case to handle old attributes being passed in\n                if (isset($arguments['attributes'])) {\n                    $old_attributes = $arguments['attributes'];\n                    if (is_array($old_attributes)) {\n                        $arguments = array_merge($arguments, $old_attributes);\n                    } else {\n                        $arguments['type'] = $old_attributes;\n                    }\n                }\n                unset($arguments['attributes']);\n\n                break;\n\n            case (Assets::INLINE_CSS_TYPE):\n                $defaults = ['priority' => null, 'group' => null];\n                $arguments = $this->createArgumentsFromLegacy($args, $defaults);\n                break;\n\n            default:\n            case (Assets::CSS_TYPE):\n                $defaults = ['priority' => null, 'pipeline' => true, 'group' => null, 'loading' => null];\n                $arguments = $this->createArgumentsFromLegacy($args, $defaults);\n        }\n\n        return $arguments;\n    }\n\n    /**\n     * @param array $args\n     * @param array $defaults\n     * @return array\n     */\n    protected function createArgumentsFromLegacy(array $args, array $defaults)\n    {\n        // Remove arguments with old default values.\n        $arguments = [];\n        foreach ($args as $arg) {\n            $default = current($defaults);\n            if ($arg !== $default) {\n                $arguments[key($defaults)] = $arg;\n            }\n            next($defaults);\n        }\n\n        return $arguments;\n    }\n\n    /**\n     * Convenience wrapper for async loading of JavaScript\n     *\n     * @param string|array  $asset\n     * @param int           $priority\n     * @param bool          $pipeline\n     * @param string        $group name of the group\n     * @return Assets\n     * @deprecated Please use dynamic method with ['loading' => 'async'].\n     */\n    public function addAsyncJs($asset, $priority = 10, $pipeline = true, $group = 'head')\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use dynamic method with [\\'loading\\' => \\'async\\']', E_USER_DEPRECATED);\n\n        return $this->addJs($asset, $priority, $pipeline, 'async', $group);\n    }\n\n    /**\n     * Convenience wrapper for deferred loading of JavaScript\n     *\n     * @param string|array  $asset\n     * @param int           $priority\n     * @param bool          $pipeline\n     * @param string        $group name of the group\n     * @return Assets\n     * @deprecated Please use dynamic method with ['loading' => 'defer'].\n     */\n    public function addDeferJs($asset, $priority = 10, $pipeline = true, $group = 'head')\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use dynamic method with [\\'loading\\' => \\'defer\\']', E_USER_DEPRECATED);\n\n        return $this->addJs($asset, $priority, $pipeline, 'defer', $group);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Assets\\Traits\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Assets\\Traits;\n\nuse FilesystemIterator;\nuse Grav\\Common\\Grav;\nuse RecursiveDirectoryIterator;\nuse RecursiveIteratorIterator;\nuse RegexIterator;\nuse function strlen;\n\n/**\n * Trait TestingAssetsTrait\n * @package Grav\\Common\\Assets\\Traits\n */\ntrait TestingAssetsTrait\n{\n    /**\n     * Determines if an asset exists as a collection, CSS or JS reference\n     *\n     * @param string $asset\n     * @return bool\n     */\n    public function exists($asset)\n    {\n        return isset($this->collections[$asset]) || isset($this->assets_css[$asset]) || isset($this->assets_js[$asset]);\n    }\n\n    /**\n     * Return the array of all the registered collections\n     *\n     * @return array\n     */\n    public function getCollections()\n    {\n        return $this->collections;\n    }\n\n    /**\n     * Set the array of collections explicitly\n     *\n     * @param array $collections\n     * @return $this\n     */\n    public function setCollection($collections)\n    {\n        $this->collections = $collections;\n\n        return $this;\n    }\n\n    /**\n     * Return the array of all the registered CSS assets\n     * If a $key is provided, it will try to return only that asset\n     * else it will return null\n     *\n     * @param string|null $key the asset key\n     * @return array\n     */\n    public function getCss($key = null)\n    {\n        if (null !== $key) {\n            $asset_key = md5($key);\n\n            return $this->assets_css[$asset_key] ?? null;\n        }\n\n        return $this->assets_css;\n    }\n\n    /**\n     * Return the array of all the registered JS assets\n     * If a $key is provided, it will try to return only that asset\n     * else it will return null\n     *\n     * @param string|null $key the asset key\n     * @return array\n     */\n    public function getJs($key = null)\n    {\n        if (null !== $key) {\n            $asset_key = md5($key);\n\n            return $this->assets_js[$asset_key] ?? null;\n        }\n\n        return $this->assets_js;\n    }\n\n    /**\n     * Set the whole array of CSS assets\n     *\n     * @param array $css\n     * @return $this\n     */\n    public function setCss($css)\n    {\n        $this->assets_css = $css;\n\n        return $this;\n    }\n\n    /**\n     * Set the whole array of JS assets\n     *\n     * @param array $js\n     * @return $this\n     */\n    public function setJs($js)\n    {\n        $this->assets_js = $js;\n\n        return $this;\n    }\n\n    /**\n     * Removes an item from the CSS array if set\n     *\n     * @param string $key  The asset key\n     * @return $this\n     */\n    public function removeCss($key)\n    {\n        $asset_key = md5($key);\n        if (isset($this->assets_css[$asset_key])) {\n            unset($this->assets_css[$asset_key]);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Removes an item from the JS array if set\n     *\n     * @param string $key  The asset key\n     * @return $this\n     */\n    public function removeJs($key)\n    {\n        $asset_key = md5($key);\n        if (isset($this->assets_js[$asset_key])) {\n            unset($this->assets_js[$asset_key]);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Sets the state of CSS Pipeline\n     *\n     * @param bool $value\n     * @return $this\n     */\n    public function setCssPipeline($value)\n    {\n        $this->css_pipeline = (bool)$value;\n\n        return $this;\n    }\n\n    /**\n     * Sets the state of JS Pipeline\n     *\n     * @param bool $value\n     * @return $this\n     */\n    public function setJsPipeline($value)\n    {\n        $this->js_pipeline = (bool)$value;\n\n        return $this;\n    }\n\n    /**\n     * Reset all assets.\n     *\n     * @return $this\n     */\n    public function reset()\n    {\n        $this->resetCss();\n        $this->resetJs();\n        $this->setCssPipeline(false);\n        $this->setJsPipeline(false);\n        $this->order = [];\n\n        return $this;\n    }\n\n    /**\n     * Reset JavaScript assets.\n     *\n     * @return $this\n     */\n    public function resetJs()\n    {\n        $this->assets_js = [];\n\n        return $this;\n    }\n\n    /**\n     * Reset CSS assets.\n     *\n     * @return $this\n     */\n    public function resetCss()\n    {\n        $this->assets_css = [];\n\n        return $this;\n    }\n\n    /**\n     * Explicitly set's a timestamp for assets\n     *\n     * @param string|int $value\n     */\n    public function setTimestamp($value)\n    {\n        $this->timestamp = $value;\n    }\n\n    /**\n     * Get the timestamp for assets\n     *\n     * @param  bool  $include_join\n     * @return string|null\n     */\n    public function getTimestamp($include_join = true)\n    {\n        if ($this->timestamp) {\n            return $include_join ? '?' . $this->timestamp : $this->timestamp;\n        }\n\n        return null;\n    }\n\n    /**\n     * Add all assets matching $pattern within $directory.\n     *\n     * @param  string $directory Relative to the Grav root path, or a stream identifier\n     * @param  string $pattern   (regex)\n     * @return $this\n     */\n    public function addDir($directory, $pattern = self::DEFAULT_REGEX)\n    {\n        $root_dir = GRAV_ROOT;\n\n        // Check if $directory is a stream.\n        if (strpos($directory, '://')) {\n            $directory = Grav::instance()['locator']->findResource($directory, null);\n        }\n\n        // Get files\n        $files = $this->rglob($root_dir . DIRECTORY_SEPARATOR . $directory, $pattern, $root_dir . '/');\n\n        // No luck? Nothing to do\n        if (!$files) {\n            return $this;\n        }\n\n        // Add CSS files\n        if ($pattern === self::CSS_REGEX) {\n            foreach ($files as $file) {\n                $this->addCss($file);\n            }\n\n            return $this;\n        }\n\n        // Add JavaScript files\n        if ($pattern === self::JS_REGEX) {\n            foreach ($files as $file) {\n                $this->addJs($file);\n            }\n\n            return $this;\n        }\n\n        // Add JavaScript Module files\n        if ($pattern === self::JS_MODULE_REGEX) {\n            foreach ($files as $file) {\n                $this->addJsModule($file);\n            }\n\n            return $this;\n        }\n\n        // Unknown pattern.\n        foreach ($files as $asset) {\n            $this->add($asset);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Add all JavaScript assets within $directory\n     *\n     * @param  string $directory Relative to the Grav root path, or a stream identifier\n     * @return $this\n     */\n    public function addDirJs($directory)\n    {\n        return $this->addDir($directory, self::JS_REGEX);\n    }\n\n    /**\n     * Add all CSS assets within $directory\n     *\n     * @param  string $directory Relative to the Grav root path, or a stream identifier\n     * @return $this\n     */\n    public function addDirCss($directory)\n    {\n        return $this->addDir($directory, self::CSS_REGEX);\n    }\n\n    /**\n     * Recursively get files matching $pattern within $directory.\n     *\n     * @param  string $directory\n     * @param  string $pattern (regex)\n     * @param  string|null $ltrim   Will be trimmed from the left of the file path\n     * @return array\n     */\n    protected function rglob($directory, $pattern, $ltrim = null)\n    {\n        $iterator = new RegexIterator(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(\n            $directory,\n            FilesystemIterator::SKIP_DOTS\n        )), $pattern);\n        $offset = strlen($ltrim);\n        $files = [];\n\n        foreach ($iterator as $file) {\n            $files[] = substr($file->getPathname(), $offset);\n        }\n\n        return $files;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Assets.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Closure;\nuse Grav\\Common\\Assets\\Pipeline;\nuse Grav\\Common\\Assets\\Traits\\LegacyAssetsTrait;\nuse Grav\\Common\\Assets\\Traits\\TestingAssetsTrait;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Framework\\Object\\PropertyObject;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function array_slice;\nuse function call_user_func_array;\nuse function func_get_args;\nuse function is_array;\n\n/**\n * Class Assets\n * @package Grav\\Common\n */\nclass Assets extends PropertyObject\n{\n    use TestingAssetsTrait;\n    use LegacyAssetsTrait;\n\n    const LINK = 'link';\n    const CSS = 'css';\n    const JS = 'js';\n    const JS_MODULE = 'js_module';\n    const LINK_COLLECTION = 'assets_link';\n    const CSS_COLLECTION = 'assets_css';\n    const JS_COLLECTION = 'assets_js';\n    const JS_MODULE_COLLECTION = 'assets_js_module';\n    const LINK_TYPE = Assets\\Link::class;\n    const CSS_TYPE = Assets\\Css::class;\n    const JS_TYPE = Assets\\Js::class;\n    const JS_MODULE_TYPE = Assets\\JsModule::class;\n    const INLINE_CSS_TYPE = Assets\\InlineCss::class;\n    const INLINE_JS_TYPE = Assets\\InlineJs::class;\n    const INLINE_JS_MODULE_TYPE = Assets\\InlineJsModule::class;\n\n    /** @const Regex to match CSS and JavaScript files */\n    const DEFAULT_REGEX = '/.\\.(css|js)$/i';\n\n    /** @const Regex to match CSS files */\n    const CSS_REGEX = '/.\\.css$/i';\n\n    /** @const Regex to match JavaScript files */\n    const JS_REGEX = '/.\\.js$/i';\n\n    /** @const Regex to match JavaScriptModyle files */\n    const JS_MODULE_REGEX = '/.\\.mjs$/i';\n\n    /** @var string */\n    protected $assets_dir;\n    /** @var string */\n    protected $assets_url;\n\n    /** @var array */\n    protected $assets_link = [];\n    /** @var array */\n    protected $assets_css = [];\n    /** @var array */\n    protected $assets_js = [];\n    /** @var array  */\n    protected $assets_js_module = [];\n\n\n\n    // Following variables come from the configuration:\n    /** @var bool */\n    protected $css_pipeline;\n    /** @var bool */\n    protected $css_pipeline_include_externals;\n    /** @var bool */\n    protected $css_pipeline_before_excludes;\n    /** @var bool */\n    protected $js_pipeline;\n    /** @var bool */\n    protected $js_pipeline_include_externals;\n    /** @var bool */\n    protected $js_pipeline_before_excludes;\n    /** @var bool */\n    protected $js_module_pipeline;\n    /** @var bool */\n    protected $js_module_pipeline_include_externals;\n    /** @var bool */\n    protected $js_module_pipeline_before_excludes;\n    /** @var array */\n    protected $pipeline_options = [];\n\n    /** @var Closure|string */\n    protected $fetch_command;\n    /** @var string */\n    protected $autoload;\n    /** @var bool */\n    protected $enable_asset_timestamp;\n    /** @var array|null */\n    protected $collections;\n    /** @var string */\n    protected $timestamp;\n    /** @var array Keeping track for order counts (for sorting) */\n    protected $order = [];\n\n    /**\n     * Initialization called in the Grav lifecycle to initialize the Assets with appropriate configuration\n     *\n     * @return void\n     */\n    public function init()\n    {\n        $grav = Grav::instance();\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        $asset_config = (array)$config->get('system.assets');\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n        $this->assets_dir = $locator->findResource('asset://');\n        $this->assets_url = $locator->findResource('asset://', false);\n\n        $this->config($asset_config);\n\n        // Register any preconfigured collections\n        foreach ((array) $this->collections as $name => $collection) {\n            $this->registerCollection($name, (array)$collection);\n        }\n    }\n\n    /**\n     * Set up configuration options.\n     *\n     * All the class properties except 'js' and 'css' are accepted here.\n     * Also, an extra option 'autoload' may be passed containing an array of\n     * assets and/or collections that will be automatically added on startup.\n     *\n     * @param  array $config Configurable options.\n     * @return $this\n     */\n    public function config(array $config)\n    {\n        foreach ($config as $key => $value) {\n            if ($this->hasProperty($key)) {\n                $this->setProperty($key, $value);\n            } elseif (Utils::startsWith($key, 'css_') || Utils::startsWith($key, 'js_')) {\n                $this->pipeline_options[$key] = $value;\n            }\n        }\n\n        // Add timestamp if it's enabled\n        if ($this->enable_asset_timestamp) {\n            $this->timestamp = Grav::instance()['cache']->getKey();\n        }\n\n        return $this;\n    }\n\n    /**\n     * Add an asset or a collection of assets.\n     *\n     * It automatically detects the asset type (JavaScript, CSS or collection).\n     * You may add more than one asset passing an array as argument.\n     *\n     * @param string|string[] $asset\n     * @return $this\n     */\n    public function add($asset)\n    {\n        if (!$asset) {\n            return $this;\n        }\n\n        $args = func_get_args();\n\n        // More than one asset\n        if (is_array($asset)) {\n            foreach ($asset as $index => $location) {\n                $params = array_slice($args, 1);\n                if (is_array($location)) {\n                    $params = array_shift($params);\n                    if (is_numeric($params)) {\n                        $params = [ 'priority' => $params ];\n                    }\n                    $params = [array_replace_recursive([], $location, $params)];\n                    $location = $index;\n                }\n\n                $params = array_merge([$location], $params);\n                call_user_func_array([$this, 'add'], $params);\n            }\n        } elseif (isset($this->collections[$asset])) {\n            array_shift($args);\n            $args = array_merge([$this->collections[$asset]], $args);\n            call_user_func_array([$this, 'add'], $args);\n        } else {\n            // Get extension\n            $path = parse_url($asset, PHP_URL_PATH);\n            $extension = $path ? Utils::pathinfo($path, PATHINFO_EXTENSION) : '';\n\n            // JavaScript or CSS\n            if ($extension !== '') {\n                $extension = strtolower($extension);\n                if ($extension === 'css') {\n                    call_user_func_array([$this, 'addCss'], $args);\n                } elseif ($extension === 'js') {\n                    call_user_func_array([$this, 'addJs'], $args);\n                } elseif ($extension === 'mjs') {\n                    call_user_func_array([$this, 'addJsModule'], $args);\n                }\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $collection\n     * @param string $type\n     * @param string|string[] $asset\n     * @param array $options\n     * @return $this\n     */\n    protected function addType($collection, $type, $asset, $options)\n    {\n        if (is_array($asset)) {\n            foreach ($asset as $index => $location) {\n                $assetOptions = $options;\n                if (is_array($location)) {\n                    $assetOptions = array_replace_recursive([], $options, $location);\n                    $location = $index;\n                }\n                $this->addType($collection, $type, $location, $assetOptions);\n            }\n\n            return $this;\n        }\n\n        if ($this->isValidType($type) && isset($this->collections[$asset])) {\n            $this->addType($collection, $type, $this->collections[$asset], $options);\n            return $this;\n        }\n\n        // If pipeline disabled, set to position if provided, else after\n        if (isset($options['pipeline'])) {\n            if ($options['pipeline'] === false) {\n\n                $exclude_type = $this->getBaseType($type);\n\n                $excludes = strtolower($exclude_type . '_pipeline_before_excludes');\n                if ($this->{$excludes}) {\n                    $default = 'after';\n                } else {\n                    $default = 'before';\n                }\n\n                $options['position'] = $options['position'] ?? $default;\n            }\n\n            unset($options['pipeline']);\n        }\n\n        // Add timestamp\n        $timestamp_override = $options['timestamp'] ?? true;\n\n        if (filter_var($timestamp_override, FILTER_VALIDATE_BOOLEAN)) {\n            $options['timestamp'] = $this->timestamp;\n        } else {\n            $options['timestamp'] = null;\n        }\n\n        // Set order\n        $group = $options['group'] ?? 'head';\n        $position = $options['position'] ?? 'pipeline';\n\n        $orderKey = \"{$type}|{$group}|{$position}\";\n        if (!isset($this->order[$orderKey])) {\n           $this->order[$orderKey] = 0;\n        }\n\n        $options['order'] = $this->order[$orderKey]++;\n\n        // Create asset of correct type\n        $asset_object = new $type();\n\n        // If exists\n        if ($asset_object->init($asset, $options)) {\n            $this->$collection[md5($asset)] = $asset_object;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Add a CSS asset or a collection of assets.\n     *\n     * @return $this\n     */\n    public function addLink($asset)\n    {\n        return $this->addType($this::LINK_COLLECTION, $this::LINK_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::LINK_TYPE));\n    }\n\n    /**\n     * Add a CSS asset or a collection of assets.\n     *\n     * @return $this\n     */\n    public function addCss($asset)\n    {\n        return $this->addType($this::CSS_COLLECTION, $this::CSS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::CSS_TYPE));\n    }\n\n    /**\n     * Add an Inline CSS asset or a collection of assets.\n     *\n     * @return $this\n     */\n    public function addInlineCss($asset)\n    {\n        return $this->addType($this::CSS_COLLECTION, $this::INLINE_CSS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_CSS_TYPE));\n    }\n\n    /**\n     * Add a JS asset or a collection of assets.\n     *\n     * @return $this\n     */\n    public function addJs($asset)\n    {\n        return $this->addType($this::JS_COLLECTION, $this::JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::JS_TYPE));\n    }\n\n    /**\n     * Add an Inline JS asset or a collection of assets.\n     *\n     * @return $this\n     */\n    public function addInlineJs($asset)\n    {\n        return $this->addType($this::JS_COLLECTION, $this::INLINE_JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_JS_TYPE));\n    }\n\n        /**\n     * Add a JS asset or a collection of assets.\n     *\n     * @return $this\n     */\n    public function addJsModule($asset)\n    {\n        return $this->addType($this::JS_MODULE_COLLECTION, $this::JS_MODULE_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::JS_MODULE_TYPE));\n    }\n\n    /**\n     * Add an Inline JS asset or a collection of assets.\n     *\n     * @return $this\n     */\n    public function addInlineJsModule($asset)\n    {\n        return $this->addType($this::JS_MODULE_COLLECTION, $this::INLINE_JS_MODULE_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_JS_MODULE_TYPE));\n    }\n\n    /**\n     * Add/replace collection.\n     *\n     * @param string $collectionName\n     * @param array  $assets\n     * @param bool    $overwrite\n     * @return $this\n     */\n    public function registerCollection($collectionName, array $assets, $overwrite = false)\n    {\n        if ($overwrite || !isset($this->collections[$collectionName])) {\n            $this->collections[$collectionName] = $assets;\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param array $assets\n     * @param string $key\n     * @param string $value\n     * @param bool $sort\n     * @return array|false\n     */\n    protected function filterAssets($assets, $key, $value, $sort = false)\n    {\n        $results = array_filter($assets, function ($asset) use ($key, $value) {\n\n            if ($key === 'position' && $value === 'pipeline') {\n                $type = $asset->getType();\n                if ($type === 'jsmodule') {\n                    $type = 'js_module';\n                }\n\n                if ($asset->getRemote() && $this->{strtolower($type) . '_pipeline_include_externals'} === false && $asset['position'] === 'pipeline') {\n                    if ($this->{strtolower($type) . '_pipeline_before_excludes'}) {\n                        $asset->setPosition('after');\n                    } else {\n                        $asset->setPosition('before');\n                    }\n                    return false;\n                }\n            }\n\n            if ($asset[$key] === $value) {\n                return true;\n            }\n            return false;\n        });\n\n        if ($sort && !empty($results)) {\n            $results = $this->sortAssets($results);\n        }\n\n\n        return $results;\n    }\n\n    /**\n     * @param array $assets\n     * @return array\n     */\n    protected function sortAssets($assets)\n    {\n        uasort($assets, static function ($a, $b) {\n            return $b['priority'] <=> $a['priority'] ?: $a['order'] <=> $b['order'];\n        });\n\n        return $assets;\n    }\n\n    /**\n     * @param string $type\n     * @param string $group\n     * @param array $attributes\n     * @return string\n     */\n    public function render($type, $group = 'head', $attributes = [])\n    {\n        $before_output = '';\n        $pipeline_output = '';\n        $after_output = '';\n\n        $assets = 'assets_' . $type;\n        $pipeline_enabled = $type . '_pipeline';\n        $render_pipeline = 'render' . ucfirst($type);\n\n        $group_assets = $this->filterAssets($this->$assets, 'group', $group);\n        $pipeline_assets = $this->filterAssets($group_assets, 'position', 'pipeline', true);\n        $before_assets = $this->filterAssets($group_assets, 'position', 'before', true);\n        $after_assets = $this->filterAssets($group_assets, 'position', 'after', true);\n\n        // Pipeline\n        if ($this->{$pipeline_enabled} ?? false) {\n            $options = array_merge($this->pipeline_options, ['timestamp' => $this->timestamp]);\n\n            $pipeline = new Pipeline($options);\n            $pipeline_output = $pipeline->$render_pipeline($pipeline_assets, $group, $attributes);\n        } else {\n            foreach ($pipeline_assets as $asset) {\n                $pipeline_output .= $asset->render();\n            }\n        }\n\n        // Before Pipeline\n        foreach ($before_assets as $asset) {\n            $before_output .= $asset->render();\n        }\n\n        // After Pipeline\n        foreach ($after_assets as $asset) {\n            $after_output .= $asset->render();\n        }\n\n        return $before_output . $pipeline_output . $after_output;\n    }\n\n\n    /**\n     * Build the CSS link tags.\n     *\n     * @param  string $group name of the group\n     * @param  array  $attributes\n     * @return string\n     */\n    public function css($group = 'head', $attributes = [], $include_link = true)\n    {\n        $output = '';\n\n        if ($include_link) {\n            $output = $this->link($group, $attributes);\n        }\n\n        $output .= $this->render(self::CSS, $group, $attributes);\n\n        return $output;\n    }\n\n    /**\n     * Build the CSS link tags.\n     *\n     * @param  string $group name of the group\n     * @param  array  $attributes\n     * @return string\n     */\n    public function link($group = 'head', $attributes = [])\n    {\n        return $this->render(self::LINK, $group, $attributes);\n    }\n\n    /**\n     * Build the JavaScript script tags.\n     *\n     * @param  string $group name of the group\n     * @param  array  $attributes\n     * @return string\n     */\n    public function js($group = 'head', $attributes = [], $include_js_module = true)\n    {\n        $output = $this->render(self::JS, $group, $attributes);\n\n        if ($include_js_module) {\n            $output .= $this->jsModule($group, $attributes);\n        }\n\n        return $output;\n    }\n\n    /**\n     * Build the Javascript Modules tags\n     *\n     * @param string $group\n     * @param array $attributes\n     * @return string\n     */\n    public function jsModule($group = 'head', $attributes = [])\n    {\n        return $this->render(self::JS_MODULE, $group, $attributes);\n    }\n\n    /**\n     * @param string $group\n     * @param array $attributes\n     * @return string\n     */\n    public function all($group = 'head', $attributes = [])\n    {\n        $output = $this->css($group, $attributes, false);\n        $output .= $this->link($group, $attributes);\n        $output .= $this->js($group, $attributes, false);\n        $output .= $this->jsModule($group, $attributes);\n        return $output;\n    }\n\n    /**\n     * @param class-string $type\n     * @return bool\n     */\n    protected function isValidType($type)\n    {\n        return in_array($type, [self::CSS_TYPE, self::JS_TYPE, self::JS_MODULE_TYPE]);\n    }\n\n    /**\n     * @param class-string $type\n     * @return string\n     */\n    protected function getBaseType($type)\n    {\n        switch ($type) {\n            case $this::JS_TYPE:\n            case $this::INLINE_JS_TYPE:\n                $base_type = $this::JS;\n                break;\n            case $this::JS_MODULE_TYPE:\n            case $this::INLINE_JS_MODULE_TYPE:\n                $base_type = $this::JS_MODULE;\n                break;\n            default:\n                $base_type = $this::CSS;\n        }\n\n        return $base_type;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Backup/Backups.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Backup\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Backup;\n\nuse DateTime;\nuse Exception;\nuse FilesystemIterator;\nuse GlobIterator;\nuse Grav\\Common\\Filesystem\\Archiver;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Inflector;\nuse Grav\\Common\\Scheduler\\Job;\nuse Grav\\Common\\Scheduler\\Scheduler;\nuse Grav\\Common\\Utils;\nuse Grav\\Common\\Grav;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\File\\JsonFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse SplFileInfo;\nuse stdClass;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\nuse function count;\n\n/**\n * Class Backups\n * @package Grav\\Common\\Backup\n */\nclass Backups\n{\n    protected const BACKUP_FILENAME_REGEXZ = \"#(.*)--(\\d*).zip#\";\n\n    protected const BACKUP_DATE_FORMAT = 'YmdHis';\n\n    /** @var string */\n    protected static $backup_dir;\n\n    /** @var array|null */\n    protected static $backups;\n\n    /**\n     * @return void\n     */\n    public function init()\n    {\n        $grav = Grav::instance();\n\n        /** @var EventDispatcher $dispatcher */\n        $dispatcher = $grav['events'];\n        $dispatcher->addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']);\n\n        $grav->fireEvent('onBackupsInitialized', new Event(['backups' => $this]));\n    }\n\n    /**\n     * @return void\n     */\n    public function setup()\n    {\n        if (null === static::$backup_dir) {\n            $grav = Grav::instance();\n            static::$backup_dir = $grav['locator']->findResource('backup://', true, true);\n            Folder::create(static::$backup_dir);\n        }\n    }\n\n    /**\n     * @param Event $event\n     * @return void\n     */\n    public function onSchedulerInitialized(Event $event)\n    {\n        $grav = Grav::instance();\n\n        /** @var Scheduler $scheduler */\n        $scheduler = $event['scheduler'];\n\n        /** @var Inflector $inflector */\n        $inflector = $grav['inflector'];\n\n        foreach (static::getBackupProfiles() as $id => $profile) {\n            if (!($profile['schedule'] ?? false)) {\n                continue;\n            }\n\n            $at = $profile['schedule_at'];\n            $name = $inflector::hyphenize($profile['name']);\n            $logs = 'logs/backup-' . $name . '.out';\n            /** @var Job $job */\n            $job = $scheduler->addFunction('Grav\\Common\\Backup\\Backups::backup', [$id], $name);\n            $job->at($at);\n            $job->output($logs);\n            $job->backlink('/tools/backups');\n        }\n    }\n\n    /**\n     * @param string $backup\n     * @param string $base_url\n     * @return string\n     */\n    public function getBackupDownloadUrl($backup, $base_url)\n    {\n        $param_sep = Grav::instance()['config']->get('system.param_sep', ':');\n        $download = urlencode(base64_encode(Utils::basename($backup)));\n        $url      = rtrim(Grav::instance()['uri']->rootUrl(true), '/') . '/' . trim(\n            $base_url,\n            '/'\n        ) . '/task' . $param_sep . 'backup/download' . $param_sep . $download . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form');\n\n        return $url;\n    }\n\n    /**\n     * @return array\n     */\n    public static function getBackupProfiles()\n    {\n        return Grav::instance()['config']->get('backups.profiles');\n    }\n\n    /**\n     * @return array\n     */\n    public static function getPurgeConfig()\n    {\n        return Grav::instance()['config']->get('backups.purge');\n    }\n\n    /**\n     * @return array\n     */\n    public function getBackupNames()\n    {\n        return array_column(static::getBackupProfiles(), 'name');\n    }\n\n    /**\n     * @return float|int\n     */\n    public static function getTotalBackupsSize()\n    {\n        $backups = static::getAvailableBackups();\n\n        return $backups ? array_sum(array_column($backups, 'size')) : 0;\n    }\n\n    /**\n     * @param bool $force\n     * @return array\n     */\n    public static function getAvailableBackups($force = false)\n    {\n        if ($force || null === static::$backups) {\n            static::$backups = [];\n\n            $grav = Grav::instance();\n            $backups_itr = new GlobIterator(static::$backup_dir . '/*.zip', FilesystemIterator::KEY_AS_FILENAME);\n            $inflector = $grav['inflector'];\n            $long_date_format = DATE_RFC2822;\n\n            /**\n             * @var string $name\n             * @var SplFileInfo $file\n             */\n            foreach ($backups_itr as $name => $file) {\n                if (preg_match(static::BACKUP_FILENAME_REGEXZ, $name, $matches)) {\n                    $date = DateTime::createFromFormat(static::BACKUP_DATE_FORMAT, $matches[2]);\n                    $timestamp = $date->getTimestamp();\n                    $backup = new stdClass();\n                    $backup->title = $inflector->titleize($matches[1]);\n                    $backup->time = $date;\n                    $backup->date = $date->format($long_date_format);\n                    $backup->filename = $name;\n                    $backup->path = $file->getPathname();\n                    $backup->size = $file->getSize();\n                    static::$backups[$timestamp] = $backup;\n                }\n            }\n            // Reverse Key Sort to get in reverse date order\n            krsort(static::$backups);\n        }\n\n        return static::$backups;\n    }\n\n    /**\n     * Backup\n     *\n     * @param int $id\n     * @param callable|null $status\n     * @return string|null\n     */\n    public static function backup($id = 0, callable $status = null)\n    {\n        $grav = Grav::instance();\n\n        $profiles = static::getBackupProfiles();\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        if (isset($profiles[$id])) {\n            $backup = (object) $profiles[$id];\n        } else {\n            throw new RuntimeException('No backups defined...');\n        }\n\n        $name = $grav['inflector']->underscorize($backup->name);\n        $date = date(static::BACKUP_DATE_FORMAT, time());\n        $filename = trim($name, '_') . '--' . $date . '.zip';\n        $destination = static::$backup_dir . DS . $filename;\n        $max_execution_time = ini_set('max_execution_time', '600');\n        $backup_root = $backup->root;\n\n        if ($locator->isStream($backup_root)) {\n            $backup_root = $locator->findResource($backup_root);\n        } else {\n            $backup_root = rtrim(GRAV_ROOT . $backup_root, DS) ?: DS;\n        }\n\n        if (!$backup_root || !file_exists($backup_root)) {\n            throw new RuntimeException(\"Backup location: {$backup_root} does not exist...\");\n        }\n\n        $options = [\n            'exclude_files' => static::convertExclude($backup->exclude_files ?? ''),\n            'exclude_paths' => static::convertExclude($backup->exclude_paths ?? ''),\n        ];\n\n        $archiver = Archiver::create('zip');\n        $archiver->setArchive($destination)->setOptions($options)->compress($backup_root, $status)->addEmptyFolders($options['exclude_paths'], $status);\n\n        $status && $status([\n            'type' => 'message',\n            'message' => 'Done...',\n        ]);\n\n        $status && $status([\n            'type' => 'progress',\n            'complete' => true\n        ]);\n\n        if ($max_execution_time !== false) {\n            ini_set('max_execution_time', $max_execution_time);\n        }\n\n        // Log the backup\n        $grav['log']->notice('Backup Created: ' . $destination);\n\n        // Fire Finished event\n        $grav->fireEvent('onBackupFinished', new Event(['backup' => $destination]));\n\n        // Purge anything required\n        static::purge();\n\n        // Log\n        $log = JsonFile::instance($locator->findResource(\"log://backup.log\", true, true));\n        $log->content([\n            'time'     => time(),\n            'location' => $destination\n        ]);\n        $log->save();\n\n        return $destination;\n    }\n\n    /**\n     * @return void\n     * @throws Exception\n     */\n    public static function purge()\n    {\n        $purge_config = static::getPurgeConfig();\n        $trigger = $purge_config['trigger'];\n        $backups = static::getAvailableBackups(true);\n\n        switch ($trigger) {\n            case 'number':\n                $backups_count = count($backups);\n                if ($backups_count > $purge_config['max_backups_count']) {\n                    $last = end($backups);\n                    unlink($last->path);\n                    static::purge();\n                }\n                break;\n\n            case 'time':\n                $last = end($backups);\n                $now = new DateTime();\n                $interval = $now->diff($last->time);\n                if ($interval->days > $purge_config['max_backups_time']) {\n                    unlink($last->path);\n                    static::purge();\n                }\n                break;\n\n            default:\n                $used_space = static::getTotalBackupsSize();\n                $max_space = $purge_config['max_backups_space'] * 1024 * 1024 *  1024;\n                if ($used_space > $max_space) {\n                    $last = end($backups);\n                    unlink($last->path);\n                    static::purge();\n                }\n                break;\n        }\n    }\n\n    /**\n     * @param string $exclude\n     * @return array\n     */\n    protected static function convertExclude($exclude)\n    {\n        // Split by newlines, commas, or multiple spaces\n        $lines = preg_split(\"/[\\r\\n,]+|[\\s]{2,}/\", $exclude);\n        // Remove empty values and trim\n        $lines = array_filter(array_map('trim', $lines));\n\n        return array_map('trim', $lines, array_fill(0, count($lines), '/'));\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Browser.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse InvalidArgumentException;\nuse function donatj\\UserAgent\\parse_user_agent;\n\n/**\n * Internally uses the PhpUserAgent package https://github.com/donatj/PhpUserAgent\n */\nclass Browser\n{\n    /** @var string[] */\n    protected $useragent = [];\n\n    /**\n     * Browser constructor.\n     */\n    public function __construct()\n    {\n        try {\n            $this->useragent = parse_user_agent();\n        } catch (InvalidArgumentException $e) {\n            $this->useragent = parse_user_agent(\"Mozilla/5.0 (compatible; Unknown;)\");\n        }\n    }\n\n    /**\n     * Get the current browser identifier\n     *\n     * Currently detected browsers:\n     *\n     * Android Browser\n     * BlackBerry Browser\n     * Camino\n     * Kindle / Silk\n     * Firefox / Iceweasel\n     * Safari\n     * Internet Explorer\n     * IEMobile\n     * Chrome\n     * Opera\n     * Midori\n     * Vivaldi\n     * TizenBrowser\n     * Lynx\n     * Wget\n     * Curl\n     *\n     * @return string the lowercase browser name\n     */\n    public function getBrowser()\n    {\n        return strtolower($this->useragent['browser']);\n    }\n\n    /**\n     * Get the current platform identifier\n     *\n     * Currently detected platforms:\n     *\n     * Desktop\n     *   -> Windows\n     *   -> Linux\n     *   -> Macintosh\n     *   -> Chrome OS\n     * Mobile\n     *   -> Android\n     *   -> iPhone\n     *   -> iPad / iPod Touch\n     *   -> Windows Phone OS\n     *   -> Kindle\n     *   -> Kindle Fire\n     *   -> BlackBerry\n     *   -> Playbook\n     *   -> Tizen\n     * Console\n     *   -> Nintendo 3DS\n     *   -> New Nintendo 3DS\n     *   -> Nintendo Wii\n     *   -> Nintendo WiiU\n     *   -> PlayStation 3\n     *   -> PlayStation 4\n     *   -> PlayStation Vita\n     *   -> Xbox 360\n     *   -> Xbox One\n     *\n     * @return string the lowercase platform name\n     */\n    public function getPlatform()\n    {\n        return strtolower($this->useragent['platform']);\n    }\n\n    /**\n     * Get the current full version identifier\n     *\n     * @return string the browser full version identifier\n     */\n    public function getLongVersion()\n    {\n        return $this->useragent['version'];\n    }\n\n    /**\n     * Get the current major version identifier\n     *\n     * @return int the browser major version identifier\n     */\n    public function getVersion()\n    {\n        $version = explode('.', $this->getLongVersion());\n\n        return (int)$version[0];\n    }\n\n    /**\n     * Determine if the request comes from a human, or from a bot/crawler\n     *\n     * @return bool\n     */\n    public function isHuman()\n    {\n        $browser = $this->getBrowser();\n        if (empty($browser)) {\n            return false;\n        }\n\n        if (preg_match('~(bot|crawl)~i', $browser)) {\n            return false;\n        }\n\n        return true;\n    }\n\n    /**\n     * Determine if “Do Not Track” is set by browser\n     * @see https://www.w3.org/TR/tracking-dnt/\n     *\n     * @return bool\n     */\n    public function isTrackable(): bool\n    {\n        return !(isset($_SERVER['HTTP_DNT']) && $_SERVER['HTTP_DNT'] === '1');\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Cache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse DirectoryIterator;\nuse \\Doctrine\\Common\\Cache as DoctrineCache;\nuse Exception;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Scheduler\\Scheduler;\nuse LogicException;\nuse Psr\\SimpleCache\\CacheInterface;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\nuse function dirname;\nuse function extension_loaded;\nuse function function_exists;\nuse function in_array;\nuse function is_array;\n\n/**\n * The GravCache object is used throughout Grav to store and retrieve cached data.\n * It uses DoctrineCache library and supports a variety of caching mechanisms. Those include:\n *\n * APCu\n * RedisCache\n * MemCache\n * MemCacheD\n * FileSystem\n */\nclass Cache extends Getters\n{\n    /** @var string Cache key. */\n    protected $key;\n\n    /** @var int */\n    protected $lifetime;\n\n    /** @var int */\n    protected $now;\n\n    /** @var Config $config */\n    protected $config;\n\n    /** @var DoctrineCache\\CacheProvider */\n    protected $driver;\n\n    /** @var CacheInterface */\n    protected $simpleCache;\n\n    /** @var string */\n    protected $driver_name;\n\n    /** @var string */\n    protected $driver_setting;\n\n    /** @var bool */\n    protected $enabled;\n\n    /** @var string */\n    protected $cache_dir;\n\n    protected static $standard_remove = [\n        'cache://twig/',\n        'cache://doctrine/',\n        'cache://compiled/',\n        'cache://clockwork/',\n        'cache://validated-',\n        'cache://images',\n        'asset://',\n    ];\n\n    protected static $standard_remove_no_images = [\n        'cache://twig/',\n        'cache://doctrine/',\n        'cache://compiled/',\n        'cache://clockwork/',\n        'cache://validated-',\n        'asset://',\n    ];\n\n    protected static $all_remove = [\n        'cache://',\n        'cache://images',\n        'asset://',\n        'tmp://'\n    ];\n\n    protected static $assets_remove = [\n        'asset://'\n    ];\n\n    protected static $images_remove = [\n        'cache://images'\n    ];\n\n    protected static $cache_remove = [\n        'cache://'\n    ];\n\n    protected static $tmp_remove = [\n        'tmp://'\n    ];\n\n    /**\n     * Constructor\n     *\n     * @param Grav $grav\n     */\n    public function __construct(Grav $grav)\n    {\n        $this->init($grav);\n    }\n\n    /**\n     * Initialization that sets a base key and the driver based on configuration settings\n     *\n     * @param  Grav $grav\n     * @return void\n     */\n    public function init(Grav $grav)\n    {\n        $this->config = $grav['config'];\n        $this->now = time();\n\n        if (null === $this->enabled) {\n            $this->enabled = (bool)$this->config->get('system.cache.enabled');\n        }\n\n        /** @var Uri $uri */\n        $uri = $grav['uri'];\n\n        $prefix = $this->config->get('system.cache.prefix');\n        $uniqueness = substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), 2, 8);\n\n        // Cache key allows us to invalidate all cache on configuration changes.\n        $this->key = ($prefix ?: 'g') . '-' . $uniqueness;\n        $this->cache_dir = $grav['locator']->findResource('cache://doctrine/' . $uniqueness, true, true);\n        $this->driver_setting = $this->config->get('system.cache.driver');\n        $this->driver = $this->getCacheDriver();\n        $this->driver->setNamespace($this->key);\n\n        /** @var EventDispatcher $dispatcher */\n        $dispatcher = Grav::instance()['events'];\n        $dispatcher->addListener('onSchedulerInitialized', [$this, 'onSchedulerInitialized']);\n    }\n\n    /**\n     * @return CacheInterface\n     */\n    public function getSimpleCache()\n    {\n        if (null === $this->simpleCache) {\n            $cache = new \\Grav\\Framework\\Cache\\Adapter\\DoctrineCache($this->driver, '', $this->getLifetime());\n\n            // Disable cache key validation.\n            $cache->setValidation(false);\n\n            $this->simpleCache = $cache;\n        }\n\n        return $this->simpleCache;\n    }\n\n    /**\n     * Deletes old cache files based on age\n     *\n     * @return int\n     */\n    public function purgeOldCache()\n    {\n        // Get the max age for cache files from config (default 30 days)\n        $max_age_days = $this->config->get('system.cache.purge_max_age_days', 30);\n        $max_age_seconds = $max_age_days * 86400; // Convert days to seconds\n        $now = time();\n        $count = 0;\n        \n        // First, clean up old orphaned cache directories (not the current one)\n        $cache_dir = dirname($this->cache_dir);\n        $current = Utils::basename($this->cache_dir);\n        \n        foreach (new DirectoryIterator($cache_dir) as $file) {\n            $dir = $file->getBasename();\n            if ($dir === $current || $file->isDot() || $file->isFile()) {\n                continue;\n            }\n            \n            // Check if directory is old and empty or very old (90+ days)\n            $dir_age = $now - $file->getMTime();\n            if ($dir_age > 7776000) { // 90 days\n                Folder::delete($file->getPathname());\n                $count++;\n            }\n        }\n        \n        // Now clean up old cache files within the current cache directory\n        if (is_dir($this->cache_dir)) {\n            $iterator = new \\RecursiveIteratorIterator(\n                new \\RecursiveDirectoryIterator($this->cache_dir, \\RecursiveDirectoryIterator::SKIP_DOTS),\n                \\RecursiveIteratorIterator::CHILD_FIRST\n            );\n            \n            foreach ($iterator as $file) {\n                if ($file->isFile()) {\n                    $file_age = $now - $file->getMTime();\n                    if ($file_age > $max_age_seconds) {\n                        @unlink($file->getPathname());\n                        $count++;\n                    }\n                }\n            }\n        }\n        \n        // Also clean up old files in compiled cache\n        $grav = Grav::instance();\n        $compiled_dir = $this->config->get('system.cache.compiled_dir', 'cache://compiled');\n        $compiled_path = $grav['locator']->findResource($compiled_dir, true);\n        \n        if ($compiled_path && is_dir($compiled_path)) {\n            $iterator = new \\RecursiveIteratorIterator(\n                new \\RecursiveDirectoryIterator($compiled_path, \\RecursiveDirectoryIterator::SKIP_DOTS),\n                \\RecursiveIteratorIterator::CHILD_FIRST\n            );\n            \n            foreach ($iterator as $file) {\n                if ($file->isFile()) {\n                    $file_age = $now - $file->getMTime();\n                    // Compiled files can be kept longer (60 days)\n                    if ($file_age > ($max_age_seconds * 2)) {\n                        @unlink($file->getPathname());\n                        $count++;\n                    }\n                }\n            }\n        }\n\n        return $count;\n    }\n\n    /**\n     * Public accessor to set the enabled state of the cache\n     *\n     * @param bool|int $enabled\n     * @return void\n     */\n    public function setEnabled($enabled)\n    {\n        $this->enabled = (bool)$enabled;\n    }\n\n    /**\n     * Returns the current enabled state\n     *\n     * @return bool\n     */\n    public function getEnabled()\n    {\n        return $this->enabled;\n    }\n\n    /**\n     * Get cache state\n     *\n     * @return string\n     */\n    public function getCacheStatus()\n    {\n        return 'Cache: [' . ($this->enabled ? 'true' : 'false') . '] Setting: [' . $this->driver_setting . '] Driver: [' . $this->driver_name . ']';\n    }\n\n    /**\n     * Automatically picks the cache mechanism to use.  If you pick one manually it will use that\n     * If there is no config option for $driver in the config, or it's set to 'auto', it will\n     * pick the best option based on which cache extensions are installed.\n     *\n     * @return DoctrineCache\\CacheProvider  The cache driver to use\n     */\n    public function getCacheDriver()\n    {\n        $setting = $this->driver_setting;\n        $driver_name = 'file';\n\n        // CLI compatibility requires a non-volatile cache driver\n        if ($this->config->get('system.cache.cli_compatibility') && (\n            $setting === 'auto' || $this->isVolatileDriver($setting))) {\n            $setting = $driver_name;\n        }\n\n        if (!$setting || $setting === 'auto') {\n            if (extension_loaded('apcu')) {\n                $driver_name = 'apcu';\n            } elseif (extension_loaded('wincache')) {\n                $driver_name = 'wincache';\n            }\n        } else {\n            $driver_name = $setting;\n        }\n\n        $this->driver_name = $driver_name;\n\n        switch ($driver_name) {\n            case 'apc':\n            case 'apcu':\n                $driver = new DoctrineCache\\ApcuCache();\n                break;\n\n            case 'wincache':\n                $driver = new DoctrineCache\\WinCacheCache();\n                break;\n\n            case 'memcache':\n                if (extension_loaded('memcache')) {\n                    $memcache = new \\Memcache();\n                    $memcache->connect(\n                        $this->config->get('system.cache.memcache.server', 'localhost'),\n                        $this->config->get('system.cache.memcache.port', 11211)\n                    );\n                    $driver = new DoctrineCache\\MemcacheCache();\n                    $driver->setMemcache($memcache);\n                } else {\n                    throw new LogicException('Memcache PHP extension has not been installed');\n                }\n                break;\n\n            case 'memcached':\n                if (extension_loaded('memcached')) {\n                    $memcached = new \\Memcached();\n                    $memcached->addServer(\n                        $this->config->get('system.cache.memcached.server', 'localhost'),\n                        $this->config->get('system.cache.memcached.port', 11211)\n                    );\n                    $driver = new DoctrineCache\\MemcachedCache();\n                    $driver->setMemcached($memcached);\n                } else {\n                    throw new LogicException('Memcached PHP extension has not been installed');\n                }\n                break;\n\n            case 'redis':\n                if (extension_loaded('redis')) {\n                    $redis = new \\Redis();\n                    $socket = $this->config->get('system.cache.redis.socket', false);\n                    $password = $this->config->get('system.cache.redis.password', false);\n                    $databaseId = $this->config->get('system.cache.redis.database', 0);\n\n                    if ($socket) {\n                        $redis->connect($socket);\n                    } else {\n                        $redis->connect(\n                            $this->config->get('system.cache.redis.server', 'localhost'),\n                            $this->config->get('system.cache.redis.port', 6379)\n                        );\n                    }\n\n                    // Authenticate with password if set\n                    if ($password && !$redis->auth($password)) {\n                        throw new \\RedisException('Redis authentication failed');\n                    }\n\n                    // Select alternate ( !=0 ) database ID if set\n                    if ($databaseId && !$redis->select($databaseId)) {\n                        throw new \\RedisException('Could not select alternate Redis database ID');\n                    }\n\n                    $driver = new DoctrineCache\\RedisCache();\n                    $driver->setRedis($redis);\n                } else {\n                    throw new LogicException('Redis PHP extension has not been installed');\n                }\n                break;\n\n            default:\n                $driver = new DoctrineCache\\FilesystemCache($this->cache_dir);\n                break;\n        }\n\n        return $driver;\n    }\n\n    /**\n     * Gets a cached entry if it exists based on an id. If it does not exist, it returns false\n     *\n     * @param  string $id the id of the cached entry\n     * @return mixed|bool     returns the cached entry, can be any type, or false if doesn't exist\n     */\n    public function fetch($id)\n    {\n        if ($this->enabled) {\n            return $this->driver->fetch($id);\n        }\n\n        return false;\n    }\n\n    /**\n     * Stores a new cached entry.\n     *\n     * @param  string       $id       the id of the cached entry\n     * @param  array|object|int $data     the data for the cached entry to store\n     * @param  int|null     $lifetime the lifetime to store the entry in seconds\n     */\n    public function save($id, $data, $lifetime = null)\n    {\n        if ($this->enabled) {\n            if ($lifetime === null) {\n                $lifetime = $this->getLifetime();\n            }\n            $this->driver->save($id, $data, $lifetime);\n        }\n    }\n\n    /**\n     * Deletes an item in the cache based on the id\n     *\n     * @param string $id    the id of the cached data entry\n     * @return bool         true if the item was deleted successfully\n     */\n    public function delete($id)\n    {\n        if ($this->enabled) {\n            return $this->driver->delete($id);\n        }\n\n        return false;\n    }\n\n    /**\n     * Deletes all cache\n     *\n     * @return bool\n     */\n    public function deleteAll()\n    {\n        if ($this->enabled) {\n            return $this->driver->deleteAll();\n        }\n\n        return false;\n    }\n\n    /**\n     * Returns a boolean state of whether or not the item exists in the cache based on id key\n     *\n     * @param string $id    the id of the cached data entry\n     * @return bool         true if the cached items exists\n     */\n    public function contains($id)\n    {\n        if ($this->enabled) {\n            return $this->driver->contains(($id));\n        }\n\n        return false;\n    }\n\n    /**\n     * Getter method to get the cache key\n     *\n     * @return string\n     */\n    public function getKey()\n    {\n        return $this->key;\n    }\n\n    /**\n     * Setter method to set key (Advanced)\n     *\n     * @param string $key\n     * @return void\n     */\n    public function setKey($key)\n    {\n        $this->key = $key;\n        $this->driver->setNamespace($this->key);\n    }\n\n    /**\n     * Helper method to clear all Grav caches\n     *\n     * @param string $remove standard|all|assets-only|images-only|cache-only\n     * @return array\n     */\n    public static function clearCache($remove = 'standard')\n    {\n        $locator = Grav::instance()['locator'];\n        $output = [];\n        $user_config = USER_DIR . 'config/system.yaml';\n\n        switch ($remove) {\n            case 'all':\n                $remove_paths = self::$all_remove;\n                break;\n            case 'assets-only':\n                $remove_paths = self::$assets_remove;\n                break;\n            case 'images-only':\n                $remove_paths = self::$images_remove;\n                break;\n            case 'cache-only':\n                $remove_paths = self::$cache_remove;\n                break;\n            case 'tmp-only':\n                $remove_paths = self::$tmp_remove;\n                break;\n            case 'invalidate':\n                $remove_paths = [];\n                break;\n            default:\n                if (Grav::instance()['config']->get('system.cache.clear_images_by_default')) {\n                    $remove_paths = self::$standard_remove;\n                } else {\n                    $remove_paths = self::$standard_remove_no_images;\n                }\n        }\n\n        // Delete entries in the doctrine cache if required\n        if (in_array($remove, ['all', 'standard'])) {\n            $cache = Grav::instance()['cache'];\n            $cache->driver->deleteAll();\n        }\n\n        // Clearing cache event to add paths to clear\n        Grav::instance()->fireEvent('onBeforeCacheClear', new Event(['remove' => $remove, 'paths' => &$remove_paths]));\n\n        foreach ($remove_paths as $stream) {\n            // Convert stream to a real path\n            try {\n                $path = $locator->findResource($stream, true, true);\n                if ($path === false) {\n                    continue;\n                }\n\n                $anything = false;\n                $files = glob($path . '/*');\n\n                if (is_array($files)) {\n                    foreach ($files as $file) {\n                        if (is_link($file)) {\n                            $output[] = '<yellow>Skipping symlink:  </yellow>' . $file;\n                        } elseif (is_file($file)) {\n                            if (@unlink($file)) {\n                                $anything = true;\n                            }\n                        } elseif (is_dir($file)) {\n                            if (Folder::delete($file, false)) {\n                                $anything = true;\n                            }\n                        }\n                    }\n                }\n\n                if ($anything) {\n                    $output[] = '<red>Cleared:  </red>' . $path . '/*';\n                }\n            } catch (Exception $e) {\n                // stream not found or another error while deleting files.\n                $output[] = '<red>ERROR: </red>' . $e->getMessage();\n            }\n        }\n\n        $output[] = '';\n\n        if (($remove === 'all' || $remove === 'standard') && file_exists($user_config)) {\n            touch($user_config);\n\n            $output[] = '<red>Touched: </red>' . $user_config;\n            $output[] = '';\n        }\n\n        // Clear stat cache\n        @clearstatcache();\n\n        // Clear opcache\n        if (function_exists('opcache_reset')) {\n            @opcache_reset();\n        }\n\n        Grav::instance()->fireEvent('onAfterCacheClear', new Event(['remove' => $remove, 'output' => &$output]));\n\n        return $output;\n    }\n\n    /**\n     * @return void\n     */\n    public static function invalidateCache()\n    {\n        $user_config = USER_DIR . 'config/system.yaml';\n\n        if (file_exists($user_config)) {\n            touch($user_config);\n        }\n\n        // Clear stat cache\n        @clearstatcache();\n\n        // Clear opcache\n        if (function_exists('opcache_reset')) {\n            @opcache_reset();\n        }\n    }\n\n    /**\n     * Set the cache lifetime programmatically\n     *\n     * @param int $future timestamp\n     * @return void\n     */\n    public function setLifetime($future)\n    {\n        if (!$future) {\n            return;\n        }\n\n        $interval = (int)($future - $this->now);\n        if ($interval > 0 && $interval < $this->getLifetime()) {\n            $this->lifetime = $interval;\n        }\n    }\n\n\n    /**\n     * Retrieve the cache lifetime (in seconds)\n     *\n     * @return int\n     */\n    public function getLifetime()\n    {\n        if ($this->lifetime === null) {\n            $this->lifetime = (int)($this->config->get('system.cache.lifetime') ?: 604800); // 1 week default\n        }\n\n        return $this->lifetime;\n    }\n\n    /**\n     * Returns the current driver name\n     *\n     * @return string\n     */\n    public function getDriverName()\n    {\n        return $this->driver_name;\n    }\n\n    /**\n     * Returns the current driver setting\n     *\n     * @return string\n     */\n    public function getDriverSetting()\n    {\n        return $this->driver_setting;\n    }\n\n    /**\n     * is this driver a volatile driver in that it resides in PHP process memory\n     *\n     * @param string $setting\n     * @return bool\n     */\n    public function isVolatileDriver($setting)\n    {\n        return in_array($setting, ['apc', 'apcu', 'xcache', 'wincache'], true);\n    }\n\n    /**\n     * Static function to call as a scheduled Job to purge old Doctrine files\n     *\n     * @param bool $echo\n     *\n     * @return string|void\n     */\n    public static function purgeJob($echo = false)\n    {\n        /** @var Cache $cache */\n        $cache = Grav::instance()['cache'];\n        $deleted_items = $cache->purgeOldCache();\n        \n        $max_age = $cache->config->get('system.cache.purge_max_age_days', 30);\n        $msg = 'Purged ' . $deleted_items . ' old cache items (files older than ' . $max_age . ' days)';\n\n        if ($echo) {\n            echo $msg;\n        } else {\n            return $msg;\n        }\n    }\n\n    /**\n     * Static function to call as a scheduled Job to clear Grav cache\n     *\n     * @param string $type\n     * @return void\n     */\n    public static function clearJob($type)\n    {\n        $result = static::clearCache($type);\n        static::invalidateCache();\n\n        echo strip_tags(implode(\"\\n\", $result));\n    }\n\n    /**\n     * @param Event $event\n     * @return void\n     */\n    public function onSchedulerInitialized(Event $event)\n    {\n        /** @var Scheduler $scheduler */\n        $scheduler = $event['scheduler'];\n        $config = Grav::instance()['config'];\n\n        // File Cache Purge\n        $at = $config->get('system.cache.purge_at');\n        $name = 'cache-purge';\n        $logs = 'logs/' . $name . '.out';\n\n        $job = $scheduler->addFunction('Grav\\Common\\Cache::purgeJob', [true], $name);\n        $job->at($at);\n        $job->output($logs);\n        $job->backlink('/config/system#caching');\n\n        // Cache Clear\n        $at = $config->get('system.cache.clear_at');\n        $clear_type = $config->get('system.cache.clear_job_type');\n        $name = 'cache-clear';\n        $logs = 'logs/' . $name . '.out';\n\n        $job = $scheduler->addFunction('Grav\\Common\\Cache::clearJob', [$clear_type], $name);\n        $job->at($at);\n        $job->output($logs);\n        $job->backlink('/config/system#caching');\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Composer.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse function function_exists;\n\n/**\n * Class Composer\n * @package Grav\\Common\n */\nclass Composer\n{\n    /** @const Default composer location */\n    const DEFAULT_PATH = 'bin/composer.phar';\n\n    /**\n     * Returns the location of composer.\n     *\n     * @return string\n     */\n    public static function getComposerLocation()\n    {\n        if (!function_exists('shell_exec') || stripos(PHP_OS, 'win') === 0) {\n            return self::DEFAULT_PATH;\n        }\n\n        // check for global composer install\n        $path = trim((string)shell_exec('command -v composer'));\n\n        // fall back to grav bundled composer\n        if (!$path || !preg_match('/(composer|composer\\.phar)$/', $path)) {\n            $path = self::DEFAULT_PATH;\n        }\n\n        return $path;\n    }\n\n    /**\n     * Return the composer executable file path\n     *\n     * @return string\n     */\n    public static function getComposerExecutor()\n    {\n        $executor = PHP_BINARY . ' ';\n        $composer = static::getComposerLocation();\n\n        if ($composer !== static::DEFAULT_PATH && is_executable($composer)) {\n            $file = fopen($composer, 'rb');\n            $firstLine = fgets($file);\n            fclose($file);\n\n            if (!preg_match('/^#!.+php/i', $firstLine)) {\n                $executor = '';\n            }\n        }\n\n        return $executor . $composer;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Config/CompiledBase.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Config\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Config;\n\nuse BadMethodCallException;\nuse Exception;\nuse RocketTheme\\Toolbox\\File\\PhpFile;\nuse RuntimeException;\nuse function get_class;\nuse function is_array;\n\n/**\n * Class CompiledBase\n * @package Grav\\Common\\Config\n */\nabstract class CompiledBase\n{\n    /** @var int Version number for the compiled file. */\n    public $version = 1;\n\n    /** @var string  Filename (base name) of the compiled configuration. */\n    public $name;\n\n    /** @var string|bool  Configuration checksum. */\n    public $checksum;\n\n    /** @var int  Timestamp of compiled configuration */\n    public $timestamp = 0;\n\n    /** @var string Cache folder to be used. */\n    protected $cacheFolder;\n\n    /** @var array  List of files to load. */\n    protected $files;\n\n    /** @var string */\n    protected $path;\n\n    /** @var mixed  Configuration object. */\n    protected $object;\n\n    /**\n     * @param  string $cacheFolder  Cache folder to be used.\n     * @param  array  $files  List of files as returned from ConfigFileFinder class.\n     * @param string $path  Base path for the file list.\n     * @throws BadMethodCallException\n     */\n    public function __construct($cacheFolder, array $files, $path)\n    {\n        if (!$cacheFolder) {\n            throw new BadMethodCallException('Cache folder not defined.');\n        }\n\n        $this->path = $path ? rtrim($path, '\\\\/') . '/' : '';\n        $this->cacheFolder = $cacheFolder;\n        $this->files = $files;\n    }\n\n    /**\n     * Get filename for the compiled PHP file.\n     *\n     * @param string|null $name\n     * @return $this\n     */\n    public function name($name = null)\n    {\n        if (!$this->name) {\n            $this->name = $name ?: md5(json_encode(array_keys($this->files)));\n        }\n\n        return $this;\n    }\n\n    /**\n     * Function gets called when cached configuration is saved.\n     *\n     * @return void\n     */\n    public function modified()\n    {\n    }\n\n    /**\n     * Get timestamp of compiled configuration\n     *\n     * @return int Timestamp of compiled configuration\n     */\n    public function timestamp()\n    {\n        return $this->timestamp ?: time();\n    }\n\n    /**\n     * Load the configuration.\n     *\n     * @return mixed\n     */\n    public function load()\n    {\n        if ($this->object) {\n            return $this->object;\n        }\n\n        $filename = $this->createFilename();\n        if (!$this->loadCompiledFile($filename) && $this->loadFiles()) {\n            $this->saveCompiledFile($filename);\n        }\n\n        return $this->object;\n    }\n\n    /**\n     * Returns checksum from the configuration files.\n     *\n     * You can set $this->checksum = false to disable this check.\n     *\n     * @return bool|string\n     */\n    public function checksum()\n    {\n        if (null === $this->checksum) {\n            $this->checksum = md5(json_encode($this->files) . $this->version);\n        }\n\n        return $this->checksum;\n    }\n\n    /**\n     * @return string\n     */\n    protected function createFilename()\n    {\n        return \"{$this->cacheFolder}/{$this->name()->name}.php\";\n    }\n\n    /**\n     * Create configuration object.\n     *\n     * @param  array  $data\n     * @return void\n     */\n    abstract protected function createObject(array $data = []);\n\n    /**\n     * Finalize configuration object.\n     *\n     * @return void\n     */\n    abstract protected function finalizeObject();\n\n    /**\n     * Load single configuration file and append it to the correct position.\n     *\n     * @param  string  $name  Name of the position.\n     * @param  string|string[]  $filename  File(s) to be loaded.\n     * @return void\n     */\n    abstract protected function loadFile($name, $filename);\n\n    /**\n     * Load and join all configuration files.\n     *\n     * @return bool\n     * @internal\n     */\n    protected function loadFiles()\n    {\n        $this->createObject();\n\n        $list = array_reverse($this->files);\n        foreach ($list as $files) {\n            foreach ($files as $name => $item) {\n                $this->loadFile($name, $this->path . $item['file']);\n            }\n        }\n\n        $this->finalizeObject();\n\n        return true;\n    }\n\n    /**\n     * Load compiled file.\n     *\n     * @param  string  $filename\n     * @return bool\n     * @internal\n     */\n    protected function loadCompiledFile($filename)\n    {\n        if (!file_exists($filename)) {\n            return false;\n        }\n\n        $cache = include $filename;\n        if (!is_array($cache)\n            || !isset($cache['checksum'], $cache['data'], $cache['@class'])\n            || $cache['@class'] !== get_class($this)\n        ) {\n            return false;\n        }\n\n        // Load real file if cache isn't up to date (or is invalid).\n        if ($cache['checksum'] !== $this->checksum()) {\n            return false;\n        }\n\n        $this->createObject($cache['data']);\n        $this->timestamp = $cache['timestamp'] ?? 0;\n\n        $this->finalizeObject();\n\n        return true;\n    }\n\n    /**\n     * Save compiled file.\n     *\n     * @param  string  $filename\n     * @return void\n     * @throws RuntimeException\n     * @internal\n     */\n    protected function saveCompiledFile($filename)\n    {\n        $file = PhpFile::instance($filename);\n\n        // Attempt to lock the file for writing.\n        try {\n            $file->lock(false);\n        } catch (Exception $e) {\n            // Another process has locked the file; we will check this in a bit.\n        }\n\n        if ($file->locked() === false) {\n            // File was already locked by another process.\n            return;\n        }\n\n        $cache = [\n            '@class' => get_class($this),\n            'timestamp' => time(),\n            'checksum' => $this->checksum(),\n            'files' => $this->files,\n            'data' => $this->getState()\n        ];\n\n        $file->save($cache);\n        $file->unlock();\n        $file->free();\n\n        $this->modified();\n    }\n\n    /**\n     * @return array\n     */\n    protected function getState()\n    {\n        return $this->object->toArray();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Config/CompiledBlueprints.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Config\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Config;\n\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\BlueprintSchema;\nuse Grav\\Common\\Grav;\n\n/**\n * Class CompiledBlueprints\n * @package Grav\\Common\\Config\n */\nclass CompiledBlueprints extends CompiledBase\n{\n    /**\n     * CompiledBlueprints constructor.\n     * @param string $cacheFolder\n     * @param array $files\n     * @param string $path\n     */\n    public function __construct($cacheFolder, array $files, $path)\n    {\n        parent::__construct($cacheFolder, $files, $path);\n\n        $this->version = 2;\n    }\n\n    /**\n     * Returns checksum from the configuration files.\n     *\n     * You can set $this->checksum = false to disable this check.\n     *\n     * @return bool|string\n     */\n    public function checksum()\n    {\n        if (null === $this->checksum) {\n            $this->checksum = md5(json_encode($this->files) . json_encode($this->getTypes()) . $this->version);\n        }\n\n        return $this->checksum;\n    }\n\n    /**\n     * Create configuration object.\n     *\n     * @param array $data\n     */\n    protected function createObject(array $data = [])\n    {\n        $this->object = (new BlueprintSchema($data))->setTypes($this->getTypes());\n    }\n\n    /**\n     * Get list of form field types.\n     *\n     * @return array\n     */\n    protected function getTypes()\n    {\n        return Grav::instance()['plugins']->formFieldTypes ?: [];\n    }\n\n    /**\n     * Finalize configuration object.\n     *\n     * @return void\n     */\n    protected function finalizeObject()\n    {\n    }\n\n    /**\n     * Load single configuration file and append it to the correct position.\n     *\n     * @param  string  $name  Name of the position.\n     * @param  array   $files  Files to be loaded.\n     * @return void\n     */\n    protected function loadFile($name, $files)\n    {\n        // Load blueprint file.\n        $blueprint = new Blueprint($files);\n\n        $this->object->embed($name, $blueprint->load()->toArray(), '/', true);\n    }\n\n    /**\n     * Load and join all configuration files.\n     *\n     * @return bool\n     * @internal\n     */\n    protected function loadFiles()\n    {\n        $this->createObject();\n\n        // Convert file list into parent list.\n        $list = [];\n        /** @var array $files */\n        foreach ($this->files as $files) {\n            foreach ($files as $name => $item) {\n                $list[$name][] = $this->path . $item['file'];\n            }\n        }\n\n        // Load files.\n        foreach ($list as $name => $files) {\n            $this->loadFile($name, $files);\n        }\n\n        $this->finalizeObject();\n\n        return true;\n    }\n\n    /**\n     * @return array\n     */\n    protected function getState()\n    {\n        return $this->object->getState();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Config/CompiledConfig.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Config\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Config;\n\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse function is_callable;\n\n/**\n * Class CompiledConfig\n * @package Grav\\Common\\Config\n */\nclass CompiledConfig extends CompiledBase\n{\n    /** @var callable  Blueprints loader. */\n    protected $callable;\n\n    /** @var bool */\n    protected $withDefaults = false;\n\n    /**\n     * CompiledConfig constructor.\n     * @param string $cacheFolder\n     * @param array $files\n     * @param string $path\n     */\n    public function __construct($cacheFolder, array $files, $path)\n    {\n        parent::__construct($cacheFolder, $files, $path);\n\n        $this->version = 1;\n    }\n\n    /**\n     * Set blueprints for the configuration.\n     *\n     * @param callable $blueprints\n     * @return $this\n     */\n    public function setBlueprints(callable $blueprints)\n    {\n        $this->callable = $blueprints;\n\n        return $this;\n    }\n\n    /**\n     * @param bool $withDefaults\n     * @return mixed\n     */\n    public function load($withDefaults = false)\n    {\n        $this->withDefaults = $withDefaults;\n\n        return parent::load();\n    }\n\n    /**\n     * Create configuration object.\n     *\n     * @param  array  $data\n     * @return void\n     */\n    protected function createObject(array $data = [])\n    {\n        if ($this->withDefaults && empty($data) && is_callable($this->callable)) {\n            $blueprints = $this->callable;\n            $data = $blueprints()->getDefaults();\n        }\n\n        $this->object = new Config($data, $this->callable);\n    }\n\n    /**\n     * Finalize configuration object.\n     *\n     * @return void\n     */\n    protected function finalizeObject()\n    {\n        $this->object->checksum($this->checksum());\n        $this->object->timestamp($this->timestamp());\n    }\n\n    /**\n     * Function gets called when cached configuration is saved.\n     *\n     * @return void\n     */\n    public function modified()\n    {\n        $this->object->modified(true);\n    }\n\n    /**\n     * Load single configuration file and append it to the correct position.\n     *\n     * @param  string  $name  Name of the position.\n     * @param  string  $filename  File to be loaded.\n     * @return void\n     */\n    protected function loadFile($name, $filename)\n    {\n        $file = CompiledYamlFile::instance($filename);\n        $this->object->join($name, $file->content(), '/');\n        $file->free();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Config/CompiledLanguages.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Config\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Config;\n\nuse Grav\\Common\\File\\CompiledYamlFile;\n\n/**\n * Class CompiledLanguages\n * @package Grav\\Common\\Config\n */\nclass CompiledLanguages extends CompiledBase\n{\n    /**\n     * CompiledLanguages constructor.\n     * @param string $cacheFolder\n     * @param array $files\n     * @param string $path\n     */\n    public function __construct($cacheFolder, array $files, $path)\n    {\n        parent::__construct($cacheFolder, $files, $path);\n\n        $this->version = 1;\n    }\n\n    /**\n     * Create configuration object.\n     *\n     * @param  array  $data\n     * @return void\n     */\n    protected function createObject(array $data = [])\n    {\n        $this->object = new Languages($data);\n    }\n\n    /**\n     * Finalize configuration object.\n     *\n     * @return void\n     */\n    protected function finalizeObject()\n    {\n        $this->object->checksum($this->checksum());\n        $this->object->timestamp($this->timestamp());\n    }\n\n\n    /**\n     * Function gets called when cached configuration is saved.\n     *\n     * @return void\n     */\n    public function modified()\n    {\n        $this->object->modified(true);\n    }\n\n    /**\n     * Load single configuration file and append it to the correct position.\n     *\n     * @param  string  $name  Name of the position.\n     * @param  string  $filename  File to be loaded.\n     * @return void\n     */\n    protected function loadFile($name, $filename)\n    {\n        $file = CompiledYamlFile::instance($filename);\n        if (preg_match('|languages\\.yaml$|', $filename)) {\n            $this->object->mergeRecursive((array) $file->content());\n        } else {\n            $this->object->mergeRecursive([$name => $file->content()]);\n        }\n        $file->free();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Config/Config.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Config\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Config;\n\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Service\\ConfigServiceProvider;\nuse Grav\\Common\\Utils;\nuse function is_array;\n\n/**\n * Class Config\n * @package Grav\\Common\\Config\n */\nclass Config extends Data\n{\n    /** @var string */\n    public $environment;\n\n    /** @var string */\n    protected $key;\n    /** @var string */\n    protected $checksum;\n    /** @var int */\n    protected $timestamp = 0;\n    /** @var bool */\n    protected $modified = false;\n\n    /**\n     * @return string\n     */\n    public function key()\n    {\n        if (null === $this->key) {\n            $this->key = md5($this->checksum . $this->timestamp);\n        }\n\n        return $this->key;\n    }\n\n    /**\n     * @param string|null $checksum\n     * @return string|null\n     */\n    public function checksum($checksum = null)\n    {\n        if ($checksum !== null) {\n            $this->checksum = $checksum;\n        }\n\n        return $this->checksum;\n    }\n\n    /**\n     * @param bool|null $modified\n     * @return bool\n     */\n    public function modified($modified = null)\n    {\n        if ($modified !== null) {\n            $this->modified = $modified;\n        }\n\n        return $this->modified;\n    }\n\n    /**\n     * @param int|null $timestamp\n     * @return int\n     */\n    public function timestamp($timestamp = null)\n    {\n        if ($timestamp !== null) {\n            $this->timestamp = $timestamp;\n        }\n\n        return $this->timestamp;\n    }\n\n    /**\n     * @return $this\n     */\n    public function reload()\n    {\n        $grav = Grav::instance();\n\n        // Load new configuration.\n        $config = ConfigServiceProvider::load($grav);\n\n        /** @var Debugger $debugger */\n        $debugger = $grav['debugger'];\n\n        if ($config->modified()) {\n            // Update current configuration.\n            $this->items = $config->toArray();\n            $this->checksum($config->checksum());\n            $this->modified(true);\n\n            $debugger->addMessage('Configuration was changed and saved.');\n        }\n\n        return $this;\n    }\n\n    /**\n     * @return void\n     */\n    public function debug()\n    {\n        /** @var Debugger $debugger */\n        $debugger = Grav::instance()['debugger'];\n\n        $debugger->addMessage('Environment Name: ' . $this->environment);\n        if ($this->modified()) {\n            $debugger->addMessage('Configuration reloaded and cached.');\n        }\n    }\n\n    /**\n     * @return void\n     */\n    public function init()\n    {\n        $setup = Grav::instance()['setup']->toArray();\n        foreach ($setup as $key => $value) {\n            if ($key === 'streams' || !is_array($value)) {\n                // Optimized as streams and simple values are fully defined in setup.\n                $this->items[$key] = $value;\n            } else {\n                $this->joinDefaults($key, $value);\n            }\n        }\n\n        // Legacy value - Override the media.upload_limit based on PHP values\n        $this->items['system']['media']['upload_limit'] = Utils::getUploadLimit();\n    }\n\n    /**\n     * @return mixed\n     * @deprecated 1.5 Use Grav::instance()['languages'] instead.\n     */\n    public function getLanguages()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use Grav::instance()[\\'languages\\'] instead', E_USER_DEPRECATED);\n\n        return Grav::instance()['languages'];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Config/ConfigFileFinder.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Config\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Config;\n\nuse DirectoryIterator;\nuse Grav\\Common\\Filesystem\\Folder;\nuse RecursiveDirectoryIterator;\n\n/**\n * Class ConfigFileFinder\n * @package Grav\\Common\\Config\n */\nclass ConfigFileFinder\n{\n    /** @var string */\n    protected $base = '';\n\n    /**\n     * @param string $base\n     * @return $this\n     */\n    public function setBase($base)\n    {\n        $this->base = $base ? \"{$base}/\" : '';\n\n        return $this;\n    }\n\n    /**\n     * Return all locations for all the files with a timestamp.\n     *\n     * @param  array  $paths    List of folders to look from.\n     * @param  string $pattern  Pattern to match the file. Pattern will also be removed from the key.\n     * @param  int    $levels   Maximum number of recursive directories.\n     * @return array\n     */\n    public function locateFiles(array $paths, $pattern = '|\\.yaml$|', $levels = -1)\n    {\n        $list = [];\n        foreach ($paths as $folder) {\n            $list += $this->detectRecursive($folder, $pattern, $levels);\n        }\n\n        return $list;\n    }\n\n    /**\n     * Return all locations for all the files with a timestamp.\n     *\n     * @param  array  $paths    List of folders to look from.\n     * @param  string $pattern  Pattern to match the file. Pattern will also be removed from the key.\n     * @param  int    $levels   Maximum number of recursive directories.\n     * @return array\n     */\n    public function getFiles(array $paths, $pattern = '|\\.yaml$|', $levels = -1)\n    {\n        $list = [];\n        foreach ($paths as $folder) {\n            $path = trim(Folder::getRelativePath($folder), '/');\n\n            $files = $this->detectRecursive($folder, $pattern, $levels);\n\n            $list += $files[trim($path, '/')];\n        }\n\n        return $list;\n    }\n\n    /**\n     * Return all paths for all the files with a timestamp.\n     *\n     * @param  array  $paths    List of folders to look from.\n     * @param  string $pattern  Pattern to match the file. Pattern will also be removed from the key.\n     * @param  int    $levels   Maximum number of recursive directories.\n     * @return array\n     */\n    public function listFiles(array $paths, $pattern = '|\\.yaml$|', $levels = -1)\n    {\n        $list = [];\n        foreach ($paths as $folder) {\n            $list = array_merge_recursive($list, $this->detectAll($folder, $pattern, $levels));\n        }\n\n        return $list;\n    }\n\n    /**\n     * Find filename from a list of folders.\n     *\n     * Note: Only finds the last override.\n     *\n     * @param string $filename\n     * @param array $folders\n     * @return array\n     */\n    public function locateFileInFolder($filename, array $folders)\n    {\n        $list = [];\n        foreach ($folders as $folder) {\n            $list += $this->detectInFolder($folder, $filename);\n        }\n\n        return $list;\n    }\n\n    /**\n     * Find filename from a list of folders.\n     *\n     * @param array $folders\n     * @param string|null $filename\n     * @return array\n     */\n    public function locateInFolders(array $folders, $filename = null)\n    {\n        $list = [];\n        foreach ($folders as $folder) {\n            $path = trim(Folder::getRelativePath($folder), '/');\n            $list[$path] = $this->detectInFolder($folder, $filename);\n        }\n\n        return $list;\n    }\n\n    /**\n     * Return all existing locations for a single file with a timestamp.\n     *\n     * @param  array  $paths   Filesystem paths to look up from.\n     * @param  string $name    Configuration file to be located.\n     * @param  string $ext     File extension (optional, defaults to .yaml).\n     * @return array\n     */\n    public function locateFile(array $paths, $name, $ext = '.yaml')\n    {\n        $filename = preg_replace('|[.\\/]+|', '/', $name) . $ext;\n\n        $list = [];\n        foreach ($paths as $folder) {\n            $path = trim(Folder::getRelativePath($folder), '/');\n\n            if (is_file(\"{$folder}/{$filename}\")) {\n                $modified = filemtime(\"{$folder}/{$filename}\");\n            } else {\n                $modified = 0;\n            }\n            $basename = $this->base . $name;\n            $list[$path] = [$basename => ['file' => \"{$path}/{$filename}\", 'modified' => $modified]];\n        }\n\n        return $list;\n    }\n\n    /**\n     * Detects all directories with a configuration file and returns them with last modification time.\n     *\n     * @param  string $folder   Location to look up from.\n     * @param  string $pattern  Pattern to match the file. Pattern will also be removed from the key.\n     * @param  int    $levels   Maximum number of recursive directories.\n     * @return array\n     * @internal\n     */\n    protected function detectRecursive($folder, $pattern, $levels)\n    {\n        $path = trim(Folder::getRelativePath($folder), '/');\n\n        if (is_dir($folder)) {\n            // Find all system and user configuration files.\n            $options = [\n                'levels'  => $levels,\n                'compare' => 'Filename',\n                'pattern' => $pattern,\n                'filters' => [\n                    'pre-key' => $this->base,\n                    'key' => $pattern,\n                    'value' => function (RecursiveDirectoryIterator $file) use ($path) {\n                        return ['file' => \"{$path}/{$file->getSubPathname()}\", 'modified' => $file->getMTime()];\n                    }\n                ],\n                'key' => 'SubPathname'\n            ];\n\n            $list = Folder::all($folder, $options);\n\n            ksort($list);\n        } else {\n            $list = [];\n        }\n\n        return [$path => $list];\n    }\n\n    /**\n     * Detects all directories with the lookup file and returns them with last modification time.\n     *\n     * @param  string $folder Location to look up from.\n     * @param  string|null $lookup Filename to be located (defaults to directory name).\n     * @return array\n     * @internal\n     */\n    protected function detectInFolder($folder, $lookup = null)\n    {\n        $folder = rtrim($folder, '/');\n        $path = trim(Folder::getRelativePath($folder), '/');\n        $base = $path === $folder ? '' : ($path ? substr($folder, 0, -strlen($path)) : $folder . '/');\n\n        $list = [];\n\n        if (is_dir($folder)) {\n            $iterator = new DirectoryIterator($folder);\n            foreach ($iterator as $directory) {\n                if (!$directory->isDir() || $directory->isDot()) {\n                    continue;\n                }\n\n                $name = $directory->getFilename();\n                $find = ($lookup ?: $name) . '.yaml';\n                $filename = \"{$path}/{$name}/{$find}\";\n\n                if (file_exists($base . $filename)) {\n                    $basename = $this->base . $name;\n                    $list[$basename] = ['file' => $filename, 'modified' => filemtime($base . $filename)];\n                }\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * Detects all plugins with a configuration file and returns them with last modification time.\n     *\n     * @param  string $folder   Location to look up from.\n     * @param  string $pattern  Pattern to match the file. Pattern will also be removed from the key.\n     * @param  int    $levels   Maximum number of recursive directories.\n     * @return array\n     * @internal\n     */\n    protected function detectAll($folder, $pattern, $levels)\n    {\n        $path = trim(Folder::getRelativePath($folder), '/');\n\n        if (is_dir($folder)) {\n            // Find all system and user configuration files.\n            $options = [\n                'levels'  => $levels,\n                'compare' => 'Filename',\n                'pattern' => $pattern,\n                'filters' => [\n                    'pre-key' => $this->base,\n                    'key' => $pattern,\n                    'value' => function (RecursiveDirectoryIterator $file) use ($path) {\n                        return [\"{$path}/{$file->getSubPathname()}\" => $file->getMTime()];\n                    }\n                ],\n                'key' => 'SubPathname'\n            ];\n\n            $list = Folder::all($folder, $options);\n\n            ksort($list);\n        } else {\n            $list = [];\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Config/Languages.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Config\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Config;\n\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Utils;\n\n/**\n * Class Languages\n * @package Grav\\Common\\Config\n */\nclass Languages extends Data\n{\n    /** @var string|null */\n    protected $checksum;\n\n    /** @var bool */\n    protected $modified = false;\n\n    /** @var int */\n    protected $timestamp = 0;\n\n    /**\n     * @param string|null $checksum\n     * @return string|null\n     */\n    public function checksum($checksum = null)\n    {\n        if ($checksum !== null) {\n            $this->checksum = $checksum;\n        }\n\n        return $this->checksum;\n    }\n\n    /**\n     * @param bool|null $modified\n     * @return bool\n     */\n    public function modified($modified = null)\n    {\n        if ($modified !== null) {\n            $this->modified = $modified;\n        }\n\n        return $this->modified;\n    }\n\n    /**\n     * @param int|null $timestamp\n     * @return int\n     */\n    public function timestamp($timestamp = null)\n    {\n        if ($timestamp !== null) {\n            $this->timestamp = $timestamp;\n        }\n\n        return $this->timestamp;\n    }\n\n    /**\n     * @return void\n     */\n    public function reformat()\n    {\n        if (isset($this->items['plugins'])) {\n            $this->items = array_merge_recursive($this->items, $this->items['plugins']);\n            unset($this->items['plugins']);\n        }\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    public function mergeRecursive(array $data)\n    {\n        $this->items = Utils::arrayMergeRecursiveUnique($this->items, $data);\n    }\n\n    /**\n     * @param string $lang\n     * @return array\n     */\n    public function flattenByLang($lang)\n    {\n        $language = $this->items[$lang];\n        return Utils::arrayFlattenDotNotation($language);\n    }\n\n    /**\n     * @param array $array\n     * @return array\n     */\n    public function unflatten($array)\n    {\n        return Utils::arrayUnflattenDotNotation($array);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Config/Setup.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Config\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Config;\n\nuse BadMethodCallException;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Utils;\nuse InvalidArgumentException;\nuse Pimple\\Container;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function defined;\nuse function is_array;\n\n/**\n * Class Setup\n * @package Grav\\Common\\Config\n */\nclass Setup extends Data\n{\n    /**\n     * @var array Environment aliases normalized to lower case.\n     */\n    public static $environments = [\n        '' => 'unknown',\n        '127.0.0.1' => 'localhost',\n        '::1' => 'localhost'\n    ];\n\n    /**\n     * @var string|null Current environment normalized to lower case.\n     */\n    public static $environment;\n\n    /** @var string */\n    public static $securityFile = 'config://security.yaml';\n\n    /** @var array */\n    protected $streams = [\n        'user' => [\n            'type' => 'ReadOnlyStream',\n            'force' => true,\n            'prefixes' => [\n                '' => [] // Set in constructor\n            ]\n        ],\n        'cache' => [\n            'type' => 'Stream',\n            'force' => true,\n            'prefixes' => [\n                '' => [], // Set in constructor\n                'images' => ['images']\n            ]\n        ],\n        'log' => [\n            'type' => 'Stream',\n            'force' => true,\n            'prefixes' => [\n                '' => [] // Set in constructor\n            ]\n        ],\n        'tmp' => [\n            'type' => 'Stream',\n            'force' => true,\n            'prefixes' => [\n                '' => [] // Set in constructor\n            ]\n        ],\n        'backup' => [\n            'type' => 'Stream',\n            'force' => true,\n            'prefixes' => [\n                '' => [] // Set in constructor\n            ]\n        ],\n        'environment' => [\n            'type' => 'ReadOnlyStream'\n            // If not defined, environment will be set up in the constructor.\n        ],\n        'system' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['system'],\n            ]\n        ],\n        'asset' => [\n            'type' => 'Stream',\n            'prefixes' => [\n                '' => ['assets'],\n            ]\n        ],\n        'blueprints' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['environment://blueprints', 'user://blueprints', 'system://blueprints'],\n            ]\n        ],\n        'config' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['environment://config', 'user://config', 'system://config'],\n            ]\n        ],\n        'plugins' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['user://plugins'],\n             ]\n        ],\n        'plugin' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['user://plugins'],\n            ]\n        ],\n        'themes' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['user://themes'],\n            ]\n        ],\n        'languages' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['environment://languages', 'user://languages', 'system://languages'],\n            ]\n        ],\n        'image' => [\n            'type' => 'Stream',\n            'prefixes' => [\n                '' => ['user://images', 'system://images']\n            ]\n        ],\n        'page' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['user://pages']\n            ]\n        ],\n        'user-data' => [\n            'type' => 'Stream',\n            'force' => true,\n            'prefixes' => [\n                '' => ['user://data']\n            ]\n        ],\n        'account' => [\n            'type' => 'ReadOnlyStream',\n            'prefixes' => [\n                '' => ['user://accounts']\n            ]\n        ],\n    ];\n\n    /**\n     * @param Container|array $container\n     */\n    public function __construct($container)\n    {\n        // Configure main streams.\n        $abs = str_starts_with(GRAV_SYSTEM_PATH, '/');\n        $this->streams['system']['prefixes'][''] = $abs ? ['system', GRAV_SYSTEM_PATH] : ['system'];\n        $this->streams['user']['prefixes'][''] = [GRAV_USER_PATH];\n        $this->streams['cache']['prefixes'][''] = [GRAV_CACHE_PATH];\n        $this->streams['log']['prefixes'][''] = [GRAV_LOG_PATH];\n        $this->streams['tmp']['prefixes'][''] = [GRAV_TMP_PATH];\n        $this->streams['backup']['prefixes'][''] = [GRAV_BACKUP_PATH];\n\n        // If environment is not set, look for the environment variable and then the constant.\n        $environment = static::$environment ??\n            (defined('GRAV_ENVIRONMENT') ? GRAV_ENVIRONMENT : (getenv('GRAV_ENVIRONMENT') ?: null));\n\n        // If no environment is set, make sure we get one (CLI or hostname).\n        if (null === $environment) {\n            if (defined('GRAV_CLI')) {\n                $request = null;\n                $uri = null;\n                $environment = 'cli';\n            } else {\n                /** @var ServerRequestInterface $request */\n                $request = $container['request'];\n                $uri = $request->getUri();\n                $environment = $uri->getHost();\n            }\n        }\n\n        // Resolve server aliases to the proper environment.\n        static::$environment = static::$environments[$environment] ?? $environment;\n\n        // Pre-load setup.php which contains our initial configuration.\n        // Configuration may contain dynamic parts, which is why we need to always load it.\n        // If GRAV_SETUP_PATH has been defined, use it, otherwise use defaults.\n        $setupFile = defined('GRAV_SETUP_PATH') ? GRAV_SETUP_PATH : (getenv('GRAV_SETUP_PATH') ?: null);\n        if (null !== $setupFile) {\n            // Make sure that the custom setup file exists. Terminates the script if not.\n            if (!str_starts_with($setupFile, '/')) {\n                $setupFile = GRAV_WEBROOT . '/' . $setupFile;\n            }\n            if (!is_file($setupFile)) {\n                echo 'GRAV_SETUP_PATH is defined but does not point to existing setup file.';\n                exit(1);\n            }\n        } else {\n            $setupFile = GRAV_WEBROOT . '/setup.php';\n            if (!is_file($setupFile)) {\n                $setupFile = GRAV_WEBROOT . '/' . GRAV_USER_PATH . '/setup.php';\n            }\n            if (!is_file($setupFile)) {\n                $setupFile = null;\n            }\n        }\n        $setup = $setupFile ? (array) include $setupFile : [];\n\n        // Add default streams defined in beginning of the class.\n        if (!isset($setup['streams']['schemes'])) {\n            $setup['streams']['schemes'] = [];\n        }\n        $setup['streams']['schemes'] += $this->streams;\n\n        // Initialize class.\n        parent::__construct($setup);\n\n        $this->def('environment', static::$environment);\n\n        // Figure out path for the current environment.\n        $envPath = defined('GRAV_ENVIRONMENT_PATH') ? GRAV_ENVIRONMENT_PATH : (getenv('GRAV_ENVIRONMENT_PATH') ?: null);\n        if (null === $envPath) {\n            // Find common path for all environments and append current environment into it.\n            $envPath = defined('GRAV_ENVIRONMENTS_PATH') ? GRAV_ENVIRONMENTS_PATH : (getenv('GRAV_ENVIRONMENTS_PATH') ?: null);\n            if (null !== $envPath) {\n                $envPath .= '/';\n            } else {\n                // Use default location. Start with Grav 1.7 default.\n                $envPath = GRAV_WEBROOT. '/' . GRAV_USER_PATH . '/env';\n                if (is_dir($envPath)) {\n                    $envPath = 'user://env/';\n                } else {\n                    // Fallback to Grav 1.6 default.\n                    $envPath = 'user://';\n                }\n            }\n            $envPath .= $this->get('environment');\n        }\n\n        // Set up environment.\n        $this->def('environment', static::$environment);\n        $this->def('streams.schemes.environment.prefixes', ['' => [$envPath]]);\n    }\n\n    /**\n     * @return $this\n     * @throws RuntimeException\n     * @throws InvalidArgumentException\n     */\n    public function init()\n    {\n        $locator = new UniformResourceLocator(GRAV_WEBROOT);\n        $files = [];\n\n        $guard = 5;\n        do {\n            $check = $files;\n            $this->initializeLocator($locator);\n            $files = $locator->findResources('config://streams.yaml');\n\n            if ($check === $files) {\n                break;\n            }\n\n            // Update streams.\n            foreach (array_reverse($files) as $path) {\n                $file = CompiledYamlFile::instance($path);\n                $content = (array)$file->content();\n                if (!empty($content['schemes'])) {\n                    $this->items['streams']['schemes'] = $content['schemes'] + $this->items['streams']['schemes'];\n                }\n            }\n        } while (--$guard);\n\n        if (!$guard) {\n            throw new RuntimeException('Setup: Configuration reload loop detected!');\n        }\n\n        // Make sure we have valid setup.\n        $this->check($locator);\n\n        return $this;\n    }\n\n    /**\n     * Initialize resource locator by using the configuration.\n     *\n     * @param UniformResourceLocator $locator\n     * @return void\n     * @throws BadMethodCallException\n     */\n    public function initializeLocator(UniformResourceLocator $locator)\n    {\n        $locator->reset();\n\n        $schemes = (array) $this->get('streams.schemes', []);\n\n        foreach ($schemes as $scheme => $config) {\n            if (isset($config['paths'])) {\n                $locator->addPath($scheme, '', $config['paths']);\n            }\n\n            $override = $config['override'] ?? false;\n            $force = $config['force'] ?? false;\n\n            if (isset($config['prefixes'])) {\n                foreach ((array)$config['prefixes'] as $prefix => $paths) {\n                    $locator->addPath($scheme, $prefix, $paths, $override, $force);\n                }\n            }\n        }\n    }\n\n    /**\n     * Get available streams and their types from the configuration.\n     *\n     * @return array\n     */\n    public function getStreams()\n    {\n        $schemes = [];\n        foreach ((array) $this->get('streams.schemes') as $scheme => $config) {\n            $type = $config['type'] ?? 'ReadOnlyStream';\n            if ($type[0] !== '\\\\') {\n                $type = '\\\\RocketTheme\\\\Toolbox\\\\StreamWrapper\\\\' . $type;\n            }\n\n            $schemes[$scheme] = $type;\n        }\n\n        return $schemes;\n    }\n\n    /**\n     * @param UniformResourceLocator $locator\n     * @return void\n     * @throws InvalidArgumentException\n     * @throws BadMethodCallException\n     * @throws RuntimeException\n     */\n    protected function check(UniformResourceLocator $locator)\n    {\n        $streams = $this->items['streams']['schemes'] ?? null;\n        if (!is_array($streams)) {\n            throw new InvalidArgumentException('Configuration is missing streams.schemes!');\n        }\n        $diff = array_keys(array_diff_key($this->streams, $streams));\n        if ($diff) {\n            throw new InvalidArgumentException(\n                sprintf('Configuration is missing keys %s from streams.schemes!', implode(', ', $diff))\n            );\n        }\n\n        try {\n            // If environment is found, remove all missing override locations (B/C compatibility).\n            if ($locator->findResource('environment://', true)) {\n                $force = $this->get('streams.schemes.environment.force', false);\n                if (!$force) {\n                    $prefixes = $this->get('streams.schemes.environment.prefixes.');\n                    $update = false;\n                    foreach ($prefixes as $i => $prefix) {\n                        if ($locator->isStream($prefix)) {\n                            if ($locator->findResource($prefix, true)) {\n                                break;\n                            }\n                        } elseif (file_exists($prefix)) {\n                            break;\n                        }\n\n                        unset($prefixes[$i]);\n                        $update = true;\n                    }\n\n                    if ($update) {\n                        $this->set('streams.schemes.environment.prefixes', ['' => array_values($prefixes)]);\n                        $this->initializeLocator($locator);\n                    }\n                }\n            }\n\n            if (!$locator->findResource('environment://config', true)) {\n                // If environment does not have its own directory, remove it from the lookup.\n                $prefixes = $this->get('streams.schemes.environment.prefixes');\n                $prefixes['config'] = [];\n\n                $this->set('streams.schemes.environment.prefixes', $prefixes);\n                $this->initializeLocator($locator);\n            }\n\n            // Create security.yaml salt if it doesn't exist into existing configuration environment if possible.\n            $securityFile = Utils::basename(static::$securityFile);\n            $securityFolder = substr(static::$securityFile, 0, -\\strlen($securityFile));\n            $securityFolder = $locator->findResource($securityFolder, true) ?: $locator->findResource($securityFolder, true, true);\n            $filename = \"{$securityFolder}/{$securityFile}\";\n\n            $security_file = CompiledYamlFile::instance($filename);\n            $security_content = (array)$security_file->content();\n\n            if (!isset($security_content['salt'])) {\n                $security_content = array_merge($security_content, ['salt' => Utils::generateRandomString(14)]);\n                $security_file->content($security_content);\n                $security_file->save();\n                $security_file->free();\n            }\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf('Grav failed to initialize: %s', $e->getMessage()), 500, $e);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Data/Blueprint.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Data\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Data;\n\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse RocketTheme\\Toolbox\\Blueprints\\BlueprintForm;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function call_user_func_array;\nuse function count;\nuse function function_exists;\nuse function in_array;\nuse function is_array;\nuse function is_int;\nuse function is_object;\nuse function is_string;\nuse function strlen;\n\n/**\n * Class Blueprint\n * @package Grav\\Common\\Data\n */\nclass Blueprint extends BlueprintForm\n{\n    /** @var string */\n    protected $context = 'blueprints://';\n\n    /** @var string|null */\n    protected $scope;\n\n    /** @var BlueprintSchema|null */\n    protected $blueprintSchema;\n\n    /** @var object|null */\n    protected $object;\n\n    /** @var array|null */\n    protected $defaults;\n\n    /** @var array */\n    protected $handlers = [];\n\n    /**\n     * Clone blueprint.\n     */\n    public function __clone()\n    {\n        if (null !== $this->blueprintSchema) {\n            $this->blueprintSchema = clone $this->blueprintSchema;\n        }\n    }\n\n    /**\n     * @param string $scope\n     * @return void\n     */\n    public function setScope($scope)\n    {\n        $this->scope = $scope;\n    }\n\n    /**\n     * @param object $object\n     * @return void\n     */\n    public function setObject($object)\n    {\n        $this->object = $object;\n    }\n\n    /**\n     * Set default values for field types.\n     *\n     * @param array $types\n     * @return $this\n     */\n    public function setTypes(array $types)\n    {\n        $this->initInternals();\n\n        $this->blueprintSchema->setTypes($types);\n\n        return $this;\n    }\n\n    /**\n     * @param string $name\n     * @return array|mixed|null\n     * @since 1.7\n     */\n    public function getDefaultValue(string $name)\n    {\n        $path = explode('.', $name);\n        $current = $this->getDefaults();\n\n        foreach ($path as $field) {\n            if (is_object($current) && isset($current->{$field})) {\n                $current = $current->{$field};\n            } elseif (is_array($current) && isset($current[$field])) {\n                $current = $current[$field];\n            } else {\n                return null;\n            }\n        }\n\n        return $current;\n    }\n\n    /**\n     * Get nested structure containing default values defined in the blueprints.\n     *\n     * Fields without default value are ignored in the list.\n     *\n     * @return array\n     */\n    public function getDefaults()\n    {\n        $this->initInternals();\n\n        if (null === $this->defaults) {\n            $this->defaults = $this->blueprintSchema->getDefaults();\n        }\n\n        return $this->defaults;\n    }\n\n    /**\n     * Initialize blueprints with its dynamic fields.\n     *\n     * @return $this\n     */\n    public function init()\n    {\n        foreach ($this->dynamic as $key => $data) {\n            // Locate field.\n            $path = explode('/', $key);\n            $current = &$this->items;\n\n            foreach ($path as $field) {\n                if (is_object($current)) {\n                    // Handle objects.\n                    if (!isset($current->{$field})) {\n                        $current->{$field} = [];\n                    }\n\n                    $current = &$current->{$field};\n                } else {\n                    // Handle arrays and scalars.\n                    if (!is_array($current)) {\n                        $current = [$field => []];\n                    } elseif (!isset($current[$field])) {\n                        $current[$field] = [];\n                    }\n\n                    $current = &$current[$field];\n                }\n            }\n\n            // Set dynamic property.\n            foreach ($data as $property => $call) {\n                $action = $call['action'];\n                $method = 'dynamic' . ucfirst($action);\n                $call['object'] = $this->object;\n\n                if (isset($this->handlers[$action])) {\n                    $callable = $this->handlers[$action];\n                    $callable($current, $property, $call);\n                } elseif (method_exists($this, $method)) {\n                    $this->{$method}($current, $property, $call);\n                }\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * Extend blueprint with another blueprint.\n     *\n     * @param BlueprintForm|array $extends\n     * @param bool $append\n     * @return $this\n     */\n    public function extend($extends, $append = false)\n    {\n        parent::extend($extends, $append);\n\n        $this->deepInit($this->items);\n\n        return $this;\n    }\n\n    /**\n     * @param string $name\n     * @param mixed $value\n     * @param string $separator\n     * @param bool $append\n     * @return $this\n     */\n    public function embed($name, $value, $separator = '/', $append = false)\n    {\n        parent::embed($name, $value, $separator, $append);\n\n        $this->deepInit($this->items);\n\n        return $this;\n    }\n\n    /**\n     * Merge two arrays by using blueprints.\n     *\n     * @param  array $data1\n     * @param  array $data2\n     * @param  string|null $name         Optional\n     * @param  string $separator    Optional\n     * @return array\n     */\n    public function mergeData(array $data1, array $data2, $name = null, $separator = '.')\n    {\n        $this->initInternals();\n\n        return $this->blueprintSchema->mergeData($data1, $data2, $name, $separator);\n    }\n\n    /**\n     * Process data coming from a form.\n     *\n     * @param array $data\n     * @param array $toggles\n     * @return array\n     */\n    public function processForm(array $data, array $toggles = [])\n    {\n        $this->initInternals();\n\n        return $this->blueprintSchema->processForm($data, $toggles);\n    }\n\n    /**\n     * Return data fields that do not exist in blueprints.\n     *\n     * @param  array  $data\n     * @param  string $prefix\n     * @return array\n     */\n    public function extra(array $data, $prefix = '')\n    {\n        $this->initInternals();\n\n        return $this->blueprintSchema->extra($data, $prefix);\n    }\n\n    /**\n     * Validate data against blueprints.\n     *\n     * @param  array $data\n     * @param  array $options\n     * @return void\n     * @throws RuntimeException\n     */\n    public function validate(array $data, array $options = [])\n    {\n        $this->initInternals();\n\n        $this->blueprintSchema->validate($data, $options);\n    }\n\n    /**\n     * Filter data by using blueprints.\n     *\n     * @param  array $data\n     * @param  bool $missingValuesAsNull\n     * @param  bool $keepEmptyValues\n     * @return array\n     */\n    public function filter(array $data, bool $missingValuesAsNull = false, bool $keepEmptyValues = false)\n    {\n        $this->initInternals();\n\n        return $this->blueprintSchema->filter($data, $missingValuesAsNull, $keepEmptyValues) ?? [];\n    }\n\n\n    /**\n     * Flatten data by using blueprints.\n     *\n     * @param array $data       Data to be flattened.\n     * @param bool $includeAll  True if undefined properties should also be included.\n     * @param string $name      Property which will be flattened, useful for flattening repeating data.\n     * @return array\n     */\n    public function flattenData(array $data, bool $includeAll = false, string $name = '')\n    {\n        $this->initInternals();\n\n        return $this->blueprintSchema->flattenData($data, $includeAll, $name);\n    }\n\n\n    /**\n     * Return blueprint data schema.\n     *\n     * @return BlueprintSchema\n     */\n    public function schema()\n    {\n        $this->initInternals();\n\n        return $this->blueprintSchema;\n    }\n\n    /**\n     * @param string $name\n     * @param callable $callable\n     * @return void\n     */\n    public function addDynamicHandler(string $name, callable $callable): void\n    {\n        $this->handlers[$name] = $callable;\n    }\n\n    /**\n     * Initialize validator.\n     *\n     * @return void\n     */\n    protected function initInternals()\n    {\n        if (null === $this->blueprintSchema) {\n            $types = Grav::instance()['plugins']->formFieldTypes;\n\n            $this->blueprintSchema = new BlueprintSchema;\n\n            if ($types) {\n                $this->blueprintSchema->setTypes($types);\n            }\n\n            $this->blueprintSchema->embed('', $this->items);\n            $this->blueprintSchema->init();\n            $this->defaults = null;\n        }\n    }\n\n    /**\n     * @param string $filename\n     * @return array\n     */\n    protected function loadFile($filename)\n    {\n        $file = CompiledYamlFile::instance($filename);\n        $content = (array)$file->content();\n        $file->free();\n\n        return $content;\n    }\n\n    /**\n     * @param string|array $path\n     * @param string|null $context\n     * @return array\n     */\n    protected function getFiles($path, $context = null)\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n\n        if (is_string($path) && !$locator->isStream($path)) {\n            if (is_file($path)) {\n                return [$path];\n            }\n\n            // Find path overrides.\n            if (null === $context) {\n                $paths = (array) ($this->overrides[$path] ?? null);\n            } else {\n                $paths = [];\n            }\n\n            // Add path pointing to default context.\n            if ($context === null) {\n                $context = $this->context;\n            }\n\n            if ($context && $context[strlen($context)-1] !== '/') {\n                $context .= '/';\n            }\n\n            $path = $context . $path;\n\n            if (!preg_match('/\\.yaml$/', $path)) {\n                $path .= '.yaml';\n            }\n\n            $paths[] = $path;\n        } else {\n            $paths = (array) $path;\n        }\n\n        $files = [];\n        foreach ($paths as $lookup) {\n            if (is_string($lookup) && strpos($lookup, '://')) {\n                $files = array_merge($files, $locator->findResources($lookup));\n            } else {\n                $files[] = $lookup;\n            }\n        }\n\n        return array_values(array_unique($files));\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param array $call\n     * @return void\n     */\n    protected function dynamicData(array &$field, $property, array &$call)\n    {\n        $params = $call['params'];\n\n        if (is_array($params)) {\n            $function = array_shift($params);\n        } else {\n            $function = $params;\n            $params = [];\n        }\n\n        [$o, $f] = explode('::', $function, 2);\n\n        $data = null;\n        if (!$f) {\n            if (function_exists($o)) {\n                $data = call_user_func_array($o, $params);\n            }\n        } else {\n            if (method_exists($o, $f)) {\n                $data = call_user_func_array([$o, $f], $params);\n            }\n        }\n\n        // If function returns a value,\n        if (null !== $data) {\n            if (is_array($data) && isset($field[$property]) && is_array($field[$property])) {\n                // Combine field and @data-field together.\n                $field[$property] += $data;\n            } else {\n                // Or create/replace field with @data-field.\n                $field[$property] = $data;\n            }\n        }\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param array $call\n     * @return void\n     */\n    protected function dynamicConfig(array &$field, $property, array &$call)\n    {\n        $params = $call['params'];\n        if (is_array($params)) {\n            $value = array_shift($params);\n            $params = array_shift($params);\n        } else {\n            $value = $params;\n            $params = [];\n        }\n\n        $default = $field[$property] ?? null;\n        $config = Grav::instance()['config']->get($value, $default);\n        if (!empty($field['value_only'])) {\n            $config = array_combine($config, $config);\n        }\n\n        if (null !== $config) {\n            if (!empty($params['append']) && is_array($config) && isset($field[$property]) && is_array($field[$property])) {\n                // Combine field and @config-field together.\n                $field[$property] += $config;\n            } else {\n                // Or create/replace field with @config-field.\n                $field[$property] = $config;\n            }\n        }\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param array $call\n     * @return void\n     */\n    protected function dynamicSecurity(array &$field, $property, array &$call)\n    {\n        if ($property || !empty($field['validate']['ignore'])) {\n            return;\n        }\n\n        $grav = Grav::instance();\n        $actions = (array)$call['params'];\n\n        /** @var UserInterface|null $user */\n        $user = $grav['user'] ?? null;\n        $success = null !== $user;\n        if ($success) {\n            $success = $this->resolveActions($user, $actions);\n        }\n        if (!$success) {\n            static::addPropertyRecursive($field, 'validate', ['ignore' => true]);\n        }\n    }\n\n    /**\n     * @param UserInterface|null $user\n     * @param array $actions\n     * @param string $op\n     * @return bool\n     */\n    protected function resolveActions(?UserInterface $user, array $actions, string $op = 'and')\n    {\n        if (null === $user) {\n            return false;\n        }\n\n        $c = $i = count($actions);\n        foreach ($actions as $key => $action) {\n            if (!is_int($key) && is_array($actions)) {\n                $i -= $this->resolveActions($user, $action, $key);\n            } elseif ($user->authorize($action)) {\n                $i--;\n            }\n        }\n\n        if ($op === 'and') {\n            return $i === 0;\n        }\n\n        return $c !== $i;\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param array $call\n     * @return void\n     */\n    protected function dynamicScope(array &$field, $property, array &$call)\n    {\n        if ($property && $property !== 'ignore') {\n            return;\n        }\n\n        $scopes = (array)$call['params'];\n        $matches = in_array($this->scope, $scopes, true);\n        if ($this->scope && $property !== 'ignore') {\n            $matches = !$matches;\n        }\n\n        if ($matches) {\n            static::addPropertyRecursive($field, 'validate', ['ignore' => true]);\n            return;\n        }\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param mixed $value\n     * @return void\n     */\n    public static function addPropertyRecursive(array &$field, $property, $value)\n    {\n        if (is_array($value) && isset($field[$property]) && is_array($field[$property])) {\n            $field[$property] = array_merge_recursive($field[$property], $value);\n        } else {\n            $field[$property] = $value;\n        }\n\n        if (!empty($field['fields'])) {\n            foreach ($field['fields'] as $key => &$child) {\n                static::addPropertyRecursive($child, $property, $value);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Data/BlueprintSchema.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Data\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Data;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Export;\nuse RocketTheme\\Toolbox\\ArrayTraits\\ExportInterface;\nuse RocketTheme\\Toolbox\\Blueprints\\BlueprintSchema as BlueprintSchemaBase;\nuse RuntimeException;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class BlueprintSchema\n * @package Grav\\Common\\Data\n */\nclass BlueprintSchema extends BlueprintSchemaBase implements ExportInterface\n{\n    use Export;\n\n    /** @var array */\n    protected $filter = ['validation' => true, 'xss_check' => true];\n\n    /** @var array */\n    protected $ignoreFormKeys = [\n        'title' => true,\n        'help' => true,\n        'placeholder' => true,\n        'placeholder_key' => true,\n        'placeholder_value' => true,\n        'fields' => true\n    ];\n\n    /**\n     * @return array\n     */\n    public function getTypes()\n    {\n        return $this->types;\n    }\n\n    /**\n     * @param string $name\n     * @return array\n     */\n    public function getType($name)\n    {\n        return $this->types[$name] ?? [];\n    }\n\n    /**\n     * @param string $name\n     * @return array|null\n     */\n    public function getNestedRules(string $name)\n    {\n        return $this->getNested($name);\n    }\n\n    /**\n     * Validate data against blueprints.\n     *\n     * @param  array $data\n     * @param  array $options\n     * @return void\n     * @throws RuntimeException\n     */\n    public function validate(array $data, array $options = [])\n    {\n        try {\n            $validation = $this->items['']['form']['validation'] ?? 'loose';\n            $messages = $this->validateArray($data, $this->nested, $validation === 'strict', $options['xss_check'] ?? true);\n        } catch (RuntimeException $e) {\n            throw (new ValidationException($e->getMessage(), $e->getCode(), $e))->setMessages();\n        }\n\n        if (!empty($messages)) {\n            throw (new ValidationException('', 400))->setMessages($messages);\n        }\n    }\n\n    /**\n     * @param array $data\n     * @param array $toggles\n     * @return array\n     */\n    public function processForm(array $data, array $toggles = [])\n    {\n        return $this->processFormRecursive($data, $toggles, $this->nested) ?? [];\n    }\n\n    /**\n     * Filter data by using blueprints.\n     *\n     * @param  array $data                  Incoming data, for example from a form.\n     * @param  bool  $missingValuesAsNull   Include missing values as nulls.\n     * @param bool   $keepEmptyValues       Include empty values.\n     * @return array\n     */\n    public function filter(array $data, $missingValuesAsNull = false, $keepEmptyValues = false)\n    {\n        $this->buildIgnoreNested($this->nested);\n\n        return $this->filterArray($data, $this->nested, '', $missingValuesAsNull, $keepEmptyValues) ?? [];\n    }\n\n    /**\n     * Flatten data by using blueprints.\n     *\n     * @param array $data       Data to be flattened.\n     * @param bool $includeAll  True if undefined properties should also be included.\n     * @param string $name      Property which will be flattened, useful for flattening repeating data.\n     * @return array\n     */\n    public function flattenData(array $data, bool $includeAll = false, string $name = '')\n    {\n        $prefix = $name !== '' ? $name . '.' : '';\n\n        $list = [];\n        if ($includeAll) {\n            $items = $name !== '' ? $this->getProperty($name)['fields'] ?? [] : $this->items;\n            foreach ($items as $key => $rules) {\n                $type = $rules['type'] ?? '';\n                $ignore = (bool) array_filter((array)($rules['validate']['ignore'] ?? [])) ?? false;\n                if (!str_starts_with($type, '_') && !str_contains($key, '*') && $ignore !== true) {\n                    $list[$prefix . $key] = null;\n                }\n            }\n        }\n\n        $nested = $this->getNestedRules($name);\n\n        return array_replace($list, $this->flattenArray($data, $nested, $prefix));\n    }\n\n    /**\n     * @param array $data\n     * @param array $rules\n     * @param string $prefix\n     * @return array\n     */\n    protected function flattenArray(array $data, array $rules, string $prefix)\n    {\n        $array = [];\n\n        foreach ($data as $key => $field) {\n            $val = $rules[$key] ?? $rules['*'] ?? null;\n            $rule = is_string($val) ? $this->items[$val] : null;\n\n            if ($rule || isset($val['*'])) {\n                // Item has been defined in blueprints.\n                $array[$prefix.$key] = $field;\n            } elseif (is_array($field) && is_array($val)) {\n                // Array has been defined in blueprints.\n                $array += $this->flattenArray($field, $val, $prefix . $key . '.');\n            } else {\n                // Undefined/extra item.\n                $array[$prefix.$key] = $field;\n            }\n        }\n\n        return $array;\n    }\n\n    /**\n     * @param array $data\n     * @param array $rules\n     * @param bool $strict\n     * @param bool $xss\n     * @return array\n     * @throws RuntimeException\n     */\n    protected function validateArray(array $data, array $rules, bool $strict, bool $xss = true)\n    {\n        $messages = $this->checkRequired($data, $rules);\n\n        foreach ($data as $key => $child) {\n            $val = $rules[$key] ?? $rules['*'] ?? null;\n            $rule = is_string($val) ? $this->items[$val] : null;\n            $checkXss = $xss;\n\n            if ($rule) {\n                // Item has been defined in blueprints.\n                if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) {\n                    // Skip validation in the ignored field.\n                    continue;\n                }\n\n                $messages += Validation::validate($child, $rule);\n\n                if (isset($rule['validate']['match']) || isset($rule['validate']['match_exact']) || isset($rule['validate']['match_any'])) {\n                    $ruleKey = current(array_intersect(['match', 'match_exact', 'match_any'], array_keys($rule['validate'])));\n                    $otherKey = $rule['validate'][$ruleKey] ?? null;\n                    $otherVal = $data[$otherKey] ?? null;\n                    $otherLabel = $this->items[$otherKey]['label'] ?? $otherKey;\n                    $currentVal = $data[$key] ?? null;\n                    $currentLabel = $this->items[$key]['label'] ?? $key;\n\n                    // Determine comparison type (loose, strict, substring)\n                    // Perform comparison:\n                    $isValid = false;\n                    if ($ruleKey === 'match') {\n                        $isValid = ($currentVal == $otherVal);\n                    } elseif ($ruleKey === 'match_exact') {\n                        $isValid = ($currentVal === $otherVal);\n                    } elseif ($ruleKey === 'match_any') {\n                        // If strings:\n                        if (is_string($currentVal) && is_string($otherVal)) {\n                            $isValid = (strlen($currentVal) && strlen($otherVal) && (str_contains($currentVal,\n                                        $otherVal) || strpos($otherVal, $currentVal) !== false));\n                        }\n                        // If arrays:\n                        if (is_array($currentVal) && is_array($otherVal)) {\n                            $common = array_intersect($currentVal, $otherVal);\n                            $isValid = !empty($common);\n                        }\n                    }\n                    if (!$isValid) {\n                        $messages[$rule['name']][] = sprintf(Grav::instance()['language']->translate('PLUGIN_FORM.VALIDATION_MATCH'), $currentLabel, $otherLabel);\n                    }\n                }\n\n            } elseif (is_array($child) && is_array($val)) {\n                // Array has been defined in blueprints.\n                $messages += $this->validateArray($child, $val, $strict);\n                $checkXss = false;\n\n            } elseif ($strict) {\n                // Undefined/extra item in strict mode.\n                /** @var Config $config */\n                $config = Grav::instance()['config'];\n                if (!$config->get('system.strict_mode.blueprint_strict_compat', true)) {\n                    throw new RuntimeException(sprintf('%s is not defined in blueprints', $key), 400);\n                }\n\n                user_error(sprintf('Having extra key %s in your data is deprecated with blueprint having \\'validation: strict\\'', $key), E_USER_DEPRECATED);\n            }\n\n            if ($checkXss) {\n                $messages += Validation::checkSafety($child, $rule ?: ['name' => $key]);\n            }\n        }\n\n        return $messages;\n    }\n\n    /**\n     * @param array $data\n     * @param array $rules\n     * @param string $parent\n     * @param bool  $missingValuesAsNull\n     * @param bool $keepEmptyValues\n     * @return array|null\n     */\n    protected function filterArray(array $data, array $rules, string $parent, bool $missingValuesAsNull, bool $keepEmptyValues)\n    {\n        $results = [];\n\n        foreach ($data as $key => $field) {\n            $val = $rules[$key] ?? $rules['*'] ?? null;\n            $rule = is_string($val) ? $this->items[$val] : $this->items[$parent . $key] ?? null;\n\n            if (!empty($rule['disabled']) || !empty($rule['validate']['ignore'])) {\n                // Skip any data in the ignored field.\n                unset($results[$key]);\n                continue;\n            }\n\n            if (null === $field) {\n                if ($missingValuesAsNull) {\n                    $results[$key] = null;\n                } else {\n                    unset($results[$key]);\n                }\n                continue;\n            }\n\n            $isParent = isset($val['*']);\n            $type = $rule['type'] ?? null;\n\n            if (!$isParent && $type && $type !== '_parent') {\n                $field = Validation::filter($field, $rule);\n            } elseif (is_array($field) && is_array($val)) {\n                // Array has been defined in blueprints.\n                $k = $isParent ? '*' : $key;\n                $field = $this->filterArray($field, $val, $parent . $k . '.', $missingValuesAsNull, $keepEmptyValues);\n\n                if (null === $field) {\n                    // Nested parent has no values.\n                    unset($results[$key]);\n                    continue;\n                }\n            } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') {\n                // Skip any extra data.\n                continue;\n            }\n\n            if ($keepEmptyValues || (null !== $field && (!is_array($field) || !empty($field)))) {\n                $results[$key] = $field;\n            }\n        }\n\n        return $results ?: null;\n    }\n\n    /**\n     * @param array $nested\n     * @param string $parent\n     * @return bool\n     */\n    protected function buildIgnoreNested(array $nested, $parent = '')\n    {\n        $ignore = true;\n        foreach ($nested as $key => $val) {\n            $key = $parent . $key;\n            if (is_array($val)) {\n                $ignore = $this->buildIgnoreNested($val, $key . '.') && $ignore; // Keep the order!\n            } else {\n                $child = $this->items[$key] ?? null;\n                $ignore = $ignore && (!$child || !empty($child['disabled']) || !empty($child['validate']['ignore']));\n            }\n        }\n        if ($ignore) {\n            $key = trim($parent, '.');\n            $this->items[$key]['validate']['ignore'] = true;\n        }\n\n        return $ignore;\n    }\n\n    /**\n     * @param array|null $data\n     * @param array $toggles\n     * @param array $nested\n     * @return array|null\n     */\n    protected function processFormRecursive(?array $data, array $toggles, array $nested)\n    {\n        foreach ($nested as $key => $value) {\n            if ($key === '') {\n                continue;\n            }\n            if ($key === '*') {\n                // TODO: Add support to collections.\n                continue;\n            }\n            if (is_array($value)) {\n                // Special toggle handling for all the nested data.\n                $toggle = $toggles[$key] ?? [];\n                if (!is_array($toggle)) {\n                    if (!$toggle) {\n                        $data[$key] = null;\n\n                        continue;\n                    }\n\n                    $toggle = [];\n                }\n                // Recursively fetch the items.\n                $childData = $data[$key] ?? null;\n                if (null !== $childData && !is_array($childData)) {\n                    throw new \\RuntimeException(sprintf(\"Bad form data for field collection '%s': %s used instead of an array\", $key, gettype($childData)));\n                }\n                $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggle, $value);\n            } else {\n                $field = $this->get($value);\n                // Do not add the field if:\n                if (\n                    // Not an input field\n                    !$field\n                    // Field has been disabled\n                    || !empty($field['disabled'])\n                    // Field validation is set to be ignored\n                    || !empty($field['validate']['ignore'])\n                    // Field is overridable and the toggle is turned off\n                    || (!empty($field['overridable']) && empty($toggles[$key]))\n                ) {\n                    continue;\n                }\n                if (!isset($data[$key])) {\n                    $data[$key] = null;\n                }\n            }\n        }\n\n        return $data;\n    }\n\n    /**\n     * @param array $data\n     * @param array $fields\n     * @return array\n     */\n    protected function checkRequired(array $data, array $fields)\n    {\n        $messages = [];\n\n        foreach ($fields as $name => $field) {\n            if (!is_string($field)) {\n                continue;\n            }\n\n            $field = $this->items[$field];\n\n            // Skip ignored field, it will not be required.\n            if (!empty($field['disabled']) || !empty($field['validate']['ignore'])) {\n                continue;\n            }\n\n            // Skip overridable fields without value.\n            // TODO: We need better overridable support, which is not just ignoring required values but also looking if defaults are good.\n            if (!empty($field['overridable']) && !isset($data[$name])) {\n                continue;\n            }\n\n            // Check if required.\n            if (isset($field['validate']['required'])\n                && $field['validate']['required'] === true) {\n                if (isset($data[$name])) {\n                    continue;\n                }\n                if ($field['type'] === 'file' && isset($data['data']['name'][$name])) { //handle case of file input fields required\n                    continue;\n                }\n\n                $value = $field['label'] ?? $field['name'];\n                $language = Grav::instance()['language'];\n                $message  = sprintf($language->translate('GRAV.FORM.MISSING_REQUIRED_FIELD', null, true) . ' %s', $language->translate($value));\n                $messages[$field['name']][] = $message;\n            }\n        }\n\n        return $messages;\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param array $call\n     * @return void\n     */\n    protected function dynamicConfig(array &$field, $property, array &$call)\n    {\n        $value = $call['params'];\n\n        $default = $field[$property] ?? null;\n        $config = Grav::instance()['config']->get($value, $default);\n\n        if (null !== $config) {\n            $field[$property] = $config;\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Data/Blueprints.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Data\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Data;\n\nuse DirectoryIterator;\nuse Grav\\Common\\Grav;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function is_array;\nuse function is_object;\n\n/**\n * Class Blueprints\n * @package Grav\\Common\\Data\n */\nclass Blueprints\n{\n    /** @var array|string */\n    protected $search;\n    /** @var array */\n    protected $types;\n    /** @var array */\n    protected $instances = [];\n\n    /**\n     * @param  string|array  $search  Search path.\n     */\n    public function __construct($search = 'blueprints://')\n    {\n        $this->search = $search;\n    }\n\n    /**\n     * Get blueprint.\n     *\n     * @param  string  $type  Blueprint type.\n     * @return Blueprint\n     * @throws RuntimeException\n     */\n    public function get($type)\n    {\n        if (!isset($this->instances[$type])) {\n            $blueprint = $this->loadFile($type);\n            $this->instances[$type] = $blueprint;\n        }\n\n        return $this->instances[$type];\n    }\n\n    /**\n     * Get all available blueprint types.\n     *\n     * @return  array  List of type=>name\n     */\n    public function types()\n    {\n        if ($this->types === null) {\n            $this->types = [];\n\n            $grav = Grav::instance();\n\n            /** @var UniformResourceLocator $locator */\n            $locator = $grav['locator'];\n\n            // Get stream / directory iterator.\n            if ($locator->isStream($this->search)) {\n                $iterator = $locator->getIterator($this->search);\n            } else {\n                $iterator = new DirectoryIterator($this->search);\n            }\n\n            foreach ($iterator as $file) {\n                if (!$file->isFile() || '.' . $file->getExtension() !== YAML_EXT) {\n                    continue;\n                }\n                $name = $file->getBasename(YAML_EXT);\n                $this->types[$name] = ucfirst(str_replace('_', ' ', $name));\n            }\n        }\n\n        return $this->types;\n    }\n\n\n    /**\n     * Load blueprint file.\n     *\n     * @param  string  $name  Name of the blueprint.\n     * @return Blueprint\n     */\n    protected function loadFile($name)\n    {\n        $blueprint = new Blueprint($name);\n\n        if (is_array($this->search) || is_object($this->search)) {\n            // Page types.\n            $blueprint->setOverrides($this->search);\n            $blueprint->setContext('blueprints://pages');\n        } else {\n            $blueprint->setContext($this->search);\n        }\n\n        try {\n            $blueprint->load()->init();\n        } catch (RuntimeException $e) {\n            $log = Grav::instance()['log'];\n            $log->error(sprintf('Blueprint %s cannot be loaded: %s', $name, $e->getMessage()));\n\n            throw $e;\n        }\n\n        return $blueprint;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Data/Data.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Data\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Data;\n\nuse ArrayAccess;\nuse Exception;\nuse JsonSerializable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Countable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Export;\nuse RocketTheme\\Toolbox\\ArrayTraits\\ExportInterface;\nuse RocketTheme\\Toolbox\\ArrayTraits\\NestedArrayAccessWithGetters;\nuse RocketTheme\\Toolbox\\File\\FileInterface;\nuse RuntimeException;\nuse function func_get_args;\nuse function is_array;\nuse function is_callable;\nuse function is_object;\n\n/**\n * Class Data\n * @package Grav\\Common\\Data\n */\nclass Data implements DataInterface, ArrayAccess, \\Countable, JsonSerializable, ExportInterface\n{\n    use NestedArrayAccessWithGetters, Countable, Export;\n\n    /** @var string */\n    protected $gettersVariable = 'items';\n    /** @var array */\n    protected $items;\n    /** @var Blueprint|callable|null */\n    protected $blueprints;\n    /** @var FileInterface|null */\n    protected $storage;\n\n    /** @var bool */\n    private $missingValuesAsNull = false;\n    /** @var bool */\n    private $keepEmptyValues = true;\n\n    /**\n     * @param array $items\n     * @param Blueprint|callable|null $blueprints\n     */\n    public function __construct(array $items = [], $blueprints = null)\n    {\n        $this->items = $items;\n        if (null !== $blueprints) {\n            $this->blueprints = $blueprints;\n        }\n    }\n\n    /**\n     * @param bool $value\n     * @return $this\n     */\n    public function setKeepEmptyValues(bool $value)\n    {\n        $this->keepEmptyValues = $value;\n\n        return $this;\n    }\n\n    /**\n     * @param bool $value\n     * @return $this\n     */\n    public function setMissingValuesAsNull(bool $value)\n    {\n        $this->missingValuesAsNull = $value;\n\n        return $this;\n    }\n\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @example $value = $data->value('this.is.my.nested.variable');\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $default    Default value (or null).\n     * @param string  $separator  Separator, defaults to '.'\n     * @return mixed  Value.\n     */\n    public function value($name, $default = null, $separator = '.')\n    {\n        return $this->get($name, $default, $separator);\n    }\n\n    /**\n     * Join nested values together by using blueprints.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $value      Value to be joined.\n     * @param string  $separator  Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function join($name, $value, $separator = '.')\n    {\n        $old = $this->get($name, null, $separator);\n        if ($old !== null) {\n            if (!is_array($old)) {\n                throw new RuntimeException('Value ' . $old);\n            }\n\n            if (is_object($value)) {\n                $value = (array) $value;\n            } elseif (!is_array($value)) {\n                throw new RuntimeException('Value ' . $value);\n            }\n\n            $value = $this->blueprints()->mergeData($old, $value, $name, $separator);\n        }\n\n        $this->set($name, $value, $separator);\n\n        return $this;\n    }\n\n    /**\n     * Get nested structure containing default values defined in the blueprints.\n     *\n     * Fields without default value are ignored in the list.\n\n     * @return array\n     */\n    public function getDefaults()\n    {\n        return $this->blueprints()->getDefaults();\n    }\n\n    /**\n     * Set default values by using blueprints.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $value      Value to be joined.\n     * @param string  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function joinDefaults($name, $value, $separator = '.')\n    {\n        if (is_object($value)) {\n            $value = (array) $value;\n        }\n\n        $old = $this->get($name, null, $separator);\n        if ($old !== null) {\n            $value = $this->blueprints()->mergeData($value, $old, $name, $separator);\n        }\n\n        $this->set($name, $value, $separator);\n\n        return $this;\n    }\n\n    /**\n     * Get value from the configuration and join it with given data.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param array|object $value      Value to be joined.\n     * @param string  $separator  Separator, defaults to '.'\n     * @return array\n     * @throws RuntimeException\n     */\n    public function getJoined($name, $value, $separator = '.')\n    {\n        if (is_object($value)) {\n            $value = (array) $value;\n        } elseif (!is_array($value)) {\n            throw new RuntimeException('Value ' . $value);\n        }\n\n        $old = $this->get($name, null, $separator);\n\n        if ($old === null) {\n            // No value set; no need to join data.\n            return $value;\n        }\n\n        if (!is_array($old)) {\n            throw new RuntimeException('Value ' . $old);\n        }\n\n        // Return joined data.\n        return $this->blueprints()->mergeData($old, $value, $name, $separator);\n    }\n\n\n    /**\n     * Merge two configurations together.\n     *\n     * @param array $data\n     * @return $this\n     */\n    public function merge(array $data)\n    {\n        $this->items = $this->blueprints()->mergeData($this->items, $data);\n\n        return $this;\n    }\n\n    /**\n     * Set default values to the configuration if variables were not set.\n     *\n     * @param array $data\n     * @return $this\n     */\n    public function setDefaults(array $data)\n    {\n        $this->items = $this->blueprints()->mergeData($data, $this->items);\n\n        return $this;\n    }\n\n    /**\n     * Validate by blueprints.\n     *\n     * @return $this\n     * @throws Exception\n     */\n    public function validate()\n    {\n        $this->blueprints()->validate($this->items);\n\n        return $this;\n    }\n\n    /**\n     * @return $this\n     */\n    public function filter()\n    {\n        $args = func_get_args();\n        $missingValuesAsNull = (bool)(array_shift($args) ?? $this->missingValuesAsNull);\n        $keepEmptyValues = (bool)(array_shift($args) ?? $this->keepEmptyValues);\n\n        $this->items = $this->blueprints()->filter($this->items, $missingValuesAsNull, $keepEmptyValues);\n\n        return $this;\n    }\n\n    /**\n     * Get extra items which haven't been defined in blueprints.\n     *\n     * @return array\n     */\n    public function extra()\n    {\n        return $this->blueprints()->extra($this->items);\n    }\n\n    /**\n     * Return blueprints.\n     *\n     * @return Blueprint\n     */\n    public function blueprints()\n    {\n        if (null === $this->blueprints) {\n            $this->blueprints = new Blueprint();\n        } elseif (is_callable($this->blueprints)) {\n            // Lazy load blueprints.\n            $blueprints = $this->blueprints;\n            $this->blueprints = $blueprints();\n        }\n\n        return $this->blueprints;\n    }\n\n    /**\n     * Save data if storage has been defined.\n     *\n     * @return void\n     * @throws RuntimeException\n     */\n    public function save()\n    {\n        $file = $this->file();\n        if ($file) {\n            $file->save($this->items);\n        }\n    }\n\n    /**\n     * Returns whether the data already exists in the storage.\n     *\n     * NOTE: This method does not check if the data is current.\n     *\n     * @return bool\n     */\n    public function exists()\n    {\n        $file = $this->file();\n\n        return $file && $file->exists();\n    }\n\n    /**\n     * Return unmodified data as raw string.\n     *\n     * NOTE: This function only returns data which has been saved to the storage.\n     *\n     * @return string\n     */\n    public function raw()\n    {\n        $file = $this->file();\n\n        return $file ? $file->raw() : '';\n    }\n\n    /**\n     * Set or get the data storage.\n     *\n     * @param FileInterface|null $storage Optionally enter a new storage.\n     * @return FileInterface|null\n     */\n    public function file(FileInterface $storage = null)\n    {\n        if ($storage) {\n            $this->storage = $storage;\n        }\n\n        return $this->storage;\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->items;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Data/DataInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Data\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Data;\n\nuse Exception;\nuse RocketTheme\\Toolbox\\File\\FileInterface;\n\n/**\n * Interface DataInterface\n * @package Grav\\Common\\Data\n */\ninterface DataInterface\n{\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @example $value = $data->value('this.is.my.nested.variable');\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $default    Default value (or null).\n     * @param string  $separator  Separator, defaults to '.'\n     * @return mixed  Value.\n     */\n    public function value($name, $default = null, $separator = '.');\n\n    /**\n     * Merge external data.\n     *\n     * @param array $data\n     * @return mixed\n     */\n    public function merge(array $data);\n\n    /**\n     * Return blueprints.\n     *\n     * @return Blueprint\n     */\n    public function blueprints();\n\n    /**\n     * Validate by blueprints.\n     *\n     * @return $this\n     * @throws Exception\n     */\n    public function validate();\n\n    /**\n     * Filter all items by using blueprints.\n     *\n     * @return $this\n     */\n    public function filter();\n\n    /**\n     * Get extra items which haven't been defined in blueprints.\n     *\n     * @return array\n     */\n    public function extra();\n\n    /**\n     * Save data into the file.\n     *\n     * @return void\n     */\n    public function save();\n\n    /**\n     * Set or get the data storage.\n     *\n     * @param FileInterface|null $storage Optionally enter a new storage.\n     * @return FileInterface\n     */\n    public function file(FileInterface $storage = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Data/Validation.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Data\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Data;\n\nuse ArrayAccess;\nuse Countable;\nuse DateTime;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Security;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Common\\Yaml;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Traversable;\nuse function count;\nuse function is_array;\nuse function is_bool;\nuse function is_float;\nuse function is_int;\nuse function is_string;\n\n/**\n * Class Validation\n * @package Grav\\Common\\Data\n */\nclass Validation\n{\n    /**\n     * Validate value against a blueprint field definition.\n     *\n     * @param mixed $value\n     * @param array $field\n     * @return array\n     */\n    public static function validate($value, array $field)\n    {\n        if (!isset($field['type'])) {\n            $field['type'] = 'text';\n        }\n\n        $validate = (array)($field['validate'] ?? null);\n        $validate_type = $validate['type'] ?? null;\n        $required = $validate['required'] ?? false;\n        $type = $validate_type ?? $field['type'];\n\n        $required = $required && ($validate_type !== 'ignore');\n\n        // If value isn't required, we will stop validation if empty value is given.\n        if ($required !== true && ($value === null || $value === '' || empty($value) || (($field['type'] === 'checkbox' || $field['type'] === 'switch') && $value == false))) {\n            return [];\n        }\n\n        // Get language class.\n        $language = Grav::instance()['language'];\n\n        $name = ucfirst($field['label'] ?? $field['name']);\n        $message = (string) isset($field['validate']['message'])\n            ? $language->translate($field['validate']['message'])\n            : $language->translate('GRAV.FORM.INVALID_INPUT') . ' \"' . $language->translate($name) . '\"';\n\n\n        // Validate type with fallback type text.\n        $method = 'type' . str_replace('-', '_', $type);\n\n        // If this is a YAML field validate/filter as such\n        if (isset($field['yaml']) && $field['yaml'] === true) {\n            $method = 'typeYaml';\n        }\n\n        $messages = [];\n\n        $success = method_exists(__CLASS__, $method) ? self::$method($value, $validate, $field) : true;\n        if (!$success) {\n            $messages[$field['name']][] = $message;\n        }\n\n        // Check individual rules.\n        foreach ($validate as $rule => $params) {\n            $method = 'validate' . ucfirst(str_replace('-', '_', $rule));\n\n            if (method_exists(__CLASS__, $method)) {\n                $success = self::$method($value, $params);\n\n                if (!$success) {\n                    $messages[$field['name']][] = $message;\n                }\n            }\n        }\n\n        return $messages;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $field\n     * @return array\n     */\n    public static function checkSafety($value, array $field)\n    {\n        $messages = [];\n\n        $type = $field['validate']['type'] ?? $field['type'] ?? 'text';\n        $options = $field['xss_check'] ?? [];\n        if ($options === false || $type === 'unset') {\n            return $messages;\n        }\n        if (!is_array($options)) {\n            $options = [];\n        }\n\n        $name = ucfirst($field['label'] ?? $field['name'] ?? 'UNKNOWN');\n\n        /** @var UserInterface $user */\n        $user = Grav::instance()['user'] ?? null;\n        /** @var Config $config */\n        $config = Grav::instance()['config'];\n\n        $xss_whitelist = $config->get('security.xss_whitelist', 'admin.super');\n\n        // Get language class.\n        /** @var Language $language */\n        $language = Grav::instance()['language'];\n\n        if (!static::authorize($xss_whitelist, $user)) {\n            $defaults = Security::getXssDefaults();\n            $options += $defaults;\n            $options['enabled_rules'] += $defaults['enabled_rules'];\n            if (!empty($options['safe_protocols'])) {\n                $options['invalid_protocols'] = array_diff($options['invalid_protocols'], $options['safe_protocols']);\n            }\n            if (!empty($options['safe_tags'])) {\n                $options['dangerous_tags'] = array_diff($options['dangerous_tags'], $options['safe_tags']);\n            }\n\n            if (is_string($value)) {\n                $violation = Security::detectXss($value, $options);\n                if ($violation) {\n                    $messages[$name][] = $language->translate(['GRAV.FORM.XSS_ISSUES', $language->translate($name)], null, true);\n                }\n            } elseif (is_array($value)) {\n                $violations = Security::detectXssFromArray($value, \"{$name}.\", $options);\n                if ($violations) {\n                    $messages[$name][] = $language->translate(['GRAV.FORM.XSS_ISSUES', $language->translate($name)], null, true);\n                }\n            }\n        }\n\n        return $messages;\n    }\n\n    /**\n     * Checks user authorisation to the action.\n     *\n     * @param  string|string[] $action\n     * @param  UserInterface|null $user\n     * @return bool\n     */\n    public static function authorize($action, UserInterface $user = null)\n    {\n        if (!$user) {\n            return false;\n        }\n\n        $action = (array)$action;\n        foreach ($action as $a) {\n            // Ignore 'admin.super' if it's not the only value to be checked.\n            if ($a === 'admin.super' && count($action) > 1 && $user instanceof FlexObjectInterface) {\n                continue;\n            }\n\n            if ($user->authorize($a)) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Filter value against a blueprint field definition.\n     *\n     * @param  mixed  $value\n     * @param  array  $field\n     * @return mixed  Filtered value.\n     */\n    public static function filter($value, array $field)\n    {\n        $validate = (array)($field['filter'] ?? $field['validate'] ?? null);\n\n        // If value isn't required, we will return null if empty value is given.\n        if (($value === null || $value === '') && empty($validate['required'])) {\n            return null;\n        }\n\n        if (!isset($field['type'])) {\n            $field['type'] = 'text';\n        }\n        $type = $field['filter']['type'] ?? $field['validate']['type'] ?? $field['type'];\n\n        $method = 'filter' . ucfirst(str_replace('-', '_', $type));\n\n        // If this is a YAML field validate/filter as such\n        if (isset($field['yaml']) && $field['yaml'] === true) {\n            $method = 'filterYaml';\n        }\n\n        if (!method_exists(__CLASS__, $method)) {\n            $method = isset($field['array']) && $field['array'] === true ? 'filterArray' : 'filterText';\n        }\n\n        return self::$method($value, $validate, $field);\n    }\n\n    /**\n     * HTML5 input: text\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeText($value, array $params, array $field)\n    {\n        if (!is_string($value) && !is_numeric($value)) {\n            return false;\n        }\n\n        $value = (string)$value;\n\n        if (!empty($params['trim'])) {\n            $value = trim($value);\n        }\n\n        $value = preg_replace(\"/\\r\\n|\\r/um\", \"\\n\", $value);\n        $len = mb_strlen($value);\n\n        $min = (int)($params['min'] ?? 0);\n        if ($min && $len < $min) {\n            return false;\n        }\n\n        $multiline = isset($params['multiline']) && $params['multiline'];\n\n        $max = (int)($params['max'] ?? ($multiline ? 65536 : 2048));\n        if ($max && $len > $max) {\n            return false;\n        }\n\n        $step = (int)($params['step'] ?? 0);\n        if ($step && ($len - $min) % $step === 0) {\n            return false;\n        }\n\n        if (!$multiline && preg_match('/\\R/um', $value)) {\n            return false;\n        }\n\n        return true;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return string\n     */\n    protected static function filterText($value, array $params, array $field)\n    {\n        if (!is_string($value) && !is_numeric($value)) {\n            return '';\n        }\n\n        $value = (string)$value;\n\n        if (!empty($params['trim'])) {\n            $value = trim($value);\n        }\n\n        return preg_replace(\"/\\r\\n|\\r/um\", \"\\n\", $value);\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return string|null\n     */\n    protected static function filterCheckbox($value, array $params, array $field)\n    {\n        $value = (string)$value;\n        $field_value = (string)($field['value'] ?? '1');\n\n        return $value === $field_value ? $value : null;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return array|array[]|false|string[]\n     */\n    protected static function filterCommaList($value, array $params, array $field)\n    {\n        return is_array($value) ? $value : preg_split('/\\s*,\\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return bool\n     */\n    public static function typeCommaList($value, array $params, array $field)\n    {\n        if (!isset($params['max'])) {\n            $params['max'] = 2048;\n        }\n\n        return is_array($value) ? true : self::typeText($value, $params, $field);\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return array|array[]|false|string[]\n     */\n    protected static function filterLines($value, array $params, array $field)\n    {\n        return is_array($value) ? $value : preg_split('/\\s*[\\r\\n]+\\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @return string\n     */\n    protected static function filterLower($value, array $params)\n    {\n        return mb_strtolower($value);\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @return string\n     */\n    protected static function filterUpper($value, array $params)\n    {\n        return mb_strtoupper($value);\n    }\n\n\n    /**\n     * HTML5 input: textarea\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeTextarea($value, array $params, array $field)\n    {\n        if (!isset($params['multiline'])) {\n            $params['multiline'] = true;\n        }\n\n        return self::typeText($value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: password\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typePassword($value, array $params, array $field)\n    {\n        if (!isset($params['max'])) {\n            $params['max'] = 256;\n        }\n\n        return self::typeText($value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: hidden\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeHidden($value, array $params, array $field)\n    {\n        return self::typeText($value, $params, $field);\n    }\n\n    /**\n     * Custom input: checkbox list\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeCheckboxes($value, array $params, array $field)\n    {\n        // Set multiple: true so checkboxes can easily use min/max counts to control number of options required\n        $field['multiple'] = true;\n\n        return self::typeArray((array) $value, $params, $field);\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return array|null\n     */\n    protected static function filterCheckboxes($value, array $params, array $field)\n    {\n        return self::filterArray($value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: checkbox\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeCheckbox($value, array $params, array $field)\n    {\n        $value = (string)$value;\n        $field_value = (string)($field['value'] ?? '1');\n\n        return $value === $field_value;\n    }\n\n    /**\n     * HTML5 input: radio\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeRadio($value, array $params, array $field)\n    {\n        return self::typeArray((array) $value, $params, $field);\n    }\n\n    /**\n     * Custom input: toggle\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeToggle($value, array $params, array $field)\n    {\n        if (is_bool($value)) {\n            $value = (int)$value;\n        }\n\n        return self::typeArray((array) $value, $params, $field);\n    }\n\n    /**\n     * Custom input: file\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeFile($value, array $params, array $field)\n    {\n        return self::typeArray((array)$value, $params, $field);\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return array\n     */\n    protected static function filterFile($value, array $params, array $field)\n    {\n        return (array)$value;\n    }\n\n    /**\n     * HTML5 input: select\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeSelect($value, array $params, array $field)\n    {\n        return self::typeArray((array) $value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: number\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeNumber($value, array $params, array $field)\n    {\n        if (!is_numeric($value)) {\n            return false;\n        }\n\n        $value = (float)$value;\n\n        $min = 0;\n        if (isset($params['min'])) {\n            $min = (float)$params['min'];\n            if ($value < $min) {\n                return false;\n            }\n        }\n\n        if (isset($params['max'])) {\n            $max = (float)$params['max'];\n            if ($value > $max) {\n                return false;\n            }\n        }\n\n        if (isset($params['step'])) {\n            $step = (float)$params['step'];\n            // Count of how many steps we are above/below the minimum value.\n            $pos = ($value - $min) / $step;\n            $pos = round($pos, 10);\n            return is_int(static::filterNumber($pos, $params, $field));\n        }\n\n        return true;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return float|int\n     */\n    protected static function filterNumber($value, array $params, array $field)\n    {\n        return (string)(int)$value !== (string)(float)$value ? (float)$value : (int)$value;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return string\n     */\n    protected static function filterDateTime($value, array $params, array $field)\n    {\n        $format = Grav::instance()['config']->get('system.pages.dateformat.default');\n        if ($format) {\n            $converted = new DateTime($value);\n            return $converted->format($format);\n        }\n        return $value;\n    }\n\n    /**\n     * HTML5 input: range\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeRange($value, array $params, array $field)\n    {\n        return self::typeNumber($value, $params, $field);\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return float|int\n     */\n    protected static function filterRange($value, array $params, array $field)\n    {\n        return self::filterNumber($value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: color\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeColor($value, array $params, array $field)\n    {\n        return (bool)preg_match('/^\\#[0-9a-fA-F]{3}[0-9a-fA-F]{3}?$/u', $value);\n    }\n\n    /**\n     * HTML5 input: email\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeEmail($value, array $params, array $field)\n    {\n        if (empty($value)) {\n            return false;\n        }\n\n        if (!isset($params['max'])) {\n            $params['max'] = 320;\n        }\n\n        $values = !is_array($value) ? explode(',', preg_replace('/\\s+/', '', $value)) : $value;\n\n        foreach ($values as $val) {\n            if (!(self::typeText($val, $params, $field) && strpos($val, '@', 1))) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * HTML5 input: url\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeUrl($value, array $params, array $field)\n    {\n        if (!isset($params['max'])) {\n            $params['max'] = 2048;\n        }\n\n        return self::typeText($value, $params, $field) && filter_var($value, FILTER_VALIDATE_URL);\n    }\n\n    /**\n     * HTML5 input: datetime\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeDatetime($value, array $params, array $field)\n    {\n        if ($value instanceof DateTime) {\n            return true;\n        }\n        if (!is_string($value)) {\n            return false;\n        }\n        if (!isset($params['format'])) {\n            return false !== strtotime($value);\n        }\n\n        $dateFromFormat = DateTime::createFromFormat($params['format'], $value);\n\n        return $dateFromFormat && $value === date($params['format'], $dateFromFormat->getTimestamp());\n    }\n\n    /**\n     * HTML5 input: datetime-local\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeDatetimeLocal($value, array $params, array $field)\n    {\n        return self::typeDatetime($value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: date\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeDate($value, array $params, array $field)\n    {\n        if (!isset($params['format'])) {\n            $params['format'] = 'Y-m-d';\n        }\n\n        return self::typeDatetime($value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: time\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeTime($value, array $params, array $field)\n    {\n        if (!isset($params['format'])) {\n            $params['format'] = 'H:i';\n        }\n\n        return self::typeDatetime($value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: month\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeMonth($value, array $params, array $field)\n    {\n        if (!isset($params['format'])) {\n            $params['format'] = 'Y-m';\n        }\n\n        return self::typeDatetime($value, $params, $field);\n    }\n\n    /**\n     * HTML5 input: week\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeWeek($value, array $params, array $field)\n    {\n        if (!isset($params['format']) && !preg_match('/^\\d{4}-W\\d{2}$/u', $value)) {\n            return false;\n        }\n\n        return self::typeDatetime($value, $params, $field);\n    }\n\n    /**\n     * Custom input: array\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeArray($value, array $params, array $field)\n    {\n        if (!is_array($value)) {\n            return false;\n        }\n\n        if (isset($field['multiple'])) {\n            if (isset($params['min']) && count($value) < $params['min']) {\n                return false;\n            }\n\n            if (isset($params['max']) && count($value) > $params['max']) {\n                return false;\n            }\n\n            $min = $params['min'] ?? 0;\n            if (isset($params['step']) && (count($value) - $min) % $params['step'] === 0) {\n                return false;\n            }\n        }\n\n        // If creating new values is allowed, no further checks are needed.\n        $validateOptions = $field['validate']['options'] ?? null;\n        if (!empty($field['selectize']['create']) || $validateOptions === 'ignore') {\n            return true;\n        }\n\n        $options = $field['options'] ?? [];\n        $use = $field['use'] ?? 'values';\n\n        if ($validateOptions) {\n            // Use custom options structure.\n            foreach ($options as &$option) {\n                $option = $option[$validateOptions] ?? null;\n            }\n            unset($option);\n            $options = array_values($options);\n        } elseif (empty($field['selectize']) || empty($field['multiple'])) {\n            $options = array_keys($options);\n        }\n        if ($use === 'keys') {\n            $value = array_keys($value);\n        }\n\n        return !($options && array_diff($value, $options));\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return array|null\n     */\n    protected static function filterFlatten_array($value, $params, $field)\n    {\n        $value = static::filterArray($value, $params, $field);\n\n        return is_array($value) ? Utils::arrayUnflattenDotNotation($value) : null;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return array|null\n     */\n    protected static function filterArray($value, $params, $field)\n    {\n        $values = (array) $value;\n        $options = isset($field['options']) ? array_keys($field['options']) : [];\n        $multi = $field['multiple'] ?? false;\n\n        if (count($values) === 1 && isset($values[0]) && $values[0] === '') {\n            return null;\n        }\n\n\n        if ($options) {\n            $useKey = isset($field['use']) && $field['use'] === 'keys';\n            foreach ($values as $key => $val) {\n                $values[$key] = $useKey ? (bool) $val : $val;\n            }\n        }\n\n        if ($multi) {\n            foreach ($values as $key => $val) {\n                if (is_array($val)) {\n                    $val = implode(',', $val);\n                    $values[$key] =  array_map('trim', explode(',', $val));\n                } else {\n                    $values[$key] =  trim($val);\n                }\n            }\n        }\n\n        $ignoreEmpty = isset($field['ignore_empty']) && Utils::isPositive($field['ignore_empty']);\n        $valueType = $params['value_type'] ?? null;\n        $keyType = $params['key_type'] ?? null;\n        if ($ignoreEmpty || $valueType || $keyType) {\n            $values = static::arrayFilterRecurse($values, ['value_type' => $valueType, 'key_type' => $keyType, 'ignore_empty' => $ignoreEmpty]);\n        }\n\n        return $values;\n    }\n\n    /**\n     * @param array $values\n     * @param array $params\n     * @return array\n     */\n    protected static function arrayFilterRecurse(array $values, array $params): array\n    {\n        foreach ($values as $key => &$val) {\n            if ($params['key_type']) {\n                switch ($params['key_type']) {\n                    case 'int':\n                        $result = is_int($key);\n                        break;\n                    case 'string':\n                        $result = is_string($key);\n                        break;\n                    default:\n                        $result = false;\n                }\n                if (!$result) {\n                    unset($values[$key]);\n                }\n            }\n            if (is_array($val)) {\n                $val = static::arrayFilterRecurse($val, $params);\n                if ($params['ignore_empty'] && empty($val)) {\n                    unset($values[$key]);\n                }\n            } else {\n                if ($params['value_type'] && $val !== '' && $val !== null) {\n                    switch ($params['value_type']) {\n                        case 'bool':\n                            if (Utils::isPositive($val)) {\n                                $val = true;\n                            } elseif (Utils::isNegative($val)) {\n                                $val = false;\n                            } else {\n                                // Ignore invalid bool values.\n                                $val = null;\n                            }\n                            break;\n                        case 'int':\n                            $val = (int)$val;\n                            break;\n                        case 'float':\n                            $val = (float)$val;\n                            break;\n                        case 'string':\n                            $val = (string)$val;\n                            break;\n                        case 'trim':\n                            $val = trim($val);\n                            break;\n                    }\n                }\n\n                if ($params['ignore_empty'] && ($val === '' || $val === null)) {\n                    unset($values[$key]);\n                }\n            }\n        }\n\n        return $values;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return bool\n     */\n    public static function typeList($value, array $params, array $field)\n    {\n        if (!is_array($value)) {\n            return false;\n        }\n\n        if (isset($field['fields'])) {\n            foreach ($value as $key => $item) {\n                foreach ($field['fields'] as $subKey => $subField) {\n                    $subKey = trim($subKey, '.');\n                    $subValue = $item[$subKey] ?? null;\n                    self::validate($subValue, $subField);\n                }\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return array\n     */\n    protected static function filterList($value, array $params, array $field)\n    {\n        return (array) $value;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @return array\n     */\n    public static function filterYaml($value, $params)\n    {\n        if (!is_string($value)) {\n            return $value;\n        }\n\n        return (array) Yaml::parse($value);\n    }\n\n    /**\n     * Custom input: ignore (will not validate)\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeIgnore($value, array $params, array $field)\n    {\n        return true;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return mixed\n     */\n    public static function filterIgnore($value, array $params, array $field)\n    {\n        return $value;\n    }\n\n    /**\n     * Input value which can be ignored.\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeUnset($value, array $params, array $field)\n    {\n        return true;\n    }\n\n    /**\n     * @param mixed $value\n     * @param array $params\n     * @param array $field\n     * @return null\n     */\n    public static function filterUnset($value, array $params, array $field)\n    {\n        return null;\n    }\n\n    // HTML5 attributes (min, max and range are handled inside the types)\n\n    /**\n     * @param mixed $value\n     * @param bool $params\n     * @return bool\n     */\n    public static function validateRequired($value, $params)\n    {\n        if (is_scalar($value)) {\n            return (bool) $params !== true || $value !== '';\n        }\n\n        return (bool) $params !== true || !empty($value);\n    }\n\n    /**\n     * @param mixed $value\n     * @param string $params\n     * @return bool\n     */\n    public static function validatePattern($value, $params)\n    {\n        return (bool) preg_match(\"`^{$params}$`u\", $value);\n    }\n\n    // Internal types\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateAlpha($value, $params)\n    {\n        return ctype_alpha($value);\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateAlnum($value, $params)\n    {\n        return ctype_alnum($value);\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function typeBool($value, $params)\n    {\n        return is_bool($value) || $value == 1 || $value == 0;\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateBool($value, $params)\n    {\n        return is_bool($value) || $value == 1 || $value == 0;\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    protected static function filterBool($value, $params)\n    {\n        return (bool) $value;\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateDigit($value, $params)\n    {\n        return ctype_digit($value);\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateFloat($value, $params)\n    {\n        return is_float(filter_var($value, FILTER_VALIDATE_FLOAT));\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return float\n     */\n    protected static function filterFloat($value, $params)\n    {\n        return (float) $value;\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateHex($value, $params)\n    {\n        return ctype_xdigit($value);\n    }\n\n    /**\n     * Custom input: int\n     *\n     * @param  mixed  $value   Value to be validated.\n     * @param  array  $params  Validation parameters.\n     * @param  array  $field   Blueprint for the field.\n     * @return bool   True if validation succeeded.\n     */\n    public static function typeInt($value, array $params, array $field)\n    {\n        $params['step'] = max(1, (int)($params['step'] ?? 0));\n\n        return self::typeNumber($value, $params, $field);\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateInt($value, $params)\n    {\n        return is_numeric($value) && (int)$value == $value;\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return int\n     */\n    protected static function filterInt($value, $params)\n    {\n        return (int)$value;\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateArray($value, $params)\n    {\n        return is_array($value) || ($value instanceof ArrayAccess && $value instanceof Traversable && $value instanceof Countable);\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return array\n     */\n    public static function filterItem_List($value, $params)\n    {\n        return array_values(array_filter($value, static function ($v) {\n            return !empty($v);\n        }));\n    }\n\n    /**\n     * @param mixed $value\n     * @param mixed $params\n     * @return bool\n     */\n    public static function validateJson($value, $params)\n    {\n        return (bool) (@json_decode($value));\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Data/ValidationException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Data\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Data;\n\nuse Grav\\Common\\Grav;\nuse JsonSerializable;\nuse RuntimeException;\n\n/**\n * Class ValidationException\n * @package Grav\\Common\\Data\n */\nclass ValidationException extends RuntimeException implements JsonSerializable\n{\n    /** @var array */\n    protected $messages = [];\n    protected $escape = true;\n\n    /**\n     * @param array $messages\n     * @return $this\n     */\n    public function setMessages(array $messages = [])\n    {\n        $this->messages = $messages;\n\n        $language = Grav::instance()['language'];\n        $this->message = $language->translate('GRAV.FORM.VALIDATION_FAIL', null, true) . ' ' . $this->message;\n\n        foreach ($messages as $list) {\n            $list = array_unique($list);\n            foreach ($list as $message) {\n                $this->message .= '<br/>' . htmlspecialchars($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');\n            }\n        }\n\n        return $this;\n    }\n\n    public function setSimpleMessage(bool $escape = true): void\n    {\n        $first = reset($this->messages);\n        $message = reset($first);\n\n        $this->message = $escape ? htmlspecialchars($message, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $message;\n    }\n\n    /**\n     * @return array\n     */\n    public function getMessages(): array\n    {\n        return $this->messages;\n    }\n\n    public function jsonSerialize(): array\n    {\n        return ['validation' => $this->messages];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Debugger.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Clockwork\\Clockwork;\nuse Clockwork\\DataSource\\MonologDataSource;\nuse Clockwork\\DataSource\\PsrMessageDataSource;\nuse Clockwork\\DataSource\\XdebugDataSource;\nuse Clockwork\\Helpers\\ServerTiming;\nuse Clockwork\\Request\\UserData;\nuse Clockwork\\Storage\\FileStorage;\nuse DebugBar\\DataCollector\\ConfigCollector;\nuse DebugBar\\DataCollector\\DataCollectorInterface;\nuse DebugBar\\DataCollector\\ExceptionsCollector;\nuse DebugBar\\DataCollector\\MemoryCollector;\nuse DebugBar\\DataCollector\\MessagesCollector;\nuse DebugBar\\DataCollector\\PhpInfoCollector;\nuse DebugBar\\DataCollector\\RequestDataCollector;\nuse DebugBar\\DataCollector\\TimeDataCollector;\nuse DebugBar\\DebugBar;\nuse DebugBar\\DebugBarException;\nuse DebugBar\\JavascriptRenderer;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Processors\\ProcessorInterface;\nuse Grav\\Common\\Twig\\TwigClockworkDataSource;\nuse Grav\\Framework\\Psr7\\Response;\nuse Monolog\\Logger;\nuse Psr\\Http\\Message\\RequestInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse ReflectionObject;\nuse SplFileInfo;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse Throwable;\nuse Twig\\Environment;\nuse Twig\\Template;\nuse Twig\\TemplateWrapper;\nuse function array_slice;\nuse function call_user_func;\nuse function count;\nuse function define;\nuse function defined;\nuse function extension_loaded;\nuse function get_class;\nuse function gettype;\nuse function is_array;\nuse function is_bool;\nuse function is_object;\nuse function is_scalar;\nuse function is_string;\n\n/**\n * Class Debugger\n * @package Grav\\Common\n */\nclass Debugger\n{\n    /** @var static */\n    protected static $instance;\n    /** @var Grav|null */\n    protected $grav;\n    /** @var Config|null */\n    protected $config;\n    /** @var JavascriptRenderer|null */\n    protected $renderer;\n    /** @var DebugBar|null */\n    protected $debugbar;\n    /** @var Clockwork|null */\n    protected $clockwork;\n    /** @var bool */\n    protected $enabled = false;\n    /** @var bool */\n    protected $initialized = false;\n    /** @var array */\n    protected $timers = [];\n    /** @var array */\n    protected $deprecations = [];\n    /** @var callable|null */\n    protected $errorHandler;\n    /** @var float */\n    protected $requestTime;\n    /** @var float */\n    protected $currentTime;\n    /** @var int */\n    protected $profiling = 0;\n    /** @var bool */\n    protected $censored = false;\n\n    /**\n     * Debugger constructor.\n     */\n    public function __construct()\n    {\n        static::$instance = $this;\n\n        $this->currentTime = microtime(true);\n\n        if (!defined('GRAV_REQUEST_TIME')) {\n            define('GRAV_REQUEST_TIME', $this->currentTime);\n        }\n\n        $this->requestTime = $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME;\n\n        // Set deprecation collector.\n        $this->setErrorHandler();\n    }\n\n    /**\n     * @return Clockwork|null\n     */\n    public function getClockwork(): ?Clockwork\n    {\n        return $this->enabled ? $this->clockwork : null;\n    }\n\n    /**\n     * Initialize the debugger\n     *\n     * @return $this\n     * @throws DebugBarException\n     */\n    public function init()\n    {\n        if ($this->initialized) {\n            return $this;\n        }\n\n        $this->grav = Grav::instance();\n        $this->config = $this->grav['config'];\n\n        // Enable/disable debugger based on configuration.\n        $this->enabled = (bool)$this->config->get('system.debugger.enabled');\n        $this->censored = (bool)$this->config->get('system.debugger.censored', false);\n\n        if ($this->enabled) {\n            $this->initialized = true;\n\n            $clockwork = $debugbar = null;\n\n            switch ($this->config->get('system.debugger.provider', 'debugbar')) {\n                case 'clockwork':\n                    $this->clockwork = $clockwork = new Clockwork();\n                    break;\n                default:\n                    $this->debugbar = $debugbar = new DebugBar();\n            }\n\n            $plugins_config = (array)$this->config->get('plugins');\n            ksort($plugins_config);\n\n            if ($clockwork) {\n                $log = $this->grav['log'];\n                $clockwork->setStorage(new FileStorage('cache://clockwork'));\n                if (extension_loaded('xdebug')) {\n                    $clockwork->addDataSource(new XdebugDataSource());\n                }\n                if ($log instanceof Logger) {\n                    $clockwork->addDataSource(new MonologDataSource($log));\n                }\n\n                $timeline = $clockwork->timeline();\n                if ($this->requestTime !== GRAV_REQUEST_TIME) {\n                    $event = $timeline->event('Server');\n                    $event->finalize($this->requestTime, GRAV_REQUEST_TIME);\n                }\n                if ($this->currentTime !== GRAV_REQUEST_TIME) {\n                    $event = $timeline->event('Loading');\n                    $event->finalize(GRAV_REQUEST_TIME, $this->currentTime);\n                }\n                $event = $timeline->event('Site Setup');\n                $event->finalize($this->currentTime, microtime(true));\n            }\n\n            if ($this->censored) {\n                $censored = ['CENSORED' => true];\n            }\n\n            if ($debugbar) {\n                $debugbar->addCollector(new PhpInfoCollector());\n                $debugbar->addCollector(new MessagesCollector());\n                if (!$this->censored) {\n                    $debugbar->addCollector(new RequestDataCollector());\n                }\n                $debugbar->addCollector(new TimeDataCollector($this->requestTime));\n                $debugbar->addCollector(new MemoryCollector());\n                $debugbar->addCollector(new ExceptionsCollector());\n                $debugbar->addCollector(new ConfigCollector($censored ?? (array)$this->config->get('system'), 'Config'));\n                $debugbar->addCollector(new ConfigCollector($censored ?? $plugins_config, 'Plugins'));\n                $debugbar->addCollector(new ConfigCollector($this->config->get('streams.schemes'), 'Streams'));\n\n                if ($this->requestTime !== GRAV_REQUEST_TIME) {\n                    $debugbar['time']->addMeasure('Server', $debugbar['time']->getRequestStartTime(), GRAV_REQUEST_TIME);\n                }\n                if ($this->currentTime !== GRAV_REQUEST_TIME) {\n                    $debugbar['time']->addMeasure('Loading', GRAV_REQUEST_TIME, $this->currentTime);\n                }\n                $debugbar['time']->addMeasure('Site Setup', $this->currentTime, microtime(true));\n            }\n\n            $this->addMessage('Grav v' . GRAV_VERSION . ' - PHP ' . PHP_VERSION);\n            $this->config->debug();\n\n            if ($clockwork) {\n                $clockwork->info('System Configuration', $censored ?? $this->config->get('system'));\n                $clockwork->info('Plugins Configuration', $censored ?? $plugins_config);\n                $clockwork->info('Streams', $this->config->get('streams.schemes'));\n            }\n        }\n\n        return $this;\n    }\n\n    public function finalize(): void\n    {\n        if ($this->clockwork && $this->enabled) {\n            $this->stopProfiling('Profiler Analysis');\n            $this->addMeasures();\n\n            $deprecations = $this->getDeprecations();\n            $count = count($deprecations);\n            if (!$count) {\n                return;\n            }\n\n            /** @var UserData $userData */\n            $userData = $this->clockwork->userData('Deprecated');\n            $userData->counters([\n                'Deprecated' => count($deprecations)\n            ]);\n            /*\n            foreach ($deprecations as &$deprecation) {\n                $d = $deprecation;\n                unset($d['message']);\n                $this->clockwork->log('deprecated', $deprecation['message'], $d);\n            }\n            unset($deprecation);\n             */\n\n            $userData->table('Your site is using following deprecated features', $deprecations);\n        }\n    }\n\n    public function logRequest(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface\n    {\n        if (!$this->enabled || !$this->clockwork) {\n            return $response;\n        }\n\n        $clockwork = $this->clockwork;\n\n        $this->finalize();\n\n        $clockwork->timeline()->finalize($request->getAttribute('request_time'));\n\n        if ($this->censored) {\n            $censored = 'CENSORED';\n            $request = $request\n                ->withCookieParams([$censored => ''])\n                ->withUploadedFiles([])\n                ->withHeader('cookie', $censored);\n            $request = $request->withParsedBody([$censored => '']);\n        }\n\n        $clockwork->addDataSource(new PsrMessageDataSource($request, $response));\n\n        $clockwork->resolveRequest();\n        $clockwork->storeRequest();\n\n        $clockworkRequest = $clockwork->getRequest();\n\n        $response = $response\n            ->withHeader('X-Clockwork-Id', $clockworkRequest->id)\n            ->withHeader('X-Clockwork-Version', $clockwork::VERSION);\n\n        $response = $response->withHeader('X-Clockwork-Path', Utils::url('/__clockwork/'));\n\n        return $response->withHeader('Server-Timing', ServerTiming::fromRequest($clockworkRequest)->value());\n    }\n\n\n    public function debuggerRequest(RequestInterface $request): Response\n    {\n        $clockwork = $this->clockwork;\n\n        $headers = [\n            'Content-Type' => 'application/json',\n            'Grav-Internal-SkipShutdown' => 1\n        ];\n\n        $path = $request->getUri()->getPath();\n        $clockworkDataUri = '#/__clockwork(?:/(?<id>[0-9-]+))?(?:/(?<direction>(?:previous|next)))?(?:/(?<count>\\d+))?#';\n        if (preg_match($clockworkDataUri, $path, $matches) === false) {\n            $response = ['message' => 'Bad Input'];\n\n            return new Response(400, $headers, json_encode($response));\n        }\n\n        $id = $matches['id'] ?? null;\n        $direction = $matches['direction'] ?? 'latest';\n        $count = $matches['count'] ?? null;\n\n        $storage = $clockwork->getStorage();\n\n        if ($direction === 'previous') {\n            $data = $storage->previous($id, $count);\n        } elseif ($direction === 'next') {\n            $data = $storage->next($id, $count);\n        } elseif ($direction === 'latest' || $id === 'latest') {\n            $data = $storage->latest();\n        } else {\n            $data = $storage->find($id);\n        }\n\n        if (preg_match('#(?<id>[0-9-]+|latest)/extended#', $path)) {\n            $clockwork->extendRequest($data);\n        }\n\n        if (!$data) {\n            $response = ['message' => 'Not Found'];\n\n            return new Response(404, $headers, json_encode($response));\n        }\n\n        $data = is_array($data) ? array_map(static function ($item) {\n            return $item->toArray();\n        }, $data) : $data->toArray();\n\n        return new Response(200, $headers, json_encode($data));\n    }\n\n    /**\n     * @return void\n     */\n    protected function addMeasures(): void\n    {\n        if (!$this->enabled) {\n            return;\n        }\n\n        $nowTime = microtime(true);\n        $clkTimeLine = $this->clockwork ? $this->clockwork->timeline() : null;\n        $debTimeLine = $this->debugbar ? $this->debugbar['time'] : null;\n        foreach ($this->timers as $name => $data) {\n            $description = $data[0];\n            $startTime = $data[1] ?? null;\n            $endTime = $data[2] ?? $nowTime;\n            if ($clkTimeLine) {\n                $event = $clkTimeLine->event($description);\n                $event->finalize($startTime, $endTime);\n            } elseif ($debTimeLine) {\n                if ($endTime - $startTime < 0.001) {\n                    continue;\n                }\n\n                $debTimeLine->addMeasure($description ?? $name, $startTime, $endTime);\n            }\n        }\n        $this->timers = [];\n    }\n\n    /**\n     * Set/get the enabled state of the debugger\n     *\n     * @param bool|null $state If null, the method returns the enabled value. If set, the method sets the enabled state\n     * @return bool\n     */\n    public function enabled($state = null)\n    {\n        if ($state !== null) {\n            $this->enabled = (bool)$state;\n        }\n\n        return $this->enabled;\n    }\n\n    /**\n     * Add the debugger assets to the Grav Assets\n     *\n     * @return $this\n     */\n    public function addAssets()\n    {\n        if ($this->enabled) {\n            // Only add assets if Page is HTML\n            $page = $this->grav['page'];\n            if ($page->templateFormat() !== 'html') {\n                return $this;\n            }\n\n            /** @var Assets $assets */\n            $assets = $this->grav['assets'];\n\n            // Clockwork specific assets\n            if ($this->clockwork) {\n                if ($this->config->get('plugins.clockwork-web.enabled')) {\n                    $route = Utils::url($this->grav['config']->get('plugins.clockwork-web.route'));\n                } else {\n                    $route = 'https://github.com/getgrav/grav-plugin-clockwork-web';\n                }\n                $assets->addCss('/system/assets/debugger/clockwork.css');\n                $assets->addJs('/system/assets/debugger/clockwork.js', [\n                    'id' => 'clockwork-script',\n                    'data-route' => $route\n                ]);\n            }\n\n\n            // Debugbar specific assets\n            if ($this->debugbar) {\n                // Add jquery library\n                $assets->add('jquery', 101);\n\n                $this->renderer = $this->debugbar->getJavascriptRenderer();\n                $this->renderer->setIncludeVendors(false);\n\n                [$css_files, $js_files] = $this->renderer->getAssets(null, JavascriptRenderer::RELATIVE_URL);\n\n                foreach ((array)$css_files as $css) {\n                    $assets->addCss($css);\n                }\n\n                $assets->addCss('/system/assets/debugger/phpdebugbar.css', ['loading' => 'inline']);\n\n                foreach ((array)$js_files as $js) {\n                    $assets->addJs($js);\n                }\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param int $limit\n     * @return array\n     */\n    public function getCaller($limit = 2)\n    {\n        $trace = debug_backtrace(false, $limit);\n\n        return array_pop($trace);\n    }\n\n    /**\n     * Adds a data collector\n     *\n     * @param DataCollectorInterface $collector\n     * @return $this\n     * @throws DebugBarException\n     */\n    public function addCollector($collector)\n    {\n        if ($this->debugbar && !$this->debugbar->hasCollector($collector->getName())) {\n            $this->debugbar->addCollector($collector);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Returns a data collector\n     *\n     * @param string $name\n     * @return DataCollectorInterface|null\n     * @throws DebugBarException\n     */\n    public function getCollector($name)\n    {\n        if ($this->debugbar && $this->debugbar->hasCollector($name)) {\n            return $this->debugbar->getCollector($name);\n        }\n\n        return null;\n    }\n\n    /**\n     * Displays the debug bar\n     *\n     * @return $this\n     */\n    public function render()\n    {\n        if ($this->enabled && $this->debugbar) {\n            // Only add assets if Page is HTML\n            $page = $this->grav['page'];\n            if (!$this->renderer || $page->templateFormat() !== 'html') {\n                return $this;\n            }\n\n            $this->addMeasures();\n            $this->addDeprecations();\n\n            echo $this->renderer->render();\n        }\n\n        return $this;\n    }\n\n    /**\n     * Sends the data through the HTTP headers\n     *\n     * @return $this\n     */\n    public function sendDataInHeaders()\n    {\n        if ($this->enabled && $this->debugbar) {\n            $this->addMeasures();\n            $this->addDeprecations();\n            $this->debugbar->sendDataInHeaders();\n        }\n\n        return $this;\n    }\n\n    /**\n     * Returns collected debugger data.\n     *\n     * @return array|null\n     */\n    public function getData()\n    {\n        if (!$this->enabled || !$this->debugbar) {\n            return null;\n        }\n\n        $this->addMeasures();\n        $this->addDeprecations();\n        $this->timers = [];\n\n        return $this->debugbar->getData();\n    }\n\n    /**\n     * Hierarchical Profiler support.\n     *\n     * @param callable $callable\n     * @param string|null $message\n     * @return mixed\n     */\n    public function profile(callable $callable, string $message = null)\n    {\n        $this->startProfiling();\n        $response = $callable();\n        $this->stopProfiling($message);\n\n        return $response;\n    }\n\n    public function addTwigProfiler(Environment $twig): void\n    {\n        $clockwork = $this->getClockwork();\n        if ($clockwork) {\n            $source = new TwigClockworkDataSource($twig);\n            $source->listenToEvents();\n            $clockwork->addDataSource($source);\n        }\n    }\n\n    /**\n     * Start profiling code.\n     *\n     * @return void\n     */\n    public function startProfiling(): void\n    {\n        if ($this->enabled && extension_loaded('tideways_xhprof')) {\n            $this->profiling++;\n            if ($this->profiling === 1) {\n                // @phpstan-ignore-next-line\n                \\tideways_xhprof_enable(TIDEWAYS_XHPROF_FLAGS_NO_BUILTINS);\n            }\n        }\n    }\n\n    /**\n     * Stop profiling code. Returns profiling array or null if profiling couldn't be done.\n     *\n     * @param string|null $message\n     * @return array|null\n     */\n    public function stopProfiling(string $message = null): ?array\n    {\n        $timings = null;\n        if ($this->enabled && extension_loaded('tideways_xhprof')) {\n            $profiling = $this->profiling - 1;\n            if ($profiling === 0) {\n                // @phpstan-ignore-next-line\n                $timings = \\tideways_xhprof_disable();\n                $timings = $this->buildProfilerTimings($timings);\n\n                if ($this->clockwork) {\n                    /** @var UserData $userData */\n                    $userData = $this->clockwork->userData('Profiler');\n                    $userData->counters([\n                        'Calls' => count($timings)\n                    ]);\n                    $userData->table('Profiler', $timings);\n                } else {\n                    $this->addMessage($message ?? 'Profiler Analysis', 'debug', $timings);\n                }\n            }\n            $this->profiling = max(0, $profiling);\n        }\n\n        return $timings;\n    }\n\n    /**\n     * @param array $timings\n     * @return array\n     */\n    protected function buildProfilerTimings(array $timings): array\n    {\n        // Filter method calls which take almost no time.\n        $timings = array_filter($timings, function ($value) {\n            return $value['wt'] > 50;\n        });\n\n        uasort($timings, function (array $a, array $b) {\n            return $b['wt'] <=> $a['wt'];\n        });\n\n        $table = [];\n        foreach ($timings as $key => $timing) {\n            $parts = explode('==>', $key);\n            $method = $this->parseProfilerCall(array_pop($parts));\n            $context = $this->parseProfilerCall(array_pop($parts));\n\n            // Skip redundant method calls.\n            if ($context === 'Grav\\Framework\\RequestHandler\\RequestHandler::handle()') {\n                continue;\n            }\n\n            // Do not profile library calls.\n            if (strpos($context, 'Grav\\\\') !== 0) {\n                continue;\n            }\n\n            $table[] = [\n                'Context' => $context,\n                'Method' => $method,\n                'Calls' => $timing['ct'],\n                'Time (ms)' => $timing['wt'] / 1000,\n            ];\n        }\n\n        return $table;\n    }\n\n    /**\n     * @param string|null $call\n     * @return mixed|string|null\n     */\n    protected function parseProfilerCall(?string $call)\n    {\n        if (null === $call) {\n            return '';\n        }\n        if (strpos($call, '@')) {\n            [$call,] = explode('@', $call);\n        }\n        if (strpos($call, '::')) {\n            [$class, $call] = explode('::', $call);\n        }\n\n        if (!isset($class)) {\n            return $call;\n        }\n\n        // It is also possible to display twig files, but they are being logged in views.\n        /*\n        if (strpos($class, '__TwigTemplate_') === 0 && class_exists($class)) {\n            $env = new Environment();\n            / ** @var Template $template * /\n            $template = new $class($env);\n\n            return $template->getTemplateName();\n        }\n        */\n\n        return \"{$class}::{$call}()\";\n    }\n\n    /**\n     * Start a timer with an associated name and description\n     *\n     * @param string      $name\n     * @param string|null $description\n     * @return $this\n     */\n    public function startTimer($name, $description = null)\n    {\n        $this->timers[$name] = [$description, microtime(true)];\n\n        return $this;\n    }\n\n    /**\n     * Stop the named timer\n     *\n     * @param string $name\n     * @return $this\n     */\n    public function stopTimer($name)\n    {\n        if (isset($this->timers[$name])) {\n            $endTime = microtime(true);\n            $this->timers[$name][] = $endTime;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Dump variables into the Messages tab of the Debug Bar\n     *\n     * @param mixed  $message\n     * @param string $label\n     * @param mixed|bool $isString\n     * @return $this\n     */\n    public function addMessage($message, $label = 'info', $isString = true)\n    {\n        if ($this->enabled) {\n            if ($this->censored) {\n                if (!is_scalar($message)) {\n                    $message = 'CENSORED';\n                }\n                if (!is_scalar($isString)) {\n                    $isString = ['CENSORED'];\n                }\n            }\n\n            if ($this->debugbar) {\n                if (is_array($isString)) {\n                    $message = $isString;\n                    $isString = false;\n                } elseif (is_string($isString)) {\n                    $message = $isString;\n                    $isString = true;\n                }\n                $this->debugbar['messages']->addMessage($message, $label, $isString);\n            }\n\n            if ($this->clockwork) {\n                $context = $isString;\n                if (!is_scalar($message)) {\n                    $context = $message;\n                    $message = gettype($context);\n                }\n                if (is_bool($context)) {\n                    $context = [];\n                } elseif (!is_array($context)) {\n                    $type = gettype($context);\n                    $context = [$type => $context];\n                }\n\n                $this->clockwork->log($label, $message, $context);\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $name\n     * @param object $event\n     * @param EventDispatcherInterface $dispatcher\n     * @param float|null $time\n     * @return $this\n     */\n    public function addEvent(string $name, $event, EventDispatcherInterface $dispatcher, float $time = null)\n    {\n        if ($this->enabled && $this->clockwork) {\n            $time = $time ?? microtime(true);\n            $duration = (microtime(true) - $time) * 1000;\n\n            $data = null;\n            if ($event && method_exists($event, '__debugInfo')) {\n                $data = $event;\n            }\n\n            $listeners = [];\n            foreach ($dispatcher->getListeners($name) as $listener) {\n                $listeners[] = $this->resolveCallable($listener);\n            }\n\n            $this->clockwork->addEvent($name, $data, $time, ['listeners' => $listeners, 'duration' => $duration]);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Dump exception into the Messages tab of the Debug Bar\n     *\n     * @param Throwable $e\n     * @return Debugger\n     */\n    public function addException(Throwable $e)\n    {\n        if ($this->initialized && $this->enabled) {\n            if ($this->debugbar) {\n                $this->debugbar['exceptions']->addThrowable($e);\n            }\n\n            if ($this->clockwork) {\n                /** @var UserData $exceptions */\n                $exceptions = $this->clockwork->userData('Exceptions');\n                $exceptions->data(['message' => $e->getMessage()]);\n\n                $this->clockwork->alert($e->getMessage(), ['exception' => $e]);\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * @return void\n     */\n    public function setErrorHandler()\n    {\n        $this->errorHandler = set_error_handler(\n            [$this, 'deprecatedErrorHandler']\n        );\n    }\n\n    /**\n     * @param int $errno\n     * @param string $errstr\n     * @param string $errfile\n     * @param int $errline\n     * @return bool\n     */\n    public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline)\n    {\n        if ($errno !== E_USER_DEPRECATED && $errno !== E_DEPRECATED) {\n            if ($this->errorHandler) {\n                return call_user_func($this->errorHandler, $errno, $errstr, $errfile, $errline);\n            }\n\n            return true;\n        }\n\n        if (!$this->enabled) {\n            return true;\n        }\n\n        // Figure out error scope from the error.\n        $scope = 'unknown';\n        if (stripos($errstr, 'grav') !== false) {\n            $scope = 'grav';\n        } elseif (strpos($errfile, '/twig/') !== false) {\n            $scope = 'twig';\n            // TODO: remove when upgrading to Twig 2+\n            if (str_contains($errstr, '#[\\ReturnTypeWillChange]') || str_contains($errstr, 'Passing null to parameter')) {\n                return true;\n            }\n        } elseif (stripos($errfile, '/yaml/') !== false) {\n            $scope = 'yaml';\n        } elseif (strpos($errfile, '/vendor/') !== false) {\n            $scope = 'vendor';\n        }\n\n        // Clean up backtrace to make it more useful.\n        $backtrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT);\n\n        // Skip current call.\n        array_shift($backtrace);\n\n        // Find yaml file where the error happened.\n        if ($scope === 'yaml') {\n            foreach ($backtrace as $current) {\n                if (isset($current['args'])) {\n                    foreach ($current['args'] as $arg) {\n                        if ($arg instanceof SplFileInfo) {\n                            $arg = $arg->getPathname();\n                        }\n                        if (is_string($arg) && preg_match('/.+\\.(yaml|md)$/i', $arg)) {\n                            $errfile = $arg;\n                            $errline = 0;\n\n                            break 2;\n                        }\n                    }\n                }\n            }\n        }\n\n        // Filter arguments.\n        $cut = 0;\n        $previous = null;\n        foreach ($backtrace as $i => &$current) {\n            if (isset($current['args'])) {\n                $args = [];\n                foreach ($current['args'] as $arg) {\n                    if (is_string($arg)) {\n                        $arg = \"'\" . $arg . \"'\";\n                        if (mb_strlen($arg) > 100) {\n                            $arg = 'string';\n                        }\n                    } elseif (is_bool($arg)) {\n                        $arg = $arg ? 'true' : 'false';\n                    } elseif (is_scalar($arg)) {\n                        $arg = $arg;\n                    } elseif (is_object($arg)) {\n                        $arg = get_class($arg) . ' $object';\n                    } elseif (is_array($arg)) {\n                        $arg = '$array';\n                    } else {\n                        $arg = '$object';\n                    }\n\n                    $args[] = $arg;\n                }\n                $current['args'] = $args;\n            }\n\n            $object = $current['object'] ?? null;\n            unset($current['object']);\n\n            $reflection = null;\n            if ($object instanceof TemplateWrapper) {\n                $reflection = new ReflectionObject($object);\n                $property = $reflection->getProperty('template');\n                $property->setAccessible(true);\n                $object = $property->getValue($object);\n            }\n\n            if ($object instanceof Template) {\n                $file = $current['file'] ?? null;\n\n                if (preg_match('`(Template.php|TemplateWrapper.php)$`', $file)) {\n                    $current = null;\n                    continue;\n                }\n\n                $debugInfo = $object->getDebugInfo();\n\n                $line = 1;\n                if (!$reflection) {\n                    foreach ($debugInfo as $codeLine => $templateLine) {\n                        if ($codeLine <= $current['line']) {\n                            $line = $templateLine;\n                            break;\n                        }\n                    }\n                }\n\n                $src = $object->getSourceContext();\n                //$code = preg_split('/\\r\\n|\\r|\\n/', $src->getCode());\n                //$current['twig']['twig'] = trim($code[$line - 1]);\n                $current['twig']['file'] = $src->getPath();\n                $current['twig']['line'] = $line;\n\n                $prevFile = $previous['file'] ?? null;\n                if ($prevFile && $file === $prevFile) {\n                    $prevLine = $previous['line'];\n\n                    $line = 1;\n                    foreach ($debugInfo as $codeLine => $templateLine) {\n                        if ($codeLine <= $prevLine) {\n                            $line = $templateLine;\n                            break;\n                        }\n                    }\n\n                    //$previous['twig']['twig'] = trim($code[$line - 1]);\n                    $previous['twig']['file'] = $src->getPath();\n                    $previous['twig']['line'] = $line;\n                }\n\n                $cut = $i;\n            } elseif ($object instanceof ProcessorInterface) {\n                $cut = $cut ?: $i;\n                break;\n            }\n\n            $previous = &$backtrace[$i];\n        }\n        unset($current);\n\n        if ($cut) {\n            $backtrace = array_slice($backtrace, 0, $cut + 1);\n        }\n        $backtrace = array_values(array_filter($backtrace));\n\n        // Skip vendor libraries and the method where error was triggered.\n        foreach ($backtrace as $i => $current) {\n            if (!isset($current['file'])) {\n                continue;\n            }\n            if (strpos($current['file'], '/vendor/') !== false) {\n                $cut = $i + 1;\n                continue;\n            }\n            if (isset($current['function']) && ($current['function'] === 'user_error' || $current['function'] === 'trigger_error')) {\n                $cut = $i + 1;\n                continue;\n            }\n\n            break;\n        }\n\n        if ($cut) {\n            $backtrace = array_slice($backtrace, $cut);\n        }\n        $backtrace = array_values(array_filter($backtrace));\n\n        $current = reset($backtrace);\n\n        // If the issue happened inside twig file, change the file and line to match that file.\n        $file = $current['twig']['file'] ?? '';\n        if ($file) {\n            $errfile = $file;\n            $errline = $current['twig']['line'] ?? 0;\n        }\n\n        $deprecation = [\n            'scope' => $scope,\n            'message' => $errstr,\n            'file' => $errfile,\n            'line' => $errline,\n            'trace' => $backtrace,\n            'count' => 1\n        ];\n\n        $this->deprecations[] = $deprecation;\n\n        // Do not pass forward.\n        return true;\n    }\n\n    /**\n     * @return array\n     */\n    protected function getDeprecations(): array\n    {\n        if (!$this->deprecations) {\n            return [];\n        }\n\n        $list = [];\n        /** @var array $deprecated */\n        foreach ($this->deprecations as $deprecated) {\n            $list[] = $this->getDepracatedMessage($deprecated)[0];\n        }\n\n        return $list;\n    }\n\n    /**\n     * @return void\n     * @throws DebugBarException\n     */\n    protected function addDeprecations()\n    {\n        if (!$this->deprecations) {\n            return;\n        }\n\n        $collector = new MessagesCollector('deprecated');\n        $this->addCollector($collector);\n        $collector->addMessage('Your site is using following deprecated features:');\n\n        /** @var array $deprecated */\n        foreach ($this->deprecations as $deprecated) {\n            list($message, $scope) = $this->getDepracatedMessage($deprecated);\n\n            $collector->addMessage($message, $scope);\n        }\n    }\n\n    /**\n     * @param array $deprecated\n     * @return array\n     */\n    protected function getDepracatedMessage($deprecated)\n    {\n        $scope = $deprecated['scope'];\n\n        $trace = [];\n        if (isset($deprecated['trace'])) {\n            foreach ($deprecated['trace'] as $current) {\n                $class = $current['class'] ?? '';\n                $type = $current['type'] ?? '';\n                $function = $this->getFunction($current);\n                if (isset($current['file'])) {\n                    $current['file'] = str_replace(GRAV_ROOT . '/', '', $current['file']);\n                }\n\n                unset($current['class'], $current['type'], $current['function'], $current['args']);\n\n                if (isset($current['twig'])) {\n                    $trace[] = $current['twig'];\n                } else {\n                    $trace[] = ['call' => $class . $type . $function] + $current;\n                }\n            }\n        }\n\n        $array = [\n            'message' => $deprecated['message'],\n            'file' => $deprecated['file'],\n            'line' => $deprecated['line'],\n            'trace' => $trace\n        ];\n\n        return [\n            array_filter($array),\n            $scope\n        ];\n    }\n\n    /**\n     * @param array $trace\n     * @return string\n     */\n    protected function getFunction($trace)\n    {\n        if (!isset($trace['function'])) {\n            return '';\n        }\n\n        return $trace['function'] . '(' . implode(', ', $trace['args'] ?? []) . ')';\n    }\n\n    /**\n     * @param callable $callable\n     * @return string\n     */\n    protected function resolveCallable(callable $callable)\n    {\n        if (is_array($callable)) {\n            return get_class($callable[0]) . '->' . $callable[1] . '()';\n        }\n\n        return 'unknown';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Errors/BareHandler.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Errors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Errors;\n\nuse Whoops\\Handler\\Handler;\n\n/**\n * Class BareHandler\n * @package Grav\\Common\\Errors\n */\nclass BareHandler extends Handler\n{\n    /**\n     * @return int\n     */\n    public function handle()\n    {\n        $inspector = $this->getInspector();\n        $code = $inspector->getException()->getCode();\n        if (($code >= 400) && ($code < 600)) {\n            $this->getRun()->sendHttpCode($code);\n        }\n\n        return Handler::QUIT;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Errors/Errors.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Errors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Errors;\n\nuse Exception;\nuse Grav\\Common\\Grav;\nuse Whoops\\Handler\\JsonResponseHandler;\nuse Whoops\\Handler\\PrettyPageHandler;\nuse Whoops\\Run;\nuse Whoops\\Util\\Misc;\nuse function is_int;\n\n/**\n * Class Errors\n * @package Grav\\Common\\Errors\n */\nclass Errors\n{\n    /**\n     * @return void\n     */\n    public function resetHandlers()\n    {\n        $grav = Grav::instance();\n        $config = $grav['config']->get('system.errors');\n        $jsonRequest = $_SERVER && isset($_SERVER['HTTP_ACCEPT']) && $_SERVER['HTTP_ACCEPT'] === 'application/json';\n\n        // Setup Whoops-based error handler\n        $system = new SystemFacade;\n        $whoops = new Run($system);\n\n        $verbosity = 1;\n\n        if (isset($config['display'])) {\n            if (is_int($config['display'])) {\n                $verbosity = $config['display'];\n            } else {\n                $verbosity = $config['display'] ? 1 : 0;\n            }\n        }\n\n        switch ($verbosity) {\n            case 1:\n                $error_page = new PrettyPageHandler();\n                $error_page->setPageTitle('Crikey! There was an error...');\n                $error_page->addResourcePath(GRAV_ROOT . '/system/assets');\n                $error_page->addCustomCss('whoops.css');\n                $whoops->prependHandler($error_page);\n                break;\n            case -1:\n                $whoops->prependHandler(new BareHandler);\n                break;\n            default:\n                $whoops->prependHandler(new SimplePageHandler);\n                break;\n        }\n\n        if ($jsonRequest || Misc::isAjaxRequest()) {\n            $whoops->prependHandler(new JsonResponseHandler());\n        }\n\n        if (isset($config['log']) && $config['log']) {\n            $logger = $grav['log'];\n            $whoops->pushHandler(function ($exception, $inspector, $run) use ($logger) {\n                try {\n                    $logger->addCritical($exception->getMessage() . ' - Trace: ' . $exception->getTraceAsString());\n                } catch (Exception $e) {\n                    echo $e;\n                }\n            });\n        }\n\n        $whoops->register();\n\n        // Re-register deprecation handler.\n        $grav['debugger']->setErrorHandler();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Errors/Resources/error.css",
    "content": "html, body {\n    height: 100%\n}\nbody {\n    margin:0 3rem;\n    padding:0;\n    font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n    font-size: 1.5rem;\n    line-height: 1.4;\n    display: -webkit-box;      /* OLD - iOS 6-, Safari 3.1-6 */\n    display: -moz-box;         /* OLD - Firefox 19- (buggy but mostly works) */\n    display: -ms-flexbox;      /* TWEENER - IE 10 */\n    display: -webkit-flex;     /* NEW - Chrome */\n    display: flex;\n    -webkit-align-items: center;\n    align-items: center;\n    -webkit-justify-content: center;\n    justify-content: center;\n}\n.container {\n    margin: 0rem;\n    max-width: 600px;\n    padding-bottom:5rem;\n}\n\nheader {\n    color: #000;\n    font-size: 4rem;\n    letter-spacing: 2px;\n    line-height: 1.1;\n    margin-bottom: 2rem;\n}\np {\n    font-family: Optima, Segoe, \"Segoe UI\", Candara, Calibri, Arial, sans-serif;\n    color: #666;\n}\n\nh5 {\n    font-weight: normal;\n    color: #999;\n    font-size: 1rem;\n}\n\nh6 {\n    font-weight: normal;\n    color: #999;\n}\n\ncode {\n    font-weight: bold;\n    font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Errors/Resources/layout.html.php",
    "content": "<?php\n/**\n * Layout template file for Whoops's pretty error output.\n */\n?>\n<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>Whoops there was an error!</title>\n    <style><?php echo $stylesheet ?></style>\n</head>\n<body>\n    <div class=\"container\">\n        <div class=\"details\">\n            <header>\n                Server Error\n            </header>\n\n\n\n            <p>Sorry, something went terribly wrong!</p>\n\n            <h3><?php echo $code ?> - <?php echo $message ?></h3>\n\n            <h5>For further details please review your <code>logs/</code> folder, or enable displaying of errors in your system configuration.</h5>\n        </div>\n    </div>\n</body>\n</html>\n"
  },
  {
    "path": "system/src/Grav/Common/Errors/SimplePageHandler.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Errors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Errors;\n\nuse ErrorException;\nuse InvalidArgumentException;\nuse RuntimeException;\nuse Whoops\\Handler\\Handler;\nuse Whoops\\Util\\Misc;\nuse Whoops\\Util\\TemplateHelper;\n\n/**\n * Class SimplePageHandler\n * @package Grav\\Common\\Errors\n */\nclass SimplePageHandler extends Handler\n{\n    /** @var array */\n    private $searchPaths = [];\n    /** @var array */\n    private $resourceCache = [];\n\n    public function __construct()\n    {\n        // Add the default, local resource search path:\n        $this->searchPaths[] = __DIR__ . '/Resources';\n    }\n\n    /**\n     * @return int\n     */\n    public function handle()\n    {\n        $inspector = $this->getInspector();\n\n        $helper = new TemplateHelper();\n        $templateFile = $this->getResource('layout.html.php');\n        $cssFile      = $this->getResource('error.css');\n\n        $code = $inspector->getException()->getCode();\n        if (($code >= 400) && ($code < 600)) {\n            $this->getRun()->sendHttpCode($code);\n        }\n        $message = $inspector->getException()->getMessage();\n\n        if ($inspector->getException() instanceof ErrorException) {\n            $code = Misc::translateErrorCode($code);\n        }\n\n        $vars = array(\n            'stylesheet' => file_get_contents($cssFile),\n            'code'        => $code,\n            'message'     => htmlspecialchars(strip_tags(rawurldecode($message)), ENT_QUOTES, 'UTF-8'),\n        );\n\n        $helper->setVariables($vars);\n        $helper->render($templateFile);\n\n        return Handler::QUIT;\n    }\n\n    /**\n     * @param string $resource\n     * @return string\n     * @throws RuntimeException\n     */\n    protected function getResource($resource)\n    {\n        // If the resource was found before, we can speed things up\n        // by caching its absolute, resolved path:\n        if (isset($this->resourceCache[$resource])) {\n            return $this->resourceCache[$resource];\n        }\n\n        // Search through available search paths, until we find the\n        // resource we're after:\n        foreach ($this->searchPaths as $path) {\n            $fullPath = \"{$path}/{$resource}\";\n\n            if (is_file($fullPath)) {\n                // Cache the result:\n                $this->resourceCache[$resource] = $fullPath;\n                return $fullPath;\n            }\n        }\n\n        // If we got this far, nothing was found.\n        throw new RuntimeException(\n            \"Could not find resource '{$resource}' in any resource paths (searched: \" . implode(', ', $this->searchPaths). ')'\n        );\n    }\n\n    /**\n     * @param string $path\n     * @return void\n     */\n    public function addResourcePath($path)\n    {\n        if (!is_dir($path)) {\n            throw new InvalidArgumentException(\n                \"'{$path}' is not a valid directory\"\n            );\n        }\n\n        array_unshift($this->searchPaths, $path);\n    }\n\n    /**\n     * @return array\n     */\n    public function getResourcePaths()\n    {\n        return $this->searchPaths;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Errors/SystemFacade.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Errors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Errors;\n\n/**\n * Class SystemFacade\n * @package Grav\\Common\\Errors\n */\nclass SystemFacade extends \\Whoops\\Util\\SystemFacade\n{\n    /** @var callable */\n    protected $whoopsShutdownHandler;\n\n    /**\n     * @param callable $function\n     * @return void\n     */\n    public function registerShutdownFunction(callable $function)\n    {\n        $this->whoopsShutdownHandler = $function;\n        register_shutdown_function([$this, 'handleShutdown']);\n    }\n\n    /**\n     * Special case to deal with Fatal errors and the like.\n     *\n     * @return void\n     */\n    public function handleShutdown()\n    {\n        $error = $this->getLastError();\n\n        // Ignore core warnings and errors.\n        if ($error && !($error['type'] & (E_CORE_WARNING | E_CORE_ERROR))) {\n            $handler = $this->whoopsShutdownHandler;\n            $handler();\n        }\n    }\n\n\n    /**\n     * @param int $httpCode\n     *\n     * @return int\n     */\n    public function setHttpResponseCode($httpCode)\n    {\n        if (!headers_sent()) {\n            // Ensure that no 'location' header is present as otherwise this\n            // will override the HTTP code being set here, and mask the\n            // expected error page.\n            header_remove('location');\n\n            // Work around PHP bug #8218 (8.0.17 & 8.1.4).\n            header_remove('Content-Encoding');\n        }\n\n        return http_response_code($httpCode);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/File/CompiledFile.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\File;\n\nuse Exception;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\File\\PhpFile;\nuse RuntimeException;\nuse Throwable;\nuse function function_exists;\nuse function get_class;\n\n/**\n * Trait CompiledFile\n * @package Grav\\Common\\File\n */\ntrait CompiledFile\n{\n    /**\n     * Get/set parsed file contents.\n     *\n     * @param mixed $var\n     * @return array\n     */\n    public function content($var = null)\n    {\n        try {\n            $filename = $this->filename;\n            // If nothing has been loaded, attempt to get pre-compiled version of the file first.\n            if ($var === null && $this->raw === null && $this->content === null) {\n                $key = md5($filename);\n                $file = PhpFile::instance(CACHE_DIR . \"compiled/files/{$key}{$this->extension}.php\");\n\n                $modified = $this->modified();\n                if (!$modified) {\n                    try {\n                        return $this->decode($this->raw());\n                    } catch (Throwable $e) {\n                        // If the compiled file is broken, we can safely ignore the error and continue.\n                    }\n                }\n\n                $class = get_class($this);\n\n                $size = filesize($filename);\n                $cache = $file->exists() ? $file->content() : null;\n\n                // Load real file if cache isn't up to date (or is invalid).\n                if (!isset($cache['@class'])\n                    || $cache['@class'] !== $class\n                    || $cache['modified'] !== $modified\n                    || ($cache['size'] ?? null) !== $size\n                    || $cache['filename'] !== $filename\n                ) {\n                    // Attempt to lock the file for writing.\n                    try {\n                        $locked = $file->lock(false);\n                    } catch (Exception $e) {\n                        $locked = false;\n\n                        /** @var Debugger $debugger */\n                        $debugger = Grav::instance()['debugger'];\n                        $debugger->addMessage(sprintf('%s(): Cannot obtain a lock for compiling cache file for %s: %s', __METHOD__, $this->filename, $e->getMessage()), 'warning');\n                    }\n\n                    // Decode RAW file into compiled array.\n                    $data = (array)$this->decode($this->raw());\n                    $cache = [\n                        '@class' => $class,\n                        'filename' => $filename,\n                        'modified' => $modified,\n                        'size' => $size,\n                        'data' => $data\n                    ];\n\n                    // If compiled file wasn't already locked by another process, save it.\n                    if ($locked) {\n                        $file->save($cache);\n                        $file->unlock();\n\n                        // Compile cached file into bytecode cache\n                        if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \\FILTER_VALIDATE_BOOLEAN)) {\n                            $lockName = $file->filename();\n\n                            // Silence error if function exists, but is restricted.\n                            @opcache_invalidate($lockName, true);\n                            @opcache_compile_file($lockName);\n                        }\n                    }\n                }\n                $file->free();\n\n                $this->content = $cache['data'];\n            }\n        } catch (Exception $e) {\n            throw new RuntimeException(sprintf('Failed to read %s: %s', Utils::basename($filename), $e->getMessage()), 500, $e);\n        }\n\n        return parent::content($var);\n    }\n\n    /**\n     * Save file.\n     *\n     * @param  mixed  $data  Optional data to be saved, usually array.\n     * @return void\n     * @throws RuntimeException\n     */\n    public function save($data = null)\n    {\n        // Make sure that the cache file is always up to date!\n        $key = md5($this->filename);\n        $file = PhpFile::instance(CACHE_DIR . \"compiled/files/{$key}{$this->extension}.php\");\n        try {\n            $locked = $file->lock();\n        } catch (Exception $e) {\n            $locked = false;\n\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addMessage(sprintf('%s(): Cannot obtain a lock for compiling cache file for %s: %s', __METHOD__, $this->filename, $e->getMessage()), 'warning');\n        }\n\n        parent::save($data);\n\n        if ($locked) {\n            $modified = $this->modified();\n            $filename = $this->filename;\n            $class = get_class($this);\n            $size = filesize($filename);\n\n            // windows doesn't play nicely with this as it can't read when locked\n            if (!Utils::isWindows()) {\n                // Reload data from the filesystem. This ensures that we always cache the correct data (see issue #2282).\n                $this->raw = $this->content = null;\n                $data = (array)$this->decode($this->raw());\n            }\n\n            // Decode data into compiled array.\n            $cache = [\n                '@class' => $class,\n                'filename' => $filename,\n                'modified' => $modified,\n                'size' => $size,\n                'data' => $data\n            ];\n\n            $file->save($cache);\n            $file->unlock();\n\n            // Compile cached file into bytecode cache\n            if (function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \\FILTER_VALIDATE_BOOLEAN)) {\n                $lockName = $file->filename();\n                // Silence error if function exists, but is restricted.\n                @opcache_invalidate($lockName, true);\n                @opcache_compile_file($lockName);\n            }\n        }\n    }\n\n    /**\n     * Serialize file.\n     *\n     * @return array\n     */\n    public function __sleep()\n    {\n        return [\n            'filename',\n            'extension',\n            'raw',\n            'content',\n            'settings'\n        ];\n    }\n\n    /**\n     * Unserialize file.\n     */\n    public function __wakeup()\n    {\n        if (!isset(static::$instances[$this->filename])) {\n            static::$instances[$this->filename] = $this;\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/File/CompiledJsonFile.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\File;\n\nuse RocketTheme\\Toolbox\\File\\JsonFile;\n\n/**\n * Class CompiledJsonFile\n * @package Grav\\Common\\File\n */\nclass CompiledJsonFile extends JsonFile\n{\n    use CompiledFile;\n\n    /**\n     * Decode RAW string into contents.\n     *\n     * @param string $var\n     * @param bool $assoc\n     * @return array\n     */\n    protected function decode($var, $assoc = true)\n    {\n        return (array)json_decode($var, $assoc);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/File/CompiledMarkdownFile.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\File;\n\nuse RocketTheme\\Toolbox\\File\\MarkdownFile;\n\n/**\n * Class CompiledMarkdownFile\n * @package Grav\\Common\\File\n */\nclass CompiledMarkdownFile extends MarkdownFile\n{\n    use CompiledFile;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/File/CompiledYamlFile.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\File;\n\nuse RocketTheme\\Toolbox\\File\\YamlFile;\n\n/**\n * Class CompiledYamlFile\n * @package Grav\\Common\\File\n */\nclass CompiledYamlFile extends YamlFile\n{\n    use CompiledFile;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Filesystem/Archiver.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Filesystem\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Filesystem;\n\nuse FilesystemIterator;\nuse Grav\\Common\\Utils;\nuse RecursiveDirectoryIterator;\nuse RecursiveIteratorIterator;\nuse function function_exists;\n\n/**\n * Class Archiver\n * @package Grav\\Common\\Filesystem\n */\nabstract class Archiver\n{\n    /** @var array */\n    protected $options = [\n        'exclude_files' => ['.DS_Store'],\n        'exclude_paths' => []\n    ];\n\n    /** @var string */\n    protected $archive_file;\n\n    /**\n     * @param string $compression\n     * @return ZipArchiver\n     */\n    public static function create($compression)\n    {\n        if ($compression === 'zip') {\n            return new ZipArchiver();\n        }\n\n        return new ZipArchiver();\n    }\n\n    /**\n     * @param string $archive_file\n     * @return $this\n     */\n    public function setArchive($archive_file)\n    {\n        $this->archive_file = $archive_file;\n\n        return $this;\n    }\n\n    /**\n     * @param array $options\n     * @return $this\n     */\n    public function setOptions($options)\n    {\n        // Set infinite PHP execution time if possible.\n        if (Utils::functionExists('set_time_limit')) {\n            @set_time_limit(0);\n        }\n\n        $this->options = $options + $this->options;\n\n        return $this;\n    }\n\n    /**\n     * @param string $folder\n     * @param callable|null $status\n     * @return $this\n     */\n    abstract public function compress($folder, callable $status = null);\n\n    /**\n     * @param string $destination\n     * @param callable|null $status\n     * @return $this\n     */\n    abstract public function extract($destination, callable $status = null);\n\n    /**\n     * @param array $folders\n     * @param callable|null $status\n     * @return $this\n     */\n    abstract public function addEmptyFolders($folders, callable $status = null);\n\n    /**\n     * @param string $rootPath\n     * @return RecursiveIteratorIterator\n     */\n    protected function getArchiveFiles($rootPath)\n    {\n        $exclude_paths = $this->options['exclude_paths'];\n        $exclude_files = $this->options['exclude_files'];\n        $dirItr    = new RecursiveDirectoryIterator($rootPath, RecursiveDirectoryIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS | FilesystemIterator::UNIX_PATHS);\n        $filterItr = new RecursiveDirectoryFilterIterator($dirItr, $rootPath, $exclude_paths, $exclude_files);\n        $files     = new RecursiveIteratorIterator($filterItr, RecursiveIteratorIterator::SELF_FIRST);\n\n        return $files;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Filesystem/Folder.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Filesystem\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Filesystem;\n\nuse DirectoryIterator;\nuse Exception;\nuse FilesystemIterator;\nuse Grav\\Common\\Grav;\nuse RecursiveDirectoryIterator;\nuse RecursiveIteratorIterator;\nuse RegexIterator;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function count;\nuse function dirname;\nuse function is_callable;\n\n/**\n * Class Folder\n * @package Grav\\Common\\Filesystem\n */\nabstract class Folder\n{\n    /**\n     * Recursively find the last modified time under given path.\n     *\n     * @param array $paths\n     * @return int\n     */\n    public static function lastModifiedFolder(array $paths): int\n    {\n        $last_modified = 0;\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $flags = RecursiveDirectoryIterator::SKIP_DOTS;\n\n        foreach ($paths as $path) {\n            if (!file_exists($path)) {\n                return 0;\n            }\n            if ($locator->isStream($path)) {\n                $directory = $locator->getRecursiveIterator($path, $flags);\n            } else {\n                $directory = new RecursiveDirectoryIterator($path, $flags);\n            }\n            $filter  = new RecursiveFolderFilterIterator($directory);\n            $iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);\n\n            foreach ($iterator as $dir) {\n                $dir_modified = $dir->getMTime();\n                if ($dir_modified > $last_modified) {\n                    $last_modified = $dir_modified;\n                }\n            }\n        }\n\n        return $last_modified;\n    }\n\n    /**\n     * Recursively find the last modified time under given path by file.\n     *\n     * @param array  $paths\n     * @param string  $extensions   which files to search for specifically\n     * @return int\n     */\n    public static function lastModifiedFile(array $paths, $extensions = 'md|yaml'): int\n    {\n        $last_modified = 0;\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $flags = RecursiveDirectoryIterator::SKIP_DOTS;\n\n        foreach($paths as $path) {\n            if (!file_exists($path)) {\n                return 0;\n            }\n            if ($locator->isStream($path)) {\n                $directory = $locator->getRecursiveIterator($path, $flags);\n            } else {\n                $directory = new RecursiveDirectoryIterator($path, $flags);\n            }\n            $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);\n            $iterator = new RegexIterator($recursive, '/^.+\\.'.$extensions.'$/i');\n\n            /** @var RecursiveDirectoryIterator $file */\n            foreach ($iterator as $file) {\n                try {\n                    $file_modified = $file->getMTime();\n                    if ($file_modified > $last_modified) {\n                        $last_modified = $file_modified;\n                    }\n                } catch (Exception $e) {\n                    Grav::instance()['log']->error('Could not process file: ' . $e->getMessage());\n                }\n            }\n        }\n\n        return $last_modified;\n    }\n\n    /**\n     * Recursively md5 hash all files in a path\n     *\n     * @param array $paths\n     * @return string\n     */\n    public static function hashAllFiles(array $paths): string\n    {\n        $files = [];\n\n        foreach ($paths as $path) {\n            if (file_exists($path)) {\n                $flags = RecursiveDirectoryIterator::SKIP_DOTS;\n\n                /** @var UniformResourceLocator $locator */\n                $locator = Grav::instance()['locator'];\n                if ($locator->isStream($path)) {\n                    $directory = $locator->getRecursiveIterator($path, $flags);\n                } else {\n                    $directory = new RecursiveDirectoryIterator($path, $flags);\n                }\n\n                $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);\n\n                foreach ($iterator as $file) {\n                    $files[] = $file->getPathname() . '?'. $file->getMTime();\n                }\n            }\n        }\n\n        return md5(serialize($files));\n    }\n\n    /**\n     * Get relative path between target and base path. If path isn't relative, return full path.\n     *\n     * @param string $path\n     * @param string $base\n     * @return string\n     */\n    public static function getRelativePath($path, $base = GRAV_ROOT)\n    {\n        if ($base) {\n            $base = preg_replace('![\\\\\\/]+!', '/', $base);\n            $path = preg_replace('![\\\\\\/]+!', '/', $path);\n            if (strpos($path, $base) === 0) {\n                $path = ltrim(substr($path, strlen($base)), '/');\n            }\n        }\n\n        return $path;\n    }\n\n    /**\n     * Get relative path between target and base path. If path isn't relative, return full path.\n     *\n     * @param  string  $path\n     * @param  string  $base\n     * @return string\n     */\n    public static function getRelativePathDotDot($path, $base)\n    {\n        // Normalize paths.\n        $base = preg_replace('![\\\\\\/]+!', '/', $base);\n        $path = preg_replace('![\\\\\\/]+!', '/', $path);\n\n        if ($path === $base) {\n            return '';\n        }\n\n        $baseParts = explode('/', ltrim($base, '/'));\n        $pathParts = explode('/', ltrim($path, '/'));\n\n        array_pop($baseParts);\n        $lastPart = array_pop($pathParts);\n        foreach ($baseParts as $i => $directory) {\n            if (isset($pathParts[$i]) && $pathParts[$i] === $directory) {\n                unset($baseParts[$i], $pathParts[$i]);\n            } else {\n                break;\n            }\n        }\n        $pathParts[] = $lastPart;\n        $path = str_repeat('../', count($baseParts)) . implode('/', $pathParts);\n\n        return '' === $path\n        || strpos($path, '/') === 0\n        || false !== ($colonPos = strpos($path, ':')) && ($colonPos < ($slashPos = strpos($path, '/')) || false === $slashPos)\n            ? \"./$path\" : $path;\n    }\n\n    /**\n     * Shift first directory out of the path.\n     *\n     * @param string $path\n     * @return string|null\n     */\n    public static function shift(&$path)\n    {\n        $parts = explode('/', trim($path, '/'), 2);\n        $result = array_shift($parts);\n        $path = array_shift($parts);\n\n        return $result ?: null;\n    }\n\n    /**\n     * Return recursive list of all files and directories under given path.\n     *\n     * @param  string            $path\n     * @param  array             $params\n     * @return array\n     * @throws RuntimeException\n     */\n    public static function all($path, array $params = [])\n    {\n        if (!$path) {\n            throw new RuntimeException(\"Path doesn't exist.\");\n        }\n        if (!file_exists($path)) {\n            return [];\n        }\n\n        $compare = isset($params['compare']) ? 'get' . $params['compare'] : null;\n        $pattern = $params['pattern'] ?? null;\n        $filters = $params['filters'] ?? null;\n        $recursive = $params['recursive'] ?? true;\n        $levels = $params['levels'] ?? -1;\n        $key = isset($params['key']) ? 'get' . $params['key'] : null;\n        $value = 'get' . ($params['value'] ?? ($recursive ? 'SubPathname' : 'Filename'));\n        $folders = $params['folders'] ?? true;\n        $files = $params['files'] ?? true;\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        if ($recursive) {\n            $flags = RecursiveDirectoryIterator::SKIP_DOTS + FilesystemIterator::UNIX_PATHS\n                + FilesystemIterator::CURRENT_AS_SELF + FilesystemIterator::FOLLOW_SYMLINKS;\n            if ($locator->isStream($path)) {\n                $directory = $locator->getRecursiveIterator($path, $flags);\n            } else {\n                $directory = new RecursiveDirectoryIterator($path, $flags);\n            }\n            $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);\n            $iterator->setMaxDepth(max($levels, -1));\n        } else {\n            if ($locator->isStream($path)) {\n                $iterator = $locator->getIterator($path);\n            } else {\n                $iterator = new FilesystemIterator($path);\n            }\n        }\n\n        $results = [];\n\n        /** @var RecursiveDirectoryIterator $file */\n        foreach ($iterator as $file) {\n            // Ignore hidden files.\n            if (strpos($file->getFilename(), '.') === 0 && $file->isFile()) {\n                continue;\n            }\n            if (!$folders && $file->isDir()) {\n                continue;\n            }\n            if (!$files && $file->isFile()) {\n                continue;\n            }\n            if ($compare && $pattern && !preg_match($pattern, $file->{$compare}())) {\n                continue;\n            }\n            $fileKey = $key ? $file->{$key}() : null;\n            $filePath = $file->{$value}();\n            if ($filters) {\n                if (isset($filters['key'])) {\n                    $pre = !empty($filters['pre-key']) ? $filters['pre-key'] : '';\n                    $fileKey = $pre . preg_replace($filters['key'], '', $fileKey);\n                }\n                if (isset($filters['value'])) {\n                    $filter = $filters['value'];\n                    if (is_callable($filter)) {\n                        $filePath = $filter($file);\n                    } else {\n                        $filePath = preg_replace($filter, '', $filePath);\n                    }\n                }\n            }\n\n            if ($fileKey !== null) {\n                $results[$fileKey] = $filePath;\n            } else {\n                $results[] = $filePath;\n            }\n        }\n\n        return $results;\n    }\n\n    /**\n     * Recursively copy directory in filesystem.\n     *\n     * @param  string $source\n     * @param  string $target\n     * @param  string|null $ignore  Ignore files matching pattern (regular expression).\n     * @return void\n     * @throws RuntimeException\n     */\n    public static function copy($source, $target, $ignore = null)\n    {\n        $source = rtrim($source, '\\\\/');\n        $target = rtrim($target, '\\\\/');\n\n        if (!is_dir($source)) {\n            throw new RuntimeException('Cannot copy non-existing folder.');\n        }\n\n        // Make sure that path to the target exists before copying.\n        self::create($target);\n\n        $success = true;\n\n        // Go through all sub-directories and copy everything.\n        $files = self::all($source);\n        foreach ($files as $file) {\n            if ($ignore && preg_match($ignore, $file)) {\n                continue;\n            }\n            $src = $source .'/'. $file;\n            $dst = $target .'/'. $file;\n\n            if (is_dir($src)) {\n                // Create current directory (if it doesn't exist).\n                if (!is_dir($dst)) {\n                    $success &= @mkdir($dst, 0777, true);\n                }\n            } else {\n                // Or copy current file.\n                $success &= @copy($src, $dst);\n            }\n        }\n\n        if (!$success) {\n            $error = error_get_last();\n            throw new RuntimeException($error['message'] ?? 'Unknown error');\n        }\n\n        // Make sure that the change will be detected when caching.\n        @touch(dirname($target));\n    }\n\n    /**\n     * Move directory in filesystem.\n     *\n     * @param  string $source\n     * @param  string $target\n     * @return void\n     * @throws RuntimeException\n     */\n    public static function move($source, $target)\n    {\n        if (!file_exists($source) || !is_dir($source)) {\n            // Rename fails if source folder does not exist.\n            throw new RuntimeException('Cannot move non-existing folder.');\n        }\n\n        // Don't do anything if the source is the same as the new target\n        if ($source === $target) {\n            return;\n        }\n\n        if (strpos($target, $source . '/') === 0) {\n            throw new RuntimeException('Cannot move folder to itself');\n        }\n\n        if (file_exists($target)) {\n            // Rename fails if target folder exists.\n            throw new RuntimeException('Cannot move files to existing folder/file.');\n        }\n\n        // Make sure that path to the target exists before moving.\n        self::create(dirname($target));\n\n        // Silence warnings (chmod failed etc).\n        @rename($source, $target);\n\n        // Rename function can fail while still succeeding, so let's check if the folder exists.\n        if (is_dir($source)) {\n            // Rename doesn't support moving folders across filesystems. Use copy instead.\n            self::copy($source, $target);\n            self::delete($source);\n        }\n\n        // Make sure that the change will be detected when caching.\n        @touch(dirname($source));\n        @touch(dirname($target));\n        @touch($target);\n    }\n\n    /**\n     * Recursively delete directory from filesystem.\n     *\n     * @param  string $target\n     * @param  bool   $include_target\n     * @return bool\n     * @throws RuntimeException\n     */\n    public static function delete($target, $include_target = true)\n    {\n        if (!is_dir($target)) {\n            return false;\n        }\n\n        $success = self::doDelete($target, $include_target);\n\n        if (!$success) {\n            $error = error_get_last();\n\n            throw new RuntimeException($error['message'] ?? 'Unknown error');\n        }\n\n        // Make sure that the change will be detected when caching.\n        if ($include_target) {\n            @touch(dirname($target));\n        } else {\n            @touch($target);\n        }\n\n        return $success;\n    }\n\n    /**\n     * @param  string  $folder\n     * @return void\n     * @throws RuntimeException\n     */\n    public static function mkdir($folder)\n    {\n        self::create($folder);\n    }\n\n    /**\n     * @param  string  $folder\n     * @return void\n     * @throws RuntimeException\n     */\n    public static function create($folder)\n    {\n        // Silence error for open_basedir; should fail in mkdir instead.\n        if (@is_dir($folder)) {\n            return;\n        }\n\n        $success = @mkdir($folder, 0777, true);\n\n        if (!$success) {\n            // Take yet another look, make sure that the folder doesn't exist.\n            clearstatcache(true, $folder);\n            if (!@is_dir($folder)) {\n                throw new RuntimeException(sprintf('Unable to create directory: %s', $folder));\n            }\n        }\n    }\n\n    /**\n     * Recursive copy of one directory to another\n     *\n     * @param string $src\n     * @param string $dest\n     * @return bool\n     * @throws RuntimeException\n     */\n    public static function rcopy($src, $dest)\n    {\n\n        // If the src is not a directory do a simple file copy\n        if (!is_dir($src)) {\n            copy($src, $dest);\n            return true;\n        }\n\n        // If the destination directory does not exist create it\n        if (!is_dir($dest)) {\n            static::create($dest);\n        }\n\n        // Open the source directory to read in files\n        $i = new DirectoryIterator($src);\n        foreach ($i as $f) {\n            if ($f->isFile()) {\n                copy($f->getRealPath(), \"{$dest}/\" . $f->getFilename());\n            } else {\n                if (!$f->isDot() && $f->isDir()) {\n                    static::rcopy($f->getRealPath(), \"{$dest}/{$f}\");\n                }\n            }\n        }\n        return true;\n    }\n\n    /**\n     * Does a directory contain children\n     *\n     * @param string $directory\n     * @return int|false\n     */\n    public static function countChildren($directory)\n    {\n        if (!is_dir($directory)) {\n            return false;\n        }\n        $directories = glob($directory . '/*', GLOB_ONLYDIR);\n\n        return $directories ? count($directories) : false;\n    }\n\n    /**\n     * @param  string $folder\n     * @param  bool   $include_target\n     * @return bool\n     * @internal\n     */\n    protected static function doDelete($folder, $include_target = true)\n    {\n        // Special case for symbolic links.\n        if ($include_target && is_link($folder)) {\n            return @unlink($folder);\n        }\n\n        // Go through all items in filesystem and recursively remove everything.\n        $files = scandir($folder, SCANDIR_SORT_NONE);\n        $files = $files ? array_diff($files, ['.', '..']) : [];\n        foreach ($files as $file) {\n            $path = \"{$folder}/{$file}\";\n            is_dir($path) ? self::doDelete($path) : @unlink($path);\n        }\n\n        return $include_target ? @rmdir($folder) : true;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Filesystem/RecursiveDirectoryFilterIterator.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Filesystem\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Filesystem;\n\nuse RecursiveFilterIterator;\nuse RecursiveIterator;\nuse SplFileInfo;\nuse function in_array;\n\n/**\n * Class RecursiveDirectoryFilterIterator\n * @package Grav\\Common\\Filesystem\n */\nclass RecursiveDirectoryFilterIterator extends RecursiveFilterIterator\n{\n    /** @var string */\n    protected static $root;\n    /** @var array */\n    protected static $ignore_folders;\n    /** @var array */\n    protected static $ignore_files;\n\n    /**\n     * Create a RecursiveFilterIterator from a RecursiveIterator\n     *\n     * @param RecursiveIterator $iterator\n     * @param string $root\n     * @param array $ignore_folders\n     * @param array $ignore_files\n     */\n    public function __construct(RecursiveIterator $iterator, $root, $ignore_folders, $ignore_files)\n    {\n        parent::__construct($iterator);\n\n        $this::$root = $root;\n        $this::$ignore_folders = $ignore_folders;\n        $this::$ignore_files = $ignore_files;\n    }\n\n    /**\n     * Check whether the current element of the iterator is acceptable\n     *\n     * @return bool true if the current element is acceptable, otherwise false.\n     */\n    public function accept() :bool\n    {\n        /** @var SplFileInfo $file */\n        $file = $this->current();\n        $filename = $file->getFilename();\n        $relative_filename = str_replace($this::$root . '/', '', $file->getPathname());\n\n        if ($file->isDir()) {\n            // Check if the directory path is in the ignore list\n            if (in_array($relative_filename, $this::$ignore_folders, true)) {\n                return false;\n            }\n            // Check if any parent directory is in the ignore list\n            foreach ($this::$ignore_folders as $ignore_folder) {\n                $ignore_folder = trim($ignore_folder, '/');\n                if (strpos($relative_filename, $ignore_folder . '/') === 0 || $relative_filename === $ignore_folder) {\n                    return false;\n                }\n            }\n            if (!$this->matchesPattern($filename, $this::$ignore_files)) {\n                return true;\n            }\n        } elseif ($file->isFile() && !$this->matchesPattern($filename, $this::$ignore_files)) {\n            return true;\n        }\n        return false;\n    }\n    \n    /**\n     * Check if filename matches any pattern in the list\n     *\n     * @param string $filename\n     * @param array $patterns\n     * @return bool\n     */\n    protected function matchesPattern($filename, $patterns)\n    {\n        foreach ($patterns as $pattern) {\n            // Check for exact match\n            if ($filename === $pattern) {\n                return true;\n            }\n            // Check for extension patterns like .pdf\n            if (strpos($pattern, '.') === 0 && substr($filename, -strlen($pattern)) === $pattern) {\n                return true;\n            }\n            // Check for wildcard patterns\n            if (strpos($pattern, '*') !== false) {\n                $regex = '/^' . str_replace('\\\\*', '.*', preg_quote($pattern, '/')) . '$/';\n                if (preg_match($regex, $filename)) {\n                    return true;\n                }\n            }\n        }\n        return false;\n    }\n\n    /**\n     * @return RecursiveDirectoryFilterIterator|RecursiveFilterIterator\n     */\n    public function getChildren() :RecursiveFilterIterator\n    {\n        /** @var RecursiveDirectoryFilterIterator $iterator */\n        $iterator = $this->getInnerIterator();\n\n        return new self($iterator->getChildren(), $this::$root, $this::$ignore_folders, $this::$ignore_files);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Filesystem/RecursiveFolderFilterIterator.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Filesystem\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Filesystem;\n\nuse Grav\\Common\\Grav;\nuse RecursiveIterator;\nuse SplFileInfo;\nuse function in_array;\n\n/**\n * Class RecursiveFolderFilterIterator\n * @package Grav\\Common\\Filesystem\n */\nclass RecursiveFolderFilterIterator extends \\RecursiveFilterIterator\n{\n    /** @var array */\n    protected static $ignore_folders;\n\n    /**\n     * Create a RecursiveFilterIterator from a RecursiveIterator\n     *\n     * @param RecursiveIterator $iterator\n     * @param array $ignore_folders\n     */\n    public function __construct(RecursiveIterator $iterator, $ignore_folders = [])\n    {\n        parent::__construct($iterator);\n\n        if (empty($ignore_folders)) {\n            $ignore_folders = Grav::instance()['config']->get('system.pages.ignore_folders');\n        }\n\n        $this::$ignore_folders = $ignore_folders;\n    }\n\n    /**\n     * Check whether the current element of the iterator is acceptable\n     *\n     * @return bool true if the current element is acceptable, otherwise false.\n     */\n    public function accept() :bool\n    {\n        /** @var SplFileInfo $current */\n        $current = $this->current();\n\n        return $current->isDir() && !in_array($current->getFilename(), $this::$ignore_folders, true);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Filesystem/ZipArchiver.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Filesystem\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Filesystem;\n\nuse InvalidArgumentException;\nuse RuntimeException;\nuse ZipArchive;\nuse function extension_loaded;\nuse function strlen;\n\n/**\n * Class ZipArchiver\n * @package Grav\\Common\\Filesystem\n */\nclass ZipArchiver extends Archiver\n{\n    /**\n     * @param string $destination\n     * @param callable|null $status\n     * @return $this\n     */\n    public function extract($destination, callable $status = null)\n    {\n        $zip = new ZipArchive();\n        $archive = $zip->open($this->archive_file);\n\n        if ($archive === true) {\n            Folder::create($destination);\n\n            if (!$zip->extractTo($destination)) {\n                throw new RuntimeException('ZipArchiver: ZIP failed to extract ' . $this->archive_file . ' to ' . $destination);\n            }\n\n            $zip->close();\n\n            return $this;\n        }\n\n        throw new RuntimeException('ZipArchiver: Failed to open ' . $this->archive_file);\n    }\n\n    /**\n     * @param string $source\n     * @param callable|null $status\n     * @return $this\n     */\n    public function compress($source, callable $status = null)\n    {\n        if (!extension_loaded('zip')) {\n            throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...');\n        }\n\n        // Get real path for our folder\n        $rootPath = realpath($source);\n        if (!$rootPath) {\n            throw new InvalidArgumentException('ZipArchiver: ' . $source . ' cannot be found...');\n        }\n\n        $zip = new ZipArchive();\n        $result = $zip->open($this->archive_file, ZipArchive::CREATE);\n        if ($result !== true) {\n            $error = 'unknown error';\n            if ($result === ZipArchive::ER_NOENT) {\n                $error = 'file does not exist';\n            } elseif ($result === ZipArchive::ER_EXISTS) {\n                $error = 'file already exists';\n            } elseif ($result === ZipArchive::ER_OPEN) {\n                $error = 'cannot open file';\n            } elseif ($result === ZipArchive::ER_READ) {\n                $error = 'read error';\n            } elseif ($result === ZipArchive::ER_SEEK) {\n                $error = 'seek error';\n            }\n            throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be created: ' . $error);\n        }\n\n        $files = $this->getArchiveFiles($rootPath);\n\n        $status && $status([\n            'type' => 'count',\n            'steps' => iterator_count($files),\n        ]);\n\n        foreach ($files as $file) {\n            $filePath = $file->getPathname();\n            $relativePath = ltrim(substr($filePath, strlen($rootPath)), '/');\n\n            if ($file->isDir()) {\n                $zip->addEmptyDir($relativePath);\n            } else {\n                $zip->addFile($filePath, $relativePath);\n            }\n\n            $status && $status([\n                'type' => 'progress',\n            ]);\n        }\n\n        $status && $status([\n            'type' => 'message',\n            'message' => 'Compressing...'\n        ]);\n\n        $zip->close();\n\n        return $this;\n    }\n\n    /**\n     * @param array $folders\n     * @param callable|null $status\n     * @return $this\n     */\n    public function addEmptyFolders($folders, callable $status = null)\n    {\n        if (!extension_loaded('zip')) {\n            throw new InvalidArgumentException('ZipArchiver: Zip PHP module not installed...');\n        }\n\n        $zip = new ZipArchive();\n        $result = $zip->open($this->archive_file);\n        if ($result !== true) {\n            $error = 'unknown error';\n            if ($result === ZipArchive::ER_NOENT) {\n                $error = 'file does not exist';\n            } elseif ($result === ZipArchive::ER_EXISTS) {\n                $error = 'file already exists';\n            } elseif ($result === ZipArchive::ER_OPEN) {\n                $error = 'cannot open file';\n            } elseif ($result === ZipArchive::ER_READ) {\n                $error = 'read error';\n            } elseif ($result === ZipArchive::ER_SEEK) {\n                $error = 'seek error';\n            }\n            throw new InvalidArgumentException('ZipArchiver: ' . $this->archive_file . ' cannot be opened: ' . $error);\n        }\n\n        $status && $status([\n            'type' => 'message',\n            'message' => 'Adding empty folders...'\n        ]);\n\n        foreach ($folders as $folder) {\n            if ($zip->addEmptyDir($folder) === false) {\n                $status && $status([\n                    'type' => 'message',\n                    'message' => 'Warning: Could not add empty directory: ' . $folder\n                ]);\n            }\n            $status && $status([\n                'type' => 'progress',\n            ]);\n        }\n\n        $zip->close();\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/FlexCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex;\n\nuse Grav\\Common\\Flex\\Traits\\FlexCollectionTrait;\nuse Grav\\Common\\Flex\\Traits\\FlexGravTrait;\n\n/**\n * Class FlexCollection\n *\n * @package Grav\\Common\\Flex\n * @template T of \\Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface\n * @extends \\Grav\\Framework\\Flex\\FlexCollection<T>\n */\nabstract class FlexCollection extends \\Grav\\Framework\\Flex\\FlexCollection\n{\n    use FlexGravTrait;\n    use FlexCollectionTrait;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/FlexIndex.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex;\n\nuse Grav\\Common\\Flex\\Traits\\FlexGravTrait;\nuse Grav\\Common\\Flex\\Traits\\FlexIndexTrait;\n\n/**\n * Class FlexIndex\n *\n * @package Grav\\Common\\Flex\n * @template T of \\Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface\n * @template C of \\Grav\\Framework\\Flex\\Interfaces\\FlexCollectionInterface\n * @extends \\Grav\\Framework\\Flex\\FlexIndex<T,C>\n */\nabstract class FlexIndex extends \\Grav\\Framework\\Flex\\FlexIndex\n{\n    use FlexGravTrait;\n    use FlexIndexTrait;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/FlexObject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex;\n\nuse Grav\\Common\\Flex\\Traits\\FlexGravTrait;\nuse Grav\\Common\\Flex\\Traits\\FlexObjectTrait;\nuse Grav\\Common\\Media\\Interfaces\\MediaInterface;\nuse Grav\\Framework\\Flex\\Traits\\FlexMediaTrait;\nuse function is_array;\n\n/**\n * Class FlexObject\n *\n * @package Grav\\Common\\Flex\n */\nabstract class FlexObject extends \\Grav\\Framework\\Flex\\FlexObject implements MediaInterface\n{\n    use FlexGravTrait;\n    use FlexObjectTrait;\n    use FlexMediaTrait;\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getFormValue()\n     */\n    public function getFormValue(string $name, $default = null, string $separator = null)\n    {\n        $value = $this->getNestedProperty($name, null, $separator);\n\n        // Handle media order field.\n        if (null === $value && $name === 'media_order') {\n            return implode(',', $this->getMediaOrder());\n        }\n\n        // Handle media fields.\n        $settings = $this->getFieldSettings($name);\n        if (($settings['media_field'] ?? false) === true) {\n            return $this->parseFileProperty($value, $settings);\n        }\n\n        return $value ?? $default;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::prepareStorage()\n     */\n    public function prepareStorage(): array\n    {\n        // Remove extra content from media fields.\n        $fields = $this->getMediaFields();\n        foreach ($fields as $field) {\n            $data = $this->getNestedProperty($field);\n            if (is_array($data)) {\n                foreach ($data as $name => &$image) {\n                    unset($image['image_url'], $image['thumb_url']);\n                }\n                unset($image);\n                $this->setNestedProperty($field, $data);\n            }\n        }\n\n        return parent::prepareStorage();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Traits/FlexCollectionTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Traits;\n\nuse RocketTheme\\Toolbox\\Event\\Event;\n\n/**\n * Trait FlexCollectionTrait\n * @package Grav\\Common\\Flex\\Traits\n */\ntrait FlexCollectionTrait\n{\n    use FlexCommonTrait;\n\n    /**\n     * @param string $name\n     * @param object|null $event\n     * @return $this\n     */\n    public function triggerEvent(string $name, $event = null)\n    {\n        if (null === $event) {\n            $event = new Event([\n                'type' => 'flex',\n                'directory' => $this->getFlexDirectory(),\n                'collection' => $this\n            ]);\n        }\n        if (strpos($name, 'onFlexCollection') !== 0 && strpos($name, 'on') === 0) {\n            $name = 'onFlexCollection' . substr($name, 2);\n        }\n\n        $container = $this->getContainer();\n        if ($event instanceof Event) {\n            $container->fireEvent($name, $event);\n        } else {\n            $container->dispatchEvent($event);\n        }\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Traits/FlexCommonTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Traits;\n\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Twig\\Twig;\nuse Twig\\Error\\LoaderError;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Template;\nuse Twig\\TemplateWrapper;\n\n/**\n * Trait FlexCommonTrait\n * @package Grav\\Common\\Flex\\Traits\n */\ntrait FlexCommonTrait\n{\n    /**\n     * @param string $layout\n     * @return Template|TemplateWrapper\n     * @throws LoaderError\n     * @throws SyntaxError\n     */\n    protected function getTemplate($layout)\n    {\n        $container = $this->getContainer();\n\n        /** @var Twig $twig */\n        $twig = $container['twig'];\n\n        try {\n            return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout));\n        } catch (LoaderError $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            return $twig->twig()->resolveTemplate(['flex/404.html.twig']);\n        }\n    }\n\n    abstract protected function getTemplatePaths(string $layout): array;\n    abstract protected function getContainer(): Grav;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Traits/FlexGravTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Traits;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Framework\\Flex\\Flex;\n\n/**\n * Implements Grav specific logic\n */\ntrait FlexGravTrait\n{\n    /**\n     * @return Grav\n     */\n    protected function getContainer(): Grav\n    {\n        return Grav::instance();\n    }\n\n    /**\n     * @return Flex\n     */\n    protected function getFlexContainer(): Flex\n    {\n        $container = $this->getContainer();\n\n        /** @var Flex $flex */\n        $flex = $container['flex'];\n\n        return $flex;\n    }\n\n    /**\n     * @return UserInterface|null\n     */\n    protected function getActiveUser(): ?UserInterface\n    {\n        $container = $this->getContainer();\n\n        /** @var UserInterface|null $user */\n        $user = $container['user'] ?? null;\n\n        return $user;\n    }\n\n    /**\n     * @return bool\n     */\n    protected function isAdminSite(): bool\n    {\n        $container = $this->getContainer();\n\n        return isset($container['admin']);\n    }\n\n    /**\n     * @return string\n     */\n    protected function getAuthorizeScope(): string\n    {\n        return $this->isAdminSite() ? 'admin' : 'site';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Traits/FlexIndexTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Traits;\n\n/**\n * Trait FlexIndexTrait\n * @package Grav\\Common\\Flex\\Traits\n */\ntrait FlexIndexTrait\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Traits/FlexObjectTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Traits;\n\nuse RocketTheme\\Toolbox\\Event\\Event;\n\n/**\n * Trait FlexObjectTrait\n * @package Grav\\Common\\Flex\\Traits\n */\ntrait FlexObjectTrait\n{\n    use FlexCommonTrait;\n\n    /**\n     * @param string $name\n     * @param object|null $event\n     * @return $this\n     */\n    public function triggerEvent(string $name, $event = null)\n    {\n        $events = [\n            'onRender' => 'onFlexObjectRender',\n            'onBeforeSave' => 'onFlexObjectBeforeSave',\n            'onAfterSave' => 'onFlexObjectAfterSave',\n            'onBeforeDelete' => 'onFlexObjectBeforeDelete',\n            'onAfterDelete' => 'onFlexObjectAfterDelete'\n        ];\n\n        if (null === $event) {\n            $event = new Event([\n                'type' => 'flex',\n                'directory' => $this->getFlexDirectory(),\n                'object' => $this\n            ]);\n        }\n\n        if (isset($events['name'])) {\n            $name = $events['name'];\n        } elseif (strpos($name, 'onFlexObject') !== 0 && strpos($name, 'on') === 0) {\n            $name = 'onFlexObject' . substr($name, 2);\n        }\n\n        $container = $this->getContainer();\n        if ($event instanceof Event) {\n            $container->fireEvent($name, $event);\n        } else {\n            $container->dispatchEvent($event);\n        }\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Generic/GenericCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Generic;\n\nuse Grav\\Common\\Flex\\FlexCollection;\n\n/**\n * Class GenericCollection\n * @package Grav\\Common\\Flex\\Generic\n *\n * @extends FlexCollection<GenericObject>\n */\nclass GenericCollection extends FlexCollection\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Generic/GenericIndex.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Generic;\n\nuse Grav\\Common\\Flex\\FlexIndex;\n\n/**\n * Class GenericIndex\n * @package Grav\\Common\\Flex\\Generic\n *\n * @extends FlexIndex<GenericObject,GenericCollection>\n */\nclass GenericIndex extends FlexIndex\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Generic/GenericObject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Generic;\n\nuse Grav\\Common\\Flex\\FlexObject;\n\n/**\n * Class GenericObject\n * @package Grav\\Common\\Flex\\Generic\n */\nclass GenericObject extends FlexObject\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Pages/PageCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Pages;\n\nuse Exception;\nuse Grav\\Common\\Flex\\Traits\\FlexCollectionTrait;\nuse Grav\\Common\\Flex\\Traits\\FlexGravTrait;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Header;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Flex\\Pages\\FlexPageCollection;\nuse Collator;\nuse InvalidArgumentException;\nuse RuntimeException;\nuse function array_search;\nuse function count;\nuse function extension_loaded;\nuse function in_array;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class GravPageCollection\n * @package Grav\\Plugin\\FlexObjects\\Types\\GravPages\n *\n * @template T as PageObject\n * @extends FlexPageCollection<T>\n * @implements PageCollectionInterface<string,T>\n *\n * Incompatibilities with Grav\\Common\\Page\\Collection:\n *     $page = $collection->key()       will not work at all\n *     $clone = clone $collection       does not clone objects inside the collection, does it matter?\n *     $string = (string)$collection    returns collection id instead of comma separated list\n *     $collection->add()               incompatible method signature\n *     $collection->remove()            incompatible method signature\n *     $collection->filter()            incompatible method signature (takes closure instead of callable)\n *     $collection->prev()              does not rewind the internal pointer\n * AND most methods are immutable; they do not update the current collection, but return updated one\n *\n * @method PageIndex getIndex()\n */\nclass PageCollection extends FlexPageCollection implements PageCollectionInterface\n{\n    use FlexGravTrait;\n    use FlexCollectionTrait;\n\n    /** @var array|null */\n    protected $_params;\n\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n                // Collection specific methods\n                'getRoot' => false,\n                'getParams' => false,\n                'setParams' => false,\n                'params' => false,\n                'addPage' => false,\n                'merge' => false,\n                'intersect' => false,\n                'prev' => false,\n                'nth' => false,\n                'random' => false,\n                'append' => false,\n                'batch' => false,\n                'order' => false,\n\n                // Collection filtering\n                'dateRange' => true,\n                'visible' => true,\n                'nonVisible' => true,\n                'pages' => true,\n                'modules' => true,\n                'modular' => true,\n                'nonModular' => true,\n                'published' => true,\n                'nonPublished' => true,\n                'routable' => true,\n                'nonRoutable' => true,\n                'ofType' => true,\n                'ofOneOfTheseTypes' => true,\n                'ofOneOfTheseAccessLevels' => true,\n                'withOrdered' => true,\n                'withModules' => true,\n                'withPages' => true,\n                'withTranslation' => true,\n                'filterBy' => true,\n\n                'toExtendedArray' => false,\n                'getLevelListing' => false,\n            ] + parent::getCachedMethods();\n    }\n\n    /**\n     * @return PageInterface\n     */\n    public function getRoot()\n    {\n        return $this->getIndex()->getRoot();\n    }\n\n    /**\n     * Get the collection params\n     *\n     * @return array\n     */\n    public function getParams(): array\n    {\n        return $this->_params ?? [];\n    }\n\n    /**\n     * Set parameters to the Collection\n     *\n     * @param array $params\n     * @return $this\n     */\n    public function setParams(array $params)\n    {\n        $this->_params = $this->_params ? array_merge($this->_params, $params) : $params;\n\n        return $this;\n    }\n\n    /**\n     * Get the collection params\n     *\n     * @return array\n     */\n    public function params(): array\n    {\n        return $this->getParams();\n    }\n\n    /**\n     * Add a single page to a collection\n     *\n     * @param PageInterface $page\n     * @return $this\n     */\n    public function addPage(PageInterface $page)\n    {\n        if (!$page instanceof PageObject) {\n            throw new InvalidArgumentException('$page is not a flex page.');\n        }\n\n        // FIXME: support other keys.\n        $this->set($page->getKey(), $page);\n\n        return $this;\n    }\n\n    /**\n     *\n     * Merge another collection with the current collection\n     *\n     * @param PageCollectionInterface $collection\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function merge(PageCollectionInterface $collection)\n    {\n        throw new RuntimeException(__METHOD__ . '(): Not Implemented');\n    }\n\n    /**\n     * Intersect another collection with the current collection\n     *\n     * @param PageCollectionInterface $collection\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function intersect(PageCollectionInterface $collection)\n    {\n        throw new RuntimeException(__METHOD__ . '(): Not Implemented');\n    }\n\n    /**\n     * Set current page.\n     */\n    public function setCurrent(string $path): void\n    {\n        throw new RuntimeException(__METHOD__ . '(): Not Implemented');\n    }\n\n    /**\n     * Return previous item.\n     *\n     * @return PageInterface|false\n     * @phpstan-return T|false\n     */\n    public function prev()\n    {\n        // FIXME: this method does not rewind the internal pointer!\n        $key = (string)$this->key();\n        $prev = $this->prevSibling($key);\n\n        return $prev !== $this->current() ? $prev : false;\n    }\n\n    /**\n     * Return nth item.\n     * @param int $key\n     * @return PageInterface|bool\n     * @phpstan-return T|false\n     */\n    public function nth($key)\n    {\n        return $this->slice($key, 1)[0] ?? false;\n    }\n\n    /**\n     * Pick one or more random entries.\n     *\n     * @param int $num Specifies how many entries should be picked.\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function random($num = 1)\n    {\n        return $this->createFrom($this->shuffle()->slice(0, $num));\n    }\n\n    /**\n     * Append new elements to the list.\n     *\n     * @param array $items Items to be appended. Existing keys will be overridden with the new values.\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function append($items)\n    {\n        throw new RuntimeException(__METHOD__ . '(): Not Implemented');\n    }\n\n    /**\n     * Split collection into array of smaller collections.\n     *\n     * @param int $size\n     * @return static[]\n     * @phpstan-return static<T>[]\n     */\n    public function batch($size): array\n    {\n        $chunks = $this->chunk($size);\n\n        $list = [];\n        foreach ($chunks as $chunk) {\n            $list[] = $this->createFrom($chunk);\n        }\n\n        return $list;\n    }\n\n    /**\n     * Reorder collection.\n     *\n     * @param string $by\n     * @param string $dir\n     * @param array|null  $manual\n     * @param int|null $sort_flags\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function order($by, $dir = 'asc', $manual = null, $sort_flags = null)\n    {\n        if (!$this->count()) {\n            return $this;\n        }\n\n        if ($by === 'random') {\n            return $this->shuffle();\n        }\n\n        $keys = $this->buildSort($by, $dir, $manual, $sort_flags);\n\n        return $this->createFrom(array_replace(array_flip($keys), $this->toArray()) ?? []);\n    }\n\n    /**\n     * @param string $order_by\n     * @param string $order_dir\n     * @param array|null  $manual\n     * @param int|null    $sort_flags\n     * @return array\n     */\n    protected function buildSort($order_by = 'default', $order_dir = 'asc', $manual = null, $sort_flags = null): array\n    {\n        // do this header query work only once\n        $header_query = null;\n        $header_default = null;\n        if (strpos($order_by, 'header.') === 0) {\n            $query = explode('|', str_replace('header.', '', $order_by), 2);\n            $header_query = array_shift($query) ?? '';\n            $header_default = array_shift($query);\n        }\n\n        $list = [];\n        foreach ($this as $key => $child) {\n            switch ($order_by) {\n                case 'title':\n                    $list[$key] = $child->title();\n                    break;\n                case 'date':\n                    $list[$key] = $child->date();\n                    $sort_flags = SORT_REGULAR;\n                    break;\n                case 'modified':\n                    $list[$key] = $child->modified();\n                    $sort_flags = SORT_REGULAR;\n                    break;\n                case 'publish_date':\n                    $list[$key] = $child->publishDate();\n                    $sort_flags = SORT_REGULAR;\n                    break;\n                case 'unpublish_date':\n                    $list[$key] = $child->unpublishDate();\n                    $sort_flags = SORT_REGULAR;\n                    break;\n                case 'slug':\n                    $list[$key] = $child->slug();\n                    break;\n                case 'basename':\n                    $list[$key] = Utils::basename($key);\n                    break;\n                case 'folder':\n                    $list[$key] = $child->folder();\n                    break;\n                case 'manual':\n                case 'default':\n                default:\n                    if (is_string($header_query)) {\n                        /** @var Header $child_header */\n                        $child_header = $child->header();\n                        $header_value = $child_header->get($header_query);\n                        if (is_array($header_value)) {\n                            $list[$key] = implode(',', $header_value);\n                        } elseif ($header_value) {\n                            $list[$key] = $header_value;\n                        } else {\n                            $list[$key] = $header_default ?: $key;\n                        }\n                        $sort_flags = $sort_flags ?: SORT_REGULAR;\n                        break;\n                    }\n                    $list[$key] = $key;\n                    $sort_flags = $sort_flags ?: SORT_REGULAR;\n            }\n        }\n\n        if (null === $sort_flags) {\n            $sort_flags = SORT_NATURAL | SORT_FLAG_CASE;\n        }\n\n        // else just sort the list according to specified key\n        if (extension_loaded('intl') && Grav::instance()['config']->get('system.intl_enabled')) {\n            $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set\n            $col = Collator::create($locale);\n            if ($col) {\n                $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON);\n                if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) {\n                    $list = preg_replace_callback('~([0-9]+)\\.~', static function ($number) {\n                        return sprintf('%032d.', $number[0]);\n                    }, $list);\n                    if (!is_array($list)) {\n                        throw new RuntimeException('Internal Error');\n                    }\n\n                    $list_vals = array_values($list);\n                    if (is_numeric(array_shift($list_vals))) {\n                        $sort_flags = Collator::SORT_REGULAR;\n                    } else {\n                        $sort_flags = Collator::SORT_STRING;\n                    }\n                }\n\n                $col->asort($list, $sort_flags);\n            } else {\n                asort($list, $sort_flags);\n            }\n        } else {\n            asort($list, $sort_flags);\n        }\n\n        // Move manually ordered items into the beginning of the list. Order of the unlisted items does not change.\n        if (is_array($manual) && !empty($manual)) {\n            $i = count($manual);\n            $new_list = [];\n            foreach ($list as $key => $dummy) {\n                $child = $this[$key] ?? null;\n                $order = $child ? array_search($child->slug, $manual, true) : false;\n                if ($order === false) {\n                    $order = $i++;\n                }\n                $new_list[$key] = (int)$order;\n            }\n\n            $list = $new_list;\n\n            // Apply manual ordering to the list.\n            asort($list, SORT_NUMERIC);\n        }\n\n        if ($order_dir !== 'asc') {\n            $list = array_reverse($list);\n        }\n\n        return array_keys($list);\n    }\n\n    /**\n     * Mimicks Pages class.\n     *\n     * @return $this\n     * @deprecated 1.7 Not needed anymore in Flex Pages (does nothing).\n     */\n    public function all()\n    {\n        return $this;\n    }\n\n    /**\n     * Returns the items between a set of date ranges of either the page date field (default) or\n     * an arbitrary datetime page field where start date and end date are optional\n     * Dates must be passed in as text that strtotime() can process\n     * http://php.net/manual/en/function.strtotime.php\n     *\n     * @param string|null $startDate\n     * @param string|null $endDate\n     * @param string|null $field\n     * @return static\n     * @phpstan-return static<T>\n     * @throws Exception\n     */\n    public function dateRange($startDate = null, $endDate = null, $field = null)\n    {\n        $start = $startDate ? Utils::date2timestamp($startDate) : null;\n        $end = $endDate ? Utils::date2timestamp($endDate) : null;\n\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if (!$object) {\n                continue;\n            }\n\n            $date = $field ? strtotime($object->getNestedProperty($field)) : $object->date();\n\n            if ((!$start || $date >= $start) && (!$end || $date <= $end)) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only visible pages\n     *\n     * @return static The collection with only visible pages\n     * @phpstan-return static<T>\n     */\n    public function visible()\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && $object->visible()) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only non-visible pages\n     *\n     * @return static The collection with only non-visible pages\n     * @phpstan-return static<T>\n     */\n    public function nonVisible()\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && !$object->visible()) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only pages\n     *\n     * @return static The collection with only pages\n     * @phpstan-return static<T>\n     */\n    public function pages()\n    {\n        $entries = [];\n        /**\n         * @var int|string $key\n         * @var PageInterface|null $object\n         */\n        foreach ($this as $key => $object) {\n            if ($object && !$object->isModule()) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only modules\n     *\n     * @return static The collection with only modules\n     * @phpstan-return static<T>\n     */\n    public function modules()\n    {\n        $entries = [];\n        /**\n         * @var int|string $key\n         * @var PageInterface|null $object\n         */\n        foreach ($this as $key => $object) {\n            if ($object && $object->isModule()) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Alias of modules()\n     *\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function modular()\n    {\n        return $this->modules();\n    }\n\n    /**\n     * Alias of pages()\n     *\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function nonModular()\n    {\n        return $this->pages();\n    }\n\n    /**\n     * Creates new collection with only published pages\n     *\n     * @return static The collection with only published pages\n     * @phpstan-return static<T>\n     */\n    public function published()\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && $object->published()) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only non-published pages\n     *\n     * @return static The collection with only non-published pages\n     * @phpstan-return static<T>\n     */\n    public function nonPublished()\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && !$object->published()) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only routable pages\n     *\n     * @return static The collection with only routable pages\n     * @phpstan-return static<T>\n     */\n    public function routable()\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && $object->routable()) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only non-routable pages\n     *\n     * @return static The collection with only non-routable pages\n     * @phpstan-return static<T>\n     */\n    public function nonRoutable()\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && !$object->routable()) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only pages of the specified type\n     *\n     * @param string $type\n     * @return static The collection\n     * @phpstan-return static<T>\n     */\n    public function ofType($type)\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && $object->template() === $type) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only pages of one of the specified types\n     *\n     * @param string[] $types\n     * @return static The collection\n     * @phpstan-return static<T>\n     */\n    public function ofOneOfTheseTypes($types)\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && in_array($object->template(), $types, true)) {\n                $entries[$key] = $object;\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * Creates new collection with only pages of one of the specified access levels\n     *\n     * @param array $accessLevels\n     * @return static The collection\n     * @phpstan-return static<T>\n     */\n    public function ofOneOfTheseAccessLevels($accessLevels)\n    {\n        $entries = [];\n        foreach ($this as $key => $object) {\n            if ($object && isset($object->header()->access)) {\n                if (is_array($object->header()->access)) {\n                    //Multiple values for access\n                    $valid = false;\n\n                    foreach ($object->header()->access as $index => $accessLevel) {\n                        if (is_array($accessLevel)) {\n                            foreach ($accessLevel as $innerIndex => $innerAccessLevel) {\n                                if (in_array($innerAccessLevel, $accessLevels)) {\n                                    $valid = true;\n                                }\n                            }\n                        } else {\n                            if (in_array($index, $accessLevels)) {\n                                $valid = true;\n                            }\n                        }\n                    }\n                    if ($valid) {\n                        $entries[$key] = $object;\n                    }\n                } else {\n                    //Single value for access\n                    if (in_array($object->header()->access, $accessLevels)) {\n                        $entries[$key] = $object;\n                    }\n                }\n            }\n        }\n\n        return $this->createFrom($entries);\n    }\n\n    /**\n     * @param bool $bool\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function withOrdered(bool $bool = true)\n    {\n        $list = array_keys(array_filter($this->call('isOrdered', [$bool])));\n\n        return $this->select($list);\n    }\n\n    /**\n     * @param bool $bool\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function withModules(bool $bool = true)\n    {\n        $list = array_keys(array_filter($this->call('isModule', [$bool])));\n\n        return $this->select($list);\n    }\n\n    /**\n     * @param bool $bool\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function withPages(bool $bool = true)\n    {\n        $list = array_keys(array_filter($this->call('isPage', [$bool])));\n\n        return $this->select($list);\n    }\n\n    /**\n     * @param bool $bool\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function withTranslation(bool $bool = true, string $languageCode = null, bool $fallback = null)\n    {\n        $list = array_keys(array_filter($this->call('hasTranslation', [$languageCode, $fallback])));\n\n        return $bool ? $this->select($list) : $this->unselect($list);\n    }\n\n    /**\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return PageIndex\n     */\n    public function withTranslated(string $languageCode = null, bool $fallback = null)\n    {\n        return $this->getIndex()->withTranslated($languageCode, $fallback);\n    }\n\n    /**\n     * Filter pages by given filters.\n     *\n     * - search: string\n     * - page_type: string|string[]\n     * - modular: bool\n     * - visible: bool\n     * - routable: bool\n     * - published: bool\n     * - page: bool\n     * - translated: bool\n     *\n     * @param array $filters\n     * @param bool $recursive\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function filterBy(array $filters, bool $recursive = false)\n    {\n        $list = array_keys(array_filter($this->call('filterBy', [$filters, $recursive])));\n\n        return $this->select($list);\n    }\n\n    /**\n     * Get the extended version of this Collection with each page keyed by route\n     *\n     * @return array\n     * @throws Exception\n     */\n    public function toExtendedArray(): array\n    {\n        $entries  = [];\n        foreach ($this as $key => $object) {\n            if ($object) {\n                $entries[$object->route()] = $object->toArray();\n            }\n        }\n\n        return $entries;\n    }\n\n    /**\n     * @param array $options\n     * @return array\n     */\n    public function getLevelListing(array $options): array\n    {\n        /** @var PageIndex $index */\n        $index = $this->getIndex();\n\n        return method_exists($index, 'getLevelListing') ? $index->getLevelListing($options) : [];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Pages/PageIndex.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Pages;\n\nuse Exception;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\File\\CompiledJsonFile;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Flex\\Traits\\FlexGravTrait;\nuse Grav\\Common\\Flex\\Traits\\FlexIndexTrait;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Header;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexStorageInterface;\nuse Grav\\Framework\\Flex\\Pages\\FlexPageIndex;\nuse InvalidArgumentException;\nuse RuntimeException;\nuse function array_slice;\nuse function count;\nuse function in_array;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class GravPageObject\n * @package Grav\\Plugin\\FlexObjects\\Types\\GravPages\n *\n * @template T of PageObject\n * @template C of PageCollection\n * @extends FlexPageIndex<T,C>\n * @implements PageCollectionInterface<string,T>\n *\n * @method PageIndex withModules(bool $bool = true)\n * @method PageIndex withPages(bool $bool = true)\n * @method PageIndex withTranslation(bool $bool = true, string $languageCode = null, bool $fallback = null)\n */\nclass PageIndex extends FlexPageIndex implements PageCollectionInterface\n{\n    use FlexGravTrait;\n    use FlexIndexTrait;\n\n    public const VERSION = parent::VERSION . '.5';\n    public const ORDER_LIST_REGEX = '/(\\/\\d+)\\.[^\\/]+/u';\n    public const PAGE_ROUTE_REGEX = '/\\/\\d+\\./u';\n\n    /** @var PageObject|array */\n    protected $_root;\n    /** @var array|null */\n    protected $_params;\n\n    /**\n     * @param array $entries\n     * @param FlexDirectory|null $directory\n     */\n    public function __construct(array $entries = [], FlexDirectory $directory = null)\n    {\n        // Remove root if it's taken.\n        if (isset($entries[''])) {\n            $this->_root = $entries[''];\n            unset($entries['']);\n        }\n\n        parent::__construct($entries, $directory);\n    }\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @return array\n     */\n    public static function loadEntriesFromStorage(FlexStorageInterface $storage): array\n    {\n        // Load saved index.\n        $index = static::loadIndex($storage);\n\n        $version = $index['version'] ?? 0;\n        $force = static::VERSION !== $version;\n\n        // TODO: Following check flex index to be out of sync after some saves, disabled until better solution is found.\n        //$timestamp = $index['timestamp'] ?? 0;\n        //if (!$force && $timestamp && $timestamp > time() - 1) {\n        //    return $index['index'];\n        //}\n\n        // Load up to date index.\n        $entries = parent::loadEntriesFromStorage($storage);\n\n        return static::updateIndexFile($storage, $index['index'], $entries, ['include_missing' => true, 'force_update' => $force]);\n    }\n\n    /**\n     * @param string $key\n     * @return PageObject|null\n     * @phpstan-return T|null\n     */\n    public function get($key)\n    {\n        if (mb_strpos($key, '|') !== false) {\n            [$key, $params] = explode('|', $key, 2);\n        }\n\n        $element = parent::get($key);\n        if (null === $element) {\n            return null;\n        }\n\n        if (isset($params)) {\n            $element = $element->getTranslation(ltrim($params, '.'));\n        }\n\n        \\assert(null === $element || $element instanceof PageObject);\n\n        return $element;\n    }\n\n    /**\n     * @return PageInterface\n     */\n    public function getRoot()\n    {\n        $root = $this->_root;\n        if (is_array($root)) {\n            $directory = $this->getFlexDirectory();\n            $storage = $directory->getStorage();\n\n            $defaults = [\n                'header' => [\n                    'routable' => false,\n                    'permissions' => [\n                        'inherit' => false\n                    ]\n                ]\n            ];\n\n            $row = $storage->readRows(['' => null])[''] ?? null;\n            if (null !== $row) {\n                if (isset($row['__ERROR'])) {\n                    /** @var Debugger $debugger */\n                    $debugger = Grav::instance()['debugger'];\n                    $message = sprintf('Flex Pages: root page is broken in storage: %s', $row['__ERROR']);\n\n                    $debugger->addException(new RuntimeException($message));\n                    $debugger->addMessage($message, 'error');\n\n                    $row = ['__META' => $root];\n                }\n\n            } else {\n                $row = ['__META' => $root];\n            }\n\n            $row = array_merge_recursive($defaults, $row);\n\n            /** @var PageObject $root */\n            $root = $this->getFlexDirectory()->createObject($row, '/', false);\n            $root->name('root.md');\n            $root->root(true);\n\n            $this->_root = $root;\n        }\n\n        return $root;\n    }\n\n    /**\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    public function withTranslated(string $languageCode = null, bool $fallback = null)\n    {\n        if (null === $languageCode) {\n            return $this;\n        }\n\n        $entries = $this->translateEntries($this->getEntries(), $languageCode, $fallback);\n        $params = ['language' => $languageCode, 'language_fallback' => $fallback] + $this->getParams();\n\n        return $this->createFrom($entries)->setParams($params);\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getLanguage(): ?string\n    {\n        return $this->_params['language'] ?? null;\n    }\n\n    /**\n     * Get the collection params\n     *\n     * @return array\n     */\n    public function getParams(): array\n    {\n        return $this->_params ?? [];\n    }\n\n    /**\n     * Get the collection param\n     *\n     * @param string $name\n     * @return mixed\n     */\n    public function getParam(string $name)\n    {\n        return $this->_params[$name] ?? null;\n    }\n\n    /**\n     * Set parameters to the Collection\n     *\n     * @param array $params\n     * @return $this\n     */\n    public function setParams(array $params)\n    {\n        $this->_params = $this->_params ? array_merge($this->_params, $params) : $params;\n\n        return $this;\n    }\n\n    /**\n     * Set a parameter to the Collection\n     *\n     * @param string $name\n     * @param mixed $value\n     * @return $this\n     */\n    public function setParam(string $name, $value)\n    {\n        $this->_params[$name] = $value;\n\n        return $this;\n    }\n\n    /**\n     * Get the collection params\n     *\n     * @return array\n     */\n    public function params(): array\n    {\n        return $this->getParams();\n    }\n\n        /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getCacheKey()\n     */\n    public function getCacheKey(): string\n    {\n        return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->getKeyField() . $this->getLanguage());\n    }\n\n    /**\n     * Filter pages by given filters.\n     *\n     * - search: string\n     * - page_type: string|string[]\n     * - modular: bool\n     * - visible: bool\n     * - routable: bool\n     * - published: bool\n     * - page: bool\n     * - translated: bool\n     *\n     * @param array $filters\n     * @param bool $recursive\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    public function filterBy(array $filters, bool $recursive = false)\n    {\n        if (!$filters) {\n            return $this;\n        }\n\n        if ($recursive) {\n            return $this->__call('filterBy', [$filters, true]);\n        }\n\n        $list = [];\n        $index = $this;\n        foreach ($filters as $key => $value) {\n            switch ($key) {\n                case 'search':\n                    $index = $index->search((string)$value);\n                    break;\n                case 'page_type':\n                    if (!is_array($value)) {\n                        $value = is_string($value) && $value !== '' ? explode(',', $value) : [];\n                    }\n                    $index = $index->ofOneOfTheseTypes($value);\n                    break;\n                case 'routable':\n                    $index = $index->withRoutable((bool)$value);\n                    break;\n                case 'published':\n                    $index = $index->withPublished((bool)$value);\n                    break;\n                case 'visible':\n                    $index = $index->withVisible((bool)$value);\n                    break;\n                case 'module':\n                    $index = $index->withModules((bool)$value);\n                    break;\n                case 'page':\n                    $index = $index->withPages((bool)$value);\n                    break;\n                case 'folder':\n                    $index = $index->withPages(!$value);\n                    break;\n                case 'translated':\n                    $index = $index->withTranslation((bool)$value);\n                    break;\n                default:\n                    $list[$key] = $value;\n            }\n        }\n\n        return $list ? $index->filterByParent($list) : $index;\n    }\n\n    /**\n     * @param array $filters\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    protected function filterByParent(array $filters)\n    {\n        /** @var static $index */\n        $index = parent::filterBy($filters);\n\n        return $index;\n    }\n\n    /**\n     * @param array $options\n     * @return array\n     */\n    public function getLevelListing(array $options): array\n    {\n        // Undocumented B/C\n        $order = $options['order'] ?? 'asc';\n        if ($order === SORT_ASC) {\n            $options['order'] = 'asc';\n        } elseif ($order === SORT_DESC) {\n            $options['order'] = 'desc';\n        }\n\n        $options += [\n            'field' => null,\n            'route' => null,\n            'leaf_route' => null,\n            'sortby' => null,\n            'order' => 'asc',\n            'lang' => null,\n            'filters' => [],\n        ];\n\n        $options['filters'] += [\n            'type' => ['root', 'dir'],\n        ];\n\n        $key = 'page.idx.lev.' . sha1(json_encode($options, JSON_THROW_ON_ERROR) . $this->getCacheKey());\n        $checksum = $this->getCacheChecksum();\n\n        $cache = $this->getCache('object');\n\n        /** @var Debugger $debugger */\n        $debugger = Grav::instance()['debugger'];\n\n        $result = null;\n        try {\n            $cached = $cache->get($key);\n            $test = $cached[0] ?? null;\n            $result = $test === $checksum ? ($cached[1] ?? null) : null;\n        } catch (\\Psr\\SimpleCache\\InvalidArgumentException $e) {\n            $debugger->addException($e);\n        }\n\n        try {\n            if (null === $result) {\n                $result = $this->getLevelListingRecurse($options);\n                $cache->set($key, [$checksum, $result]);\n            }\n        } catch (\\Psr\\SimpleCache\\InvalidArgumentException $e) {\n            $debugger->addException($e);\n        }\n\n        return $result;\n    }\n\n    /**\n     * @param array $entries\n     * @param string|null $keyField\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    protected function createFrom(array $entries, string $keyField = null)\n    {\n        /** @var static $index */\n        $index = parent::createFrom($entries, $keyField);\n        $index->_root = $this->getRoot();\n\n        return $index;\n    }\n\n    /**\n     * @param array $entries\n     * @param string $lang\n     * @param bool|null $fallback\n     * @return array\n     */\n    protected function translateEntries(array $entries, string $lang, bool $fallback = null): array\n    {\n        $languages = $this->getFallbackLanguages($lang, $fallback);\n        foreach ($entries as $key => &$entry) {\n            // Find out which version of the page we should load.\n            $translations = $this->getLanguageTemplates((string)$key);\n            if (!$translations) {\n                // No translations found, is this a folder?\n                continue;\n            }\n\n            // Find a translation.\n            $template = null;\n            foreach ($languages as $code) {\n                if (isset($translations[$code])) {\n                    $template = $translations[$code];\n                    break;\n                }\n            }\n\n            // We couldn't find a translation, remove entry from the list.\n            if (!isset($code, $template)) {\n                unset($entries['key']);\n                continue;\n            }\n\n            // Get the main key without template and language.\n            [$main_key,] = explode('|', $entry['storage_key'] . '|', 2);\n\n            // Update storage key and language.\n            $entry['storage_key'] = $main_key . '|' . $template . '.' . $code;\n            $entry['lang'] = $code;\n        }\n        unset($entry);\n\n        return $entries;\n    }\n\n    /**\n     * @return array\n     */\n    protected function getLanguageTemplates(string $key): array\n    {\n        $meta = $this->getMetaData($key);\n        $template = $meta['template'] ?? 'folder';\n        $translations = $meta['markdown'] ?? [];\n        $list = [];\n        foreach ($translations as $code => $search) {\n            if (isset($search[$template])) {\n                // Use main template if possible.\n                $list[$code] = $template;\n            } elseif (!empty($search)) {\n                // Fall back to first matching template.\n                $list[$code] = key($search);\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return array\n     */\n    protected function getFallbackLanguages(string $languageCode = null, bool $fallback = null): array\n    {\n        $fallback = $fallback ?? true;\n        if (!$fallback && null !== $languageCode) {\n            return [$languageCode];\n        }\n\n        $grav = Grav::instance();\n\n        /** @var Language $language */\n        $language = $grav['language'];\n        $languageCode = $languageCode ?? '';\n        if ($languageCode === '' && $fallback) {\n            return $language->getFallbackLanguages(null, true);\n        }\n\n        return $fallback ? $language->getFallbackLanguages($languageCode, true) : [$languageCode];\n    }\n\n    /**\n     * @param array $options\n     * @return array\n     */\n    protected function getLevelListingRecurse(array $options): array\n    {\n        $filters = $options['filters'] ?? [];\n        $field = $options['field'];\n        $route = $options['route'];\n        $leaf_route = $options['leaf_route'];\n        $sortby = $options['sortby'];\n        $order = $options['order'];\n        $language = $options['lang'];\n\n        $status = 'error';\n        $response = [];\n        $extra = null;\n\n        // Handle leaf_route\n        $leaf = null;\n        if ($leaf_route && $route !== $leaf_route) {\n            $nodes = explode('/', $leaf_route);\n            $sub_route =  '/' . implode('/', array_slice($nodes, 1, $options['level']++));\n            $options['route'] = $sub_route;\n\n            [$status,,$leaf,$extra] = $this->getLevelListingRecurse($options);\n        }\n\n        // Handle no route, assume page tree root\n        if (!$route) {\n            $page = $this->getRoot();\n        } else {\n            $page = $this->get(trim($route, '/'));\n        }\n        $path = $page ? $page->path() : null;\n\n        if ($field) {\n            // Get forced filters from the field.\n            $blueprint = $page ? $page->getBlueprint() : $this->getFlexDirectory()->getBlueprint();\n            $settings = $blueprint->schema()->getProperty($field);\n            $filters = array_merge([], $filters, $settings['filters'] ?? []);\n        }\n\n        // Clean up filter.\n        $filter_type = (array)($filters['type'] ?? []);\n        unset($filters['type']);\n        $filters = array_filter($filters, static function($val) { return $val !== null && $val !== ''; });\n\n        if ($page) {\n            $status = 'success';\n            $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_FOUND';\n\n            if ($page->root() && (!$filter_type || in_array('root', $filter_type, true))) {\n                if ($field) {\n                    $response[] = [\n                        'name' => '<root>',\n                        'value' => '/',\n                        'item-key' => '',\n                        'filename' => '.',\n                        'extension' => '',\n                        'type' => 'root',\n                        'modified' => $page->modified(),\n                        'size' => 0,\n                        'symlink' => false,\n                        'has-children' => false\n                    ];\n                } else {\n                    $response[] = [\n                        'item-key' => '-root-',\n                        'icon' => 'root',\n                        'title' => 'Root', // FIXME\n                        'route' => [\n                            'display' => '&lt;root&gt;', // FIXME\n                            'raw' => '_root',\n                        ],\n                        'modified' => $page->modified(),\n                        'extras' => [\n                            'template' => $page->template(),\n                            //'lang' => null,\n                            //'translated' => null,\n                            'langs' => [],\n                            'published' => false,\n                            'visible' => false,\n                            'routable' => false,\n                            'tags' => ['root', 'non-routable'],\n                            'actions' => ['edit'], // FIXME\n                        ]\n                    ];\n                }\n            }\n\n            /** @var PageCollection|PageIndex $children */\n            $children = $page->children();\n            /** @var PageIndex $children */\n            $children = $children->getIndex();\n            $selectedChildren = $children->filterBy($filters + ['language' => $language], true);\n\n            /** @var Header $header */\n            $header = $page->header();\n\n            if (!$field && $header->get('admin.children_display_order', 'collection') === 'collection' && ($orderby = $header->get('content.order.by'))) {\n                // Use custom sorting by page header.\n                $sortby = $orderby;\n                $order = $header->get('content.order.dir', $order);\n                $custom = $header->get('content.order.custom');\n            }\n\n            if ($sortby) {\n                // Sort children.\n                $selectedChildren = $selectedChildren->order($sortby, $order, $custom ?? null);\n            }\n\n            /** @var UserInterface|null $user */\n            $user = Grav::instance()['user'] ?? null;\n\n            /** @var PageObject $child */\n            foreach ($selectedChildren as $child) {\n                $selected = $child->path() === $extra;\n                $includeChildren = is_array($leaf) && !empty($leaf) && $selected;\n                if ($field) {\n                    $child_count = count($child->children());\n                    $payload = [\n                        'name' => $child->menu(),\n                        'value' => $child->rawRoute(),\n                        'item-key' => Utils::basename($child->rawRoute() ?? ''),\n                        'filename' => $child->folder(),\n                        'extension' => $child->extension(),\n                        'type' => 'dir',\n                        'modified' => $child->modified(),\n                        'size' => $child_count,\n                        'symlink' => false,\n                        'has-children' => $child_count > 0\n                    ];\n                } else {\n                    $lang = $child->findTranslation($language) ?? 'n/a';\n                    /** @var PageObject $child */\n                    $child = $child->getTranslation($language) ?? $child;\n\n                    // TODO: all these features are independent from each other, we cannot just have one icon/color to catch all.\n                    // TODO: maybe icon by home/modular/page/folder (or even from blueprints) and color by visibility etc..\n                    if ($child->home()) {\n                        $icon = 'home';\n                    } elseif ($child->isModule()) {\n                        $icon = 'modular';\n                    } elseif ($child->visible()) {\n                        $icon = 'visible';\n                    } elseif ($child->isPage()) {\n                        $icon = 'page';\n                    } else {\n                        // TODO: add support\n                        $icon = 'folder';\n                    }\n                    $tags = [\n                        $child->published() ? 'published' : 'non-published',\n                        $child->visible() ? 'visible' : 'non-visible',\n                        $child->routable() ? 'routable' : 'non-routable'\n                    ];\n                    $extras = [\n                        'template' => $child->template(),\n                        'lang' => $lang ?: null,\n                        'translated' => $lang ? $child->hasTranslation($language, false) : null,\n                        'langs' => $child->getAllLanguages(true) ?: null,\n                        'published' => $child->published(),\n                        'published_date' => $this->jsDate($child->publishDate()),\n                        'unpublished_date' => $this->jsDate($child->unpublishDate()),\n                        'visible' => $child->visible(),\n                        'routable' => $child->routable(),\n                        'tags' => $tags,\n                        'actions' => $this->getListingActions($child, $user),\n                    ];\n                    $extras = array_filter($extras, static function ($v) {\n                        return $v !== null;\n                    });\n\n                    /** @var PageIndex $tmp */\n                    $tmp = $child->children()->getIndex();\n                    $child_count = $tmp->count();\n                    $count = $filters ? $tmp->filterBy($filters, true)->count() : null;\n                    $route = $child->getRoute();\n                    $route = $route ? ($route->toString(false) ?: '/') : '';\n                    $payload = [\n                        'item-key' => htmlspecialchars(Utils::basename($child->rawRoute() ?? $child->getKey())),\n                        'icon' => $icon,\n                        'title' => htmlspecialchars($child->menu()),\n                        'route' => [\n                            'display' => htmlspecialchars($route) ?: null,\n                            'raw' => htmlspecialchars($child->rawRoute()),\n                        ],\n                        'modified' => $this->jsDate($child->modified()),\n                        'child_count' => $child_count ?: null,\n                        'count' => $count ?? null,\n                        'filters_hit' => $filters ? ($child->filterBy($filters, false) ?: null) : null,\n                        'extras' => $extras\n                    ];\n                    $payload = array_filter($payload, static function ($v) {\n                        return $v !== null;\n                    });\n                }\n\n                // Add children if any\n                if ($includeChildren) {\n                    $payload['children'] = array_values($leaf);\n                }\n\n                $response[] = $payload;\n            }\n        } else {\n            $msg = 'PLUGIN_ADMIN.PAGE_ROUTE_NOT_FOUND';\n        }\n\n        if ($field) {\n            $temp_array = [];\n            foreach ($response as $index => $item) {\n                $temp_array[$item['type']][$index] = $item;\n            }\n\n            $sorted = Utils::sortArrayByArray($temp_array, $filter_type);\n            $response = Utils::arrayFlatten($sorted);\n        }\n\n        return [$status, $msg, $response, $path];\n    }\n\n    /**\n     * @param PageObject $object\n     * @param UserInterface $user\n     * @return array\n     */\n    protected function getListingActions(PageObject $object, UserInterface $user): array\n    {\n        $actions = [];\n        if ($object->isAuthorized('read', null, $user)) {\n            $actions[] = 'preview';\n            $actions[] = 'edit';\n        }\n        if ($object->isAuthorized('update', null, $user)) {\n            $actions[] = 'copy';\n            $actions[] = 'move';\n        }\n        if ($object->isAuthorized('delete', null, $user)) {\n            $actions[] = 'delete';\n        }\n\n        return $actions;\n    }\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @return CompiledJsonFile|CompiledYamlFile|null\n     */\n    protected static function getIndexFile(FlexStorageInterface $storage)\n    {\n        if (!method_exists($storage, 'isIndexed') || !$storage->isIndexed()) {\n            return null;\n        }\n\n        // Load saved index file.\n        $grav = Grav::instance();\n        $locator = $grav['locator'];\n\n        $filename = $locator->findResource('user-data://flex/indexes/pages.json', true, true);\n\n        return CompiledJsonFile::instance($filename);\n    }\n\n    /**\n     * @param int|null $timestamp\n     * @return string|null\n     */\n    private function jsDate(int $timestamp = null): ?string\n    {\n        if (!$timestamp) {\n            return null;\n        }\n\n        $config = Grav::instance()['config'];\n        $dateFormat = $config->get('system.pages.dateformat.long');\n\n        return date($dateFormat, $timestamp) ?: null;\n    }\n\n    /**\n     * Add a single page to a collection\n     *\n     * @param PageInterface $page\n     * @return PageCollection\n     * @phpstan-return C\n     */\n    public function addPage(PageInterface $page)\n    {\n        return $this->getCollection()->addPage($page);\n    }\n\n    /**\n     *\n     * Create a copy of this collection\n     *\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    public function copy()\n    {\n        return clone $this;\n    }\n\n    /**\n     *\n     * Merge another collection with the current collection\n     *\n     * @param PageCollectionInterface $collection\n     * @return PageCollection\n     * @phpstan-return C\n     */\n    public function merge(PageCollectionInterface $collection)\n    {\n        return $this->getCollection()->merge($collection);\n    }\n\n\n    /**\n     * Intersect another collection with the current collection\n     *\n     * @param PageCollectionInterface $collection\n     * @return PageCollection\n     * @phpstan-return C\n     */\n    public function intersect(PageCollectionInterface $collection)\n    {\n        return $this->getCollection()->intersect($collection);\n    }\n\n    /**\n     * Split collection into array of smaller collections.\n     *\n     * @param int $size\n     * @return PageCollection[]\n     * @phpstan-return C[]\n     */\n    public function batch($size)\n    {\n        return $this->getCollection()->batch($size);\n    }\n\n    /**\n     * Remove item from the list.\n     *\n     * @param string $key\n     * @return PageObject|null\n     * @phpstan-return T|null\n     * @throws InvalidArgumentException\n     */\n    public function remove($key)\n    {\n        return $this->getCollection()->remove($key);\n    }\n\n    /**\n     * Reorder collection.\n     *\n     * @param string $by\n     * @param string $dir\n     * @param array  $manual\n     * @param string $sort_flags\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    public function order($by, $dir = 'asc', $manual = null, $sort_flags = null)\n    {\n        /** @var PageCollectionInterface $collection */\n        $collection = $this->__call('order', [$by, $dir, $manual, $sort_flags]);\n\n        return $collection;\n    }\n\n    /**\n     * Check to see if this item is the first in the collection.\n     *\n     * @param  string $path\n     * @return bool True if item is first.\n     */\n    public function isFirst($path): bool\n    {\n        /** @var bool $result */\n        $result = $this->__call('isFirst', [$path]);\n\n        return $result;\n\n    }\n\n    /**\n     * Check to see if this item is the last in the collection.\n     *\n     * @param  string $path\n     * @return bool True if item is last.\n     */\n    public function isLast($path): bool\n    {\n        /** @var bool $result */\n        $result = $this->__call('isLast', [$path]);\n\n        return $result;\n    }\n\n    /**\n     * Gets the previous sibling based on current position.\n     *\n     * @param  string $path\n     * @return PageObject|null  The previous item.\n     * @phpstan-return T|null\n     */\n    public function prevSibling($path)\n    {\n        /** @var PageObject|null $result */\n        $result = $this->__call('prevSibling', [$path]);\n\n        return $result;\n    }\n\n    /**\n     * Gets the next sibling based on current position.\n     *\n     * @param  string $path\n     * @return PageObject|null The next item.\n     * @phpstan-return T|null\n     */\n    public function nextSibling($path)\n    {\n        /** @var PageObject|null $result */\n        $result = $this->__call('nextSibling', [$path]);\n\n        return $result;\n    }\n\n    /**\n     * Returns the adjacent sibling based on a direction.\n     *\n     * @param  string  $path\n     * @param  int $direction either -1 or +1\n     * @return PageObject|false    The sibling item.\n     * @phpstan-return T|false\n     */\n    public function adjacentSibling($path, $direction = 1)\n    {\n        /** @var PageObject|false $result */\n        $result = $this->__call('adjacentSibling', [$path, $direction]);\n\n        return $result;\n    }\n\n    /**\n     * Returns the item in the current position.\n     *\n     * @param  string $path the path the item\n     * @return int|null The index of the current page, null if not found.\n     */\n    public function currentPosition($path): ?int\n    {\n        /** @var int|null $result */\n        $result = $this->__call('currentPosition', [$path]);\n\n        return $result;\n    }\n\n    /**\n     * Returns the items between a set of date ranges of either the page date field (default) or\n     * an arbitrary datetime page field where start date and end date are optional\n     * Dates must be passed in as text that strtotime() can process\n     * http://php.net/manual/en/function.strtotime.php\n     *\n     * @param string|null $startDate\n     * @param string|null $endDate\n     * @param string|null $field\n     * @return static\n     * @phpstan-return static<T,C>\n     * @throws Exception\n     */\n    public function dateRange($startDate = null, $endDate = null, $field = null)\n    {\n        $collection = $this->__call('dateRange', [$startDate, $endDate, $field]);\n\n        return $collection;\n    }\n\n    /**\n     * Mimicks Pages class.\n     *\n     * @return $this\n     * @deprecated 1.7 Not needed anymore in Flex Pages (does nothing).\n     */\n    public function all()\n    {\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only visible pages\n     *\n     * @return static The collection with only visible pages\n     * @phpstan-return static<T,C>\n     */\n    public function visible()\n    {\n        $collection = $this->__call('visible', []);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only non-visible pages\n     *\n     * @return static The collection with only non-visible pages\n     * @phpstan-return static<T,C>\n     */\n    public function nonVisible()\n    {\n        $collection = $this->__call('nonVisible', []);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only non-modular pages\n     *\n     * @return static The collection with only non-modular pages\n     * @phpstan-return static<T,C>\n     */\n    public function pages()\n    {\n        $collection = $this->__call('pages', []);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only modular pages\n     *\n     * @return static The collection with only modular pages\n     * @phpstan-return static<T,C>\n     */\n    public function modules()\n    {\n        $collection = $this->__call('modules', []);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only modular pages\n     *\n     * @return static The collection with only modular pages\n     * @phpstan-return static<T,C>\n     */\n    public function modular()\n    {\n        return $this->modules();\n    }\n\n    /**\n     * Creates new collection with only non-modular pages\n     *\n     * @return static The collection with only non-modular pages\n     * @phpstan-return static<T,C>\n     */\n    public function nonModular()\n    {\n        return $this->pages();\n    }\n\n    /**\n     * Creates new collection with only published pages\n     *\n     * @return static The collection with only published pages\n     * @phpstan-return static<T,C>\n     */\n    public function published()\n    {\n        $collection = $this->__call('published', []);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only non-published pages\n     *\n     * @return static The collection with only non-published pages\n     * @phpstan-return static<T,C>\n     */\n    public function nonPublished()\n    {\n        $collection = $this->__call('nonPublished', []);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only routable pages\n     *\n     * @return static The collection with only routable pages\n     * @phpstan-return static<T,C>\n     */\n    public function routable()\n    {\n        $collection = $this->__call('routable', []);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only non-routable pages\n     *\n     * @return static The collection with only non-routable pages\n     * @phpstan-return static<T,C>\n     */\n    public function nonRoutable()\n    {\n        $collection = $this->__call('nonRoutable', []);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only pages of the specified type\n     *\n     * @param string $type\n     * @return static The collection\n     * @phpstan-return static<T,C>\n     */\n    public function ofType($type)\n    {\n        $collection = $this->__call('ofType', [$type]);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only pages of one of the specified types\n     *\n     * @param string[] $types\n     * @return static The collection\n     * @phpstan-return static<T,C>\n     */\n    public function ofOneOfTheseTypes($types)\n    {\n        $collection = $this->__call('ofOneOfTheseTypes', [$types]);\n\n        return $collection;\n    }\n\n    /**\n     * Creates new collection with only pages of one of the specified access levels\n     *\n     * @param array $accessLevels\n     * @return static The collection\n     * @phpstan-return static<T,C>\n     */\n    public function ofOneOfTheseAccessLevels($accessLevels)\n    {\n        $collection = $this->__call('ofOneOfTheseAccessLevels', [$accessLevels]);\n\n        return $collection;\n    }\n\n    /**\n     * Converts collection into an array.\n     *\n     * @return array\n     */\n    public function toArray()\n    {\n        return $this->getCollection()->toArray();\n    }\n\n    /**\n     * Get the extended version of this Collection with each page keyed by route\n     *\n     * @return array\n     * @throws Exception\n     */\n    public function toExtendedArray()\n    {\n        return $this->getCollection()->toExtendedArray();\n    }\n\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Pages/PageObject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Pages;\n\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Flex\\Traits\\FlexGravTrait;\nuse Grav\\Common\\Flex\\Traits\\FlexObjectTrait;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Flex\\Types\\Pages\\Traits\\PageContentTrait;\nuse Grav\\Common\\Flex\\Types\\Pages\\Traits\\PageLegacyTrait;\nuse Grav\\Common\\Flex\\Types\\Pages\\Traits\\PageRoutableTrait;\nuse Grav\\Common\\Flex\\Types\\Pages\\Traits\\PageTranslateTrait;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Flex\\Pages\\FlexPageObject;\nuse Grav\\Framework\\Object\\ObjectCollection;\nuse Grav\\Framework\\Route\\Route;\nuse Grav\\Framework\\Route\\RouteFactory;\nuse Grav\\Plugin\\Admin\\Admin;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RuntimeException;\nuse stdClass;\nuse function array_key_exists;\nuse function count;\nuse function func_get_args;\nuse function in_array;\nuse function is_array;\n\n/**\n * Class GravPageObject\n * @package Grav\\Plugin\\FlexObjects\\Types\\GravPages\n *\n * @property string $name\n * @property string $slug\n * @property string $route\n * @property string $folder\n * @property int|false $order\n * @property string $template\n * @property string $language\n */\nclass PageObject extends FlexPageObject\n{\n    use FlexGravTrait;\n    use FlexObjectTrait;\n    use PageContentTrait;\n    use PageLegacyTrait;\n    use PageRoutableTrait;\n    use PageTranslateTrait;\n\n    /** @var string Language code, eg: 'en' */\n    protected $language;\n    /** @var string File format, eg. 'md' */\n    protected $format;\n\n    /** @var bool */\n    private $_initialized = false;\n\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n            'path' => true,\n            'full_order' => true,\n            'filterBy' => true,\n            'translated' => false,\n        ] + parent::getCachedMethods();\n    }\n\n    /**\n     * @return void\n     */\n    public function initialize(): void\n    {\n        if (!$this->_initialized) {\n            Grav::instance()->fireEvent('onPageProcessed', new Event(['page' => $this]));\n            $this->_initialized = true;\n        }\n    }\n\n    /**\n     * @param string|array $query\n     * @return Route|null\n     */\n    public function getRoute($query = []): ?Route\n    {\n        $path = $this->route();\n        if (null === $path) {\n            return null;\n        }\n\n        $route = RouteFactory::createFromString($path);\n        if ($lang = $route->getLanguage()) {\n            $grav = Grav::instance();\n            if (!$grav['config']->get('system.languages.include_default_lang')) {\n                /** @var Language $language */\n                $language = $grav['language'];\n                if ($lang === $language->getDefault()) {\n                    $route = $route->withLanguage('');\n                }\n            }\n        }\n        if (is_array($query)) {\n            foreach ($query as $key => $value) {\n                $route = $route->withQueryParam($key, $value);\n            }\n        } else {\n            $route = $route->withAddedPath($query);\n        }\n\n        return $route;\n    }\n\n    /**\n     * @inheritdoc PageInterface\n     */\n    public function getFormValue(string $name, $default = null, string $separator = null)\n    {\n        $test = new stdClass();\n\n        $value = $this->pageContentValue($name, $test);\n        if ($value !== $test) {\n            return $value;\n        }\n\n        switch ($name) {\n            case 'name':\n                // TODO: this should not be template!\n                return $this->getProperty('template');\n            case 'route':\n                $filesystem = Filesystem::getInstance(false);\n                $key = $filesystem->dirname($this->hasKey() ? '/' . $this->getKey() : '/');\n                return $key !== '/' ? $key : null;\n            case 'full_route':\n                return $this->hasKey() ? '/' . $this->getKey() : '';\n            case 'full_order':\n                return $this->full_order();\n            case 'lang':\n                return $this->getLanguage() ?? '';\n            case 'translations':\n                return $this->getLanguages();\n        }\n\n        return parent::getFormValue($name, $default, $separator);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getCacheKey()\n     */\n    public function getCacheKey(): string\n    {\n        $cacheKey = parent::getCacheKey();\n        if ($cacheKey) {\n            /** @var Language $language */\n            $language = Grav::instance()['language'];\n            $cacheKey .= '_' . $language->getActive();\n        }\n\n        return $cacheKey;\n    }\n\n    /**\n     * @param array $variables\n     * @return array\n     */\n    protected function onBeforeSave(array $variables)\n    {\n        $reorder = $variables[0] ?? true;\n\n        $meta = $this->getMetaData();\n        if (($meta['copy'] ?? false) === true) {\n            $this->folder = $this->getKey();\n        }\n\n        // Figure out storage path to the new route.\n        $parentKey = $this->getProperty('parent_key');\n        if ($parentKey !== '') {\n            $parentRoute = $this->getProperty('route');\n\n            // Root page cannot be moved.\n            if ($this->root()) {\n                throw new RuntimeException(sprintf('Root page cannot be moved to %s', $parentRoute));\n            }\n\n            // Make sure page isn't being moved under itself.\n            $key = $this->getStorageKey();\n\n            /** @var PageObject|null $parent */\n            $parent = $parentKey !== false ? $this->getFlexDirectory()->getObject($parentKey, 'storage_key') : null;\n            if (!$parent) {\n                // Page cannot be moved to non-existing location.\n                throw new RuntimeException(sprintf('Page /%s cannot be moved to non-existing path %s', $key, $parentRoute));\n            }\n\n            // TODO: make sure that the page doesn't exist yet if moved/copied.\n        }\n\n        if ($reorder === true && !$this->root()) {\n            $reorder = $this->_reorder;\n        }\n\n        // Force automatic reorder if item is supposed to be added to the last.\n        if (!is_array($reorder) && (int)$this->order() >= 999999) {\n            $reorder = [];\n        }\n\n        // Reorder siblings.\n        $siblings = is_array($reorder) ? ($this->reorderSiblings($reorder) ?? []) : [];\n\n        $data = $this->prepareStorage();\n        unset($data['header']);\n\n        foreach ($siblings as $sibling) {\n            $data = $sibling->prepareStorage();\n            unset($data['header']);\n        }\n\n        return ['reorder' => $reorder, 'siblings' => $siblings];\n    }\n\n    /**\n     * @param array $variables\n     * @return array\n     */\n    protected function onSave(array $variables): array\n    {\n        /** @var PageCollection $siblings */\n        $siblings = $variables['siblings'];\n        /** @var PageObject $sibling */\n        foreach ($siblings as $sibling) {\n            $sibling->save(false);\n        }\n\n        return $variables;\n    }\n\n    /**\n     * @param array $variables\n     */\n    protected function onAfterSave(array $variables): void\n    {\n        $this->getFlexDirectory()->reloadIndex();\n    }\n\n    /**\n     * @param UserInterface|null $user\n     */\n    public function check(UserInterface $user = null): void\n    {\n        parent::check($user);\n\n        if ($user && $this->isMoved()) {\n            $parentKey = $this->getProperty('parent_key');\n\n            /** @var PageObject|null $parent */\n            $parent = $this->getFlexDirectory()->getObject($parentKey, 'storage_key');\n            if (!$parent || !$parent->isAuthorized('create', null, $user)) {\n                throw new \\RuntimeException('Forbidden', 403);\n            }\n        }\n    }\n\n    /**\n     * @param array|bool $reorder\n     * @return static\n     */\n    public function save($reorder = true)\n    {\n        $variables = $this->onBeforeSave(func_get_args());\n\n        // Backwards compatibility with older plugins.\n        $fireEvents = $reorder && $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true);\n        $grav = $this->getContainer();\n        if ($fireEvents) {\n            $self = $this;\n            $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self]));\n            if ($self !== $this) {\n                throw new RuntimeException('Switching Flex Page object during onAdminSave event is not supported! Please update plugin.');\n            }\n        }\n\n        /** @var static $instance */\n        $instance = parent::save();\n        $variables = $this->onSave($variables);\n\n        $this->onAfterSave($variables);\n\n        // Backwards compatibility with older plugins.\n        if ($fireEvents) {\n            $grav->fireEvent('onAdminAfterSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => $this]));\n        }\n\n        // Reset original after save events have all been called.\n        $this->_originalObject = null;\n\n        return $instance;\n    }\n\n    /**\n     * @return static\n     */\n    public function delete()\n    {\n        $result = parent::delete();\n\n        // Backwards compatibility with older plugins.\n        $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true);\n        if ($fireEvents) {\n            $this->getContainer()->fireEvent('onAdminAfterDelete', new Event(['object' => $this]));\n        }\n\n        return $result;\n    }\n\n    /**\n     * Prepare move page to new location. Moves also everything that's under the current page.\n     *\n     * You need to call $this->save() in order to perform the move.\n     *\n     * @param PageInterface $parent New parent page.\n     * @return $this\n     */\n    public function move(PageInterface $parent)\n    {\n        if (!$parent instanceof FlexObjectInterface) {\n            throw new RuntimeException('Failed: Parent is not Flex Object');\n        }\n\n        $this->_reorder = [];\n        $this->setProperty('parent_key', $parent->getStorageKey());\n        $this->storeOriginal();\n\n        return $this;\n    }\n\n    /**\n     * @param UserInterface $user\n     * @param string $action\n     * @param string $scope\n     * @param bool $isMe\n     * @return bool|null\n     */\n    protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool\n    {\n        // Special case: creating a new page means checking parent for its permissions.\n        if ($action === 'create' && !$this->exists()) {\n            $parent = $this->parent();\n            if ($parent && method_exists($parent, 'isAuthorized')) {\n                return $parent->isAuthorized($action, $scope, $user);\n            }\n\n            return false;\n        }\n\n        return parent::isAuthorizedOverride($user, $action, $scope, $isMe);\n    }\n\n    /**\n     * @return bool\n     */\n    protected function isMoved(): bool\n    {\n        $storageKey = $this->getMasterKey();\n        $filesystem = Filesystem::getInstance(false);\n        $oldParentKey = ltrim($filesystem->dirname(\"/{$storageKey}\"), '/');\n        $newParentKey = $this->getProperty('parent_key');\n\n        return $this->exists() && $oldParentKey !== $newParentKey;\n    }\n\n    /**\n     * @param array $ordering\n     * @return PageCollection|null\n     * @phpstan-return ObjectCollection<string,PageObject>|null\n     */\n    protected function reorderSiblings(array $ordering)\n    {\n        $storageKey = $this->getMasterKey();\n        $isMoved = $this->isMoved();\n        $order = !$isMoved ? $this->order() : false;\n        if ($order !== false) {\n            $order = (int)$order;\n        }\n\n        $parent = $this->parent();\n        if (!$parent) {\n            throw new RuntimeException('Cannot reorder a page which has no parent');\n        }\n\n        /** @var PageCollection $siblings */\n        $siblings = $parent->children();\n        $siblings = $siblings->getCollection()->withOrdered();\n\n        // Handle special case where ordering isn't given.\n        if ($ordering === []) {\n            if ($order >= 999999) {\n                // Set ordering to point to be the last item, ignoring the object itself.\n                $order = 0;\n                foreach ($siblings as $sibling) {\n                    if ($sibling->getKey() !== $this->getKey()) {\n                        $order = max($order, (int)$sibling->order());\n                    }\n                }\n                $this->order($order + 1);\n            }\n\n            // Do not change sibling ordering.\n            return null;\n        }\n\n        $siblings = $siblings->orderBy(['order' => 'ASC']);\n\n        if ($storageKey !== null) {\n            if ($order !== false) {\n                // Add current page back to the list if it's ordered.\n                $siblings->set($storageKey, $this);\n            } else {\n                // Remove old copy of the current page from the siblings list.\n                $siblings->remove($storageKey);\n            }\n        }\n\n        // Add missing siblings into the end of the list, keeping the previous ordering between them.\n        foreach ($siblings as $sibling) {\n            $folder = (string)$sibling->getProperty('folder');\n            $basename = preg_replace('|^\\d+\\.|', '', $folder);\n            if (!in_array($basename, $ordering, true)) {\n                $ordering[] = $basename;\n            }\n        }\n\n        // Reorder.\n        $ordering = array_flip(array_values($ordering));\n        $count = count($ordering);\n        foreach ($siblings as $sibling) {\n            $folder = (string)$sibling->getProperty('folder');\n            $basename = preg_replace('|^\\d+\\.|', '', $folder);\n            $newOrder = $ordering[$basename] ?? null;\n            $newOrder = null !== $newOrder ? $newOrder + 1 : (int)$sibling->order() + $count;\n            $sibling->order($newOrder);\n        }\n\n        $siblings = $siblings->orderBy(['order' => 'ASC']);\n        $siblings->removeElement($this);\n\n        // If menu item was moved, just make it to be the last in order.\n        if ($isMoved && $this->order() !== false) {\n            $parentKey = $this->getProperty('parent_key');\n            if ($parentKey === '') {\n                /** @var PageIndex $index */\n                $index = $this->getFlexDirectory()->getIndex();\n                $newParent = $index->getRoot();\n            } else {\n                $newParent = $this->getFlexDirectory()->getObject($parentKey, 'storage_key');\n                if (!$newParent instanceof PageInterface) {\n                    throw new RuntimeException(\"New parent page '{$parentKey}' not found.\");\n                }\n            }\n            /** @var PageCollection $newSiblings */\n            $newSiblings = $newParent->children();\n            $newSiblings = $newSiblings->getCollection()->withOrdered();\n            $order = 0;\n            foreach ($newSiblings as $sibling) {\n                $order = max($order, (int)$sibling->order());\n            }\n            $this->order($order + 1);\n        }\n\n        return $siblings;\n    }\n\n    /**\n     * @return string\n     */\n    public function full_order(): string\n    {\n        $route = $this->path() . '/' . $this->folder();\n\n        return preg_replace(PageIndex::ORDER_LIST_REGEX, '\\\\1', $route) ?? $route;\n    }\n\n    /**\n     * @param string $name\n     * @return Blueprint\n     */\n    protected function doGetBlueprint(string $name = ''): Blueprint\n    {\n        try {\n            // Make sure that pages has been initialized.\n            Pages::getTypes();\n\n            // TODO: We need to move raw blueprint logic to Grav itself to remove admin dependency here.\n            if ($name === 'raw') {\n                // Admin RAW mode.\n                if ($this->isAdminSite()) {\n                    /** @var Admin $admin */\n                    $admin = Grav::instance()['admin'];\n\n                    $template = $this->isModule() ? 'modular_raw' : ($this->root() ? 'root_raw' : 'raw');\n\n                    return $admin->blueprints(\"admin/pages/{$template}\");\n                }\n            }\n\n            $template = $this->getProperty('template') . ($name ? '.' . $name : '');\n\n            $blueprint = $this->getFlexDirectory()->getBlueprint($template, 'blueprints://pages');\n        } catch (RuntimeException $e) {\n            $template = 'default' . ($name ? '.' . $name : '');\n\n            $blueprint = $this->getFlexDirectory()->getBlueprint($template, 'blueprints://pages');\n        }\n\n        $isNew = $blueprint->get('initialized', false) === false;\n        if ($isNew === true && $name === '') {\n            // Support onBlueprintCreated event just like in Pages::blueprints($template)\n            $blueprint->set('initialized', true);\n            $blueprint->setFilename($template);\n\n            Grav::instance()->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $template]));\n        }\n\n        return $blueprint;\n    }\n\n    /**\n     * @param array $options\n     * @return array\n     */\n    public function getLevelListing(array $options): array\n    {\n        $index = $this->getFlexDirectory()->getIndex();\n        if (!is_callable([$index, 'getLevelListing'])) {\n            return [];\n        }\n\n        // Deal with relative paths.\n        $initial = $options['initial'] ?? null;\n        $var = $initial ? 'leaf_route' : 'route';\n        $route = $options[$var] ?? '';\n        if ($route !== '' && !str_starts_with($route, '/')) {\n            $filesystem = Filesystem::getInstance();\n\n            $route = \"/{$this->getKey()}/{$route}\";\n            $route = $filesystem->normalize($route);\n\n            $options[$var] = $route;\n        }\n\n        [$status, $message, $response,] = $index->getLevelListing($options);\n\n        return [$status, $message, $response, $options[$var] ?? null];\n    }\n\n    /**\n     * Filter page (true/false) by given filters.\n     *\n     * - search: string\n     * - extension: string\n     * - module: bool\n     * - visible: bool\n     * - routable: bool\n     * - published: bool\n     * - page: bool\n     * - translated: bool\n     *\n     * @param array $filters\n     * @param bool $recursive\n     * @return bool\n     */\n    public function filterBy(array $filters, bool $recursive = false): bool\n    {\n        $language = $filters['language'] ?? null;\n        if (null !== $language) {\n            /** @var PageObject $test */\n            $test = $this->getTranslation($language) ?? $this;\n        } else {\n            $test = $this;\n        }\n\n        foreach ($filters as $key => $value) {\n            switch ($key) {\n                case 'search':\n                    $matches = $test->search((string)$value) > 0.0;\n                    break;\n                case 'page_type':\n                    $types = $value ? explode(',', $value) : [];\n                    $matches = in_array($test->template(), $types, true);\n                    break;\n                case 'extension':\n                    $matches = Utils::contains((string)$value, $test->extension());\n                    break;\n                case 'routable':\n                    $matches = $test->isRoutable() === (bool)$value;\n                    break;\n                case 'published':\n                    $matches = $test->isPublished() === (bool)$value;\n                    break;\n                case 'visible':\n                    $matches = $test->isVisible() === (bool)$value;\n                    break;\n                case 'module':\n                    $matches = $test->isModule() === (bool)$value;\n                    break;\n                case 'page':\n                    $matches = $test->isPage() === (bool)$value;\n                    break;\n                case 'folder':\n                    $matches = $test->isPage() === !$value;\n                    break;\n                case 'translated':\n                    $matches = $test->hasTranslation() === (bool)$value;\n                    break;\n                default:\n                    $matches = true;\n                    break;\n            }\n\n            // If current filter does not match, we still may have match as a parent.\n            if ($matches === false) {\n                if (!$recursive) {\n                    return false;\n                }\n\n                /** @var PageIndex $index */\n                $index = $this->children()->getIndex();\n\n                return $index->filterBy($filters, true)->count() > 0;\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::exists()\n     */\n    public function exists(): bool\n    {\n        return $this->root ?: parent::exists();\n    }\n\n    /**\n     * @return array\n     */\n    public function __debugInfo(): array\n    {\n        $list = parent::__debugInfo();\n\n        return $list + [\n            '_content_meta:private' => $this->getContentMeta(),\n            '_content:private' => $this->getRawContent()\n        ];\n    }\n\n    /**\n     * @param array $elements\n     * @param bool $extended\n     */\n    protected function filterElements(array &$elements, bool $extended = false): void\n    {\n        // Change parent page if needed.\n        if (array_key_exists('route', $elements) && isset($elements['folder'], $elements['name'])) {\n            $elements['template'] = $elements['name'];\n\n            // Figure out storage path to the new route.\n            $parentKey = trim($elements['route'] ?? '', '/');\n            if ($parentKey !== '') {\n                /** @var PageObject|null $parent */\n                $parent = $this->getFlexDirectory()->getObject($parentKey);\n                $parentKey = $parent ? $parent->getStorageKey() : $parentKey;\n            }\n\n            $elements['parent_key'] = $parentKey;\n        }\n\n        // Deal with ordering=bool and order=page1,page2,page3.\n        if ($this->root()) {\n            // Root page doesn't have ordering.\n            unset($elements['ordering'], $elements['order']);\n        } elseif (array_key_exists('ordering', $elements) && array_key_exists('order', $elements)) {\n            // Store ordering.\n            $ordering = $elements['order'] ?? null;\n            $this->_reorder = !empty($ordering) ? explode(',', $ordering) : [];\n\n            $order = false;\n            if ((bool)($elements['ordering'] ?? false)) {\n                $order = $this->order();\n                if ($order === false) {\n                    $order = 999999;\n                }\n            }\n\n            $elements['order'] = $order;\n        }\n\n        parent::filterElements($elements, true);\n    }\n\n    /**\n     * @return array\n     */\n    public function prepareStorage(): array\n    {\n        $meta = $this->getMetaData();\n        $oldLang = $meta['lang'] ?? '';\n        $newLang = $this->getProperty('lang') ?? '';\n\n        // Always clone the page to the new language.\n        if ($oldLang !== $newLang) {\n            $meta['clone'] = true;\n        }\n\n        // Make sure that certain elements are always sent to the storage layer.\n        $elements = [\n            '__META' => $meta,\n            'storage_key' => $this->getStorageKey(),\n            'parent_key' => $this->getProperty('parent_key'),\n            'order' => $this->getProperty('order'),\n            'folder' => preg_replace('|^\\d+\\.|', '', $this->getProperty('folder') ?? ''),\n            'template' => preg_replace('|modular/|', '', $this->getProperty('template') ?? ''),\n            'lang' => $newLang\n        ] + parent::prepareStorage();\n\n        return $elements;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Pages\\Storage;\n\nuse FilesystemIterator;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Flex\\Types\\Pages\\PageIndex;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Grav\\Framework\\Flex\\Storage\\FolderStorage;\nuse RocketTheme\\Toolbox\\File\\MarkdownFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse SplFileInfo;\nuse function in_array;\nuse function is_string;\n\n/**\n * Class GravPageStorage\n * @package Grav\\Plugin\\FlexObjects\\Types\\GravPages\n */\nclass PageStorage extends FolderStorage\n{\n    /** @var bool */\n    protected $ignore_hidden;\n    /** @var array */\n    protected $ignore_files;\n    /** @var array */\n    protected $ignore_folders;\n    /** @var bool */\n    protected $include_default_lang_file_extension;\n    /** @var bool */\n    protected $recurse;\n    /** @var string */\n    protected $base_path;\n\n    /** @var int */\n    protected $flags;\n    /** @var string */\n    protected $regex;\n\n    /**\n     * @param array $options\n     */\n    protected function initOptions(array $options): void\n    {\n        parent::initOptions($options);\n\n        $this->flags = FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_FILEINFO\n            | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS;\n\n        $grav = Grav::instance();\n\n        $config = $grav['config'];\n        $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden');\n        $this->ignore_files = (array)$config->get('system.pages.ignore_files');\n        $this->ignore_folders = (array)$config->get('system.pages.ignore_folders');\n        $this->include_default_lang_file_extension = (bool)$config->get('system.languages.include_default_lang_file_extension', true);\n        $this->recurse = (bool)($options['recurse'] ?? true);\n        $this->regex = '/(\\.([\\w\\d_-]+))?\\.md$/D';\n    }\n\n    /**\n     * @param string $key\n     * @param bool $variations\n     * @return array\n     */\n    public function parseKey(string $key, bool $variations = true): array\n    {\n        if (mb_strpos($key, '|') !== false) {\n            [$key, $params] = explode('|', $key, 2);\n        } else {\n            $params = '';\n        }\n        $key = ltrim($key, '/');\n\n        $keys = parent::parseKey($key, false) + ['params' => $params];\n\n        if ($variations) {\n            $keys += $this->parseParams($key, $params);\n        }\n\n        return $keys;\n    }\n\n    /**\n     * @param string $key\n     * @return string\n     */\n    public function readFrontmatter(string $key): string\n    {\n        $path = $this->getPathFromKey($key);\n        $file = $this->getFile($path);\n        try {\n            if ($file instanceof MarkdownFile) {\n                $frontmatter = $file->frontmatter();\n            } else {\n                $frontmatter = $file->raw();\n            }\n        } catch (RuntimeException $e) {\n            $frontmatter = 'ERROR: ' . $e->getMessage();\n        } finally {\n            $file->free();\n            unset($file);\n        }\n\n        return $frontmatter;\n    }\n\n    /**\n     * @param string $key\n     * @return string\n     */\n    public function readRaw(string $key): string\n    {\n        $path = $this->getPathFromKey($key);\n        $file = $this->getFile($path);\n        try {\n            $raw = $file->raw();\n        } catch (RuntimeException $e) {\n            $raw = 'ERROR: ' . $e->getMessage();\n        } finally {\n            $file->free();\n            unset($file);\n        }\n\n        return $raw;\n    }\n\n    /**\n     * @param array $keys\n     * @param bool $includeParams\n     * @return string\n     */\n    public function buildStorageKey(array $keys, bool $includeParams = true): string\n    {\n        $key = $keys['key'] ?? null;\n        if (null === $key) {\n            $key = $keys['parent_key'] ?? '';\n            if ($key !== '') {\n                $key .= '/';\n            }\n            $order = $keys['order'] ?? null;\n            $folder = $keys['folder'] ?? 'undefined';\n            $key .= is_numeric($order) ? sprintf('%02d.%s', $order, $folder) : $folder;\n        }\n\n        $params = $includeParams ? $this->buildStorageKeyParams($keys) : '';\n\n        return $params ? \"{$key}|{$params}\" : $key;\n    }\n\n    /**\n     * @param array $keys\n     * @return string\n     */\n    public function buildStorageKeyParams(array $keys): string\n    {\n        $params = $keys['template'] ?? '';\n        $language = $keys['lang'] ?? '';\n        if ($language) {\n            $params .= '.' . $language;\n        }\n\n        return $params;\n    }\n\n    /**\n     * @param array $keys\n     * @return string\n     */\n    public function buildFolder(array $keys): string\n    {\n        return $this->dataFolder . '/' . $this->buildStorageKey($keys, false);\n    }\n\n    /**\n     * @param array $keys\n     * @return string\n     */\n    public function buildFilename(array $keys): string\n    {\n        $file = $this->buildStorageKeyParams($keys);\n\n        // Template is optional; if it is missing, we need to have to load the object metadata.\n        if ($file && $file[0] === '.') {\n            $meta = $this->getObjectMeta($this->buildStorageKey($keys, false));\n            $file = ($meta['template'] ?? 'folder') . $file;\n        }\n\n        return $file . $this->dataExt;\n    }\n\n    /**\n     * @param array $keys\n     * @return string\n     */\n    public function buildFilepath(array $keys): string\n    {\n        $folder = $this->buildFolder($keys);\n        $filename = $this->buildFilename($keys);\n\n        return rtrim($folder, '/') !== $folder ? $folder . $filename : $folder . '/' . $filename;\n    }\n\n    /**\n     * @param array $row\n     * @param bool $setDefaultLang\n     * @return array\n     */\n    public function extractKeysFromRow(array $row, bool $setDefaultLang = true): array\n    {\n        $meta = $row['__META'] ?? null;\n        $storageKey = $row['storage_key'] ?? $meta['storage_key']  ?? '';\n        $keyMeta = $storageKey !== '' ? $this->extractKeysFromStorageKey($storageKey) : null;\n        $parentKey = $row['parent_key'] ?? $meta['parent_key'] ?? $keyMeta['parent_key'] ?? '';\n        $order = $row['order'] ?? $meta['order'] ?? $keyMeta['order'] ?? null;\n        $folder = $row['folder'] ?? $meta['folder']  ?? $keyMeta['folder'] ?? '';\n        $template = $row['template'] ?? $meta['template'] ?? $keyMeta['template'] ?? '';\n        $lang = $row['lang'] ?? $meta['lang'] ?? $keyMeta['lang'] ?? '';\n\n        // Handle default language, if it should be saved without language extension.\n        if ($setDefaultLang && empty($meta['markdown'][$lang])) {\n            $grav = Grav::instance();\n\n            /** @var Language $language */\n            $language = $grav['language'];\n            $default = $language->getDefault();\n            // Make sure that the default language file doesn't exist before overriding it.\n            if (empty($meta['markdown'][$default])) {\n                if ($this->include_default_lang_file_extension) {\n                    if ($lang === '') {\n                        $lang = $language->getDefault();\n                    }\n                } elseif ($lang === $language->getDefault()) {\n                    $lang = '';\n                }\n            }\n        }\n\n        $keys = [\n            'key' => null,\n            'params' => null,\n            'parent_key' => $parentKey,\n            'order' => is_numeric($order) ? (int)$order : null,\n            'folder' => $folder,\n            'template' => $template,\n            'lang' => $lang\n        ];\n\n        $keys['key'] = $this->buildStorageKey($keys, false);\n        $keys['params'] = $this->buildStorageKeyParams($keys);\n\n        return $keys;\n    }\n\n    /**\n     * @param string $key\n     * @return array\n     */\n    public function extractKeysFromStorageKey(string $key): array\n    {\n        if (mb_strpos($key, '|') !== false) {\n            [$key, $params] = explode('|', $key, 2);\n            [$template, $language] = mb_strpos($params, '.') !== false ? explode('.', $params, 2) : [$params, ''];\n        } else {\n            $params = $template = $language = '';\n        }\n        $objectKey = Utils::basename($key);\n        if (preg_match('|^(\\d+)\\.(.+)$|', $objectKey, $matches)) {\n            [, $order, $folder] = $matches;\n        } else {\n            [$order, $folder] = ['', $objectKey];\n        }\n\n        $filesystem = Filesystem::getInstance(false);\n\n        $parentKey = ltrim($filesystem->dirname('/' . $key), '/');\n\n        return [\n            'key' => $key,\n            'params' => $params,\n            'parent_key' => $parentKey,\n            'order' => is_numeric($order) ? (int)$order : null,\n            'folder' => $folder,\n            'template' => $template,\n            'lang' => $language\n        ];\n    }\n\n    /**\n     * @param string $key\n     * @param string $params\n     * @return array\n     */\n    protected function parseParams(string $key, string $params): array\n    {\n        if (mb_strpos($params, '.') !== false) {\n            [$template, $language] = explode('.', $params, 2);\n        } else {\n            $template = $params;\n            $language = '';\n        }\n\n        if ($template === '') {\n            $meta = $this->getObjectMeta($key);\n            $template = $meta['template'] ?? 'folder';\n        }\n\n        return [\n            'file' => $template . ($language ? '.' . $language : ''),\n            'template' => $template,\n            'lang' => $language\n        ];\n    }\n\n    /**\n     * Prepares the row for saving and returns the storage key for the record.\n     *\n     * @param array $row\n     */\n    protected function prepareRow(array &$row): void\n    {\n        // Remove keys used in the filesystem.\n        unset($row['parent_key'], $row['order'], $row['folder'], $row['template'], $row['lang']);\n    }\n\n    /**\n     * @param string $key\n     * @return array\n     */\n    protected function loadRow(string $key): ?array\n    {\n        $data = parent::loadRow($key);\n\n        // Special case for root page.\n        if ($key === '' && null !== $data) {\n            $data['root'] = true;\n        }\n\n        return $data;\n    }\n\n    /**\n     * Page storage supports moving and copying the pages and their languages.\n     *\n     * $row['__META']['copy'] = true       Use this if you want to copy the whole folder, otherwise it will be moved\n     * $row['__META']['clone'] = true      Use this if you want to clone the file, otherwise it will be renamed\n     *\n     * @param string $key\n     * @param array $row\n     * @return array\n     */\n    protected function saveRow(string $key, array $row): array\n    {\n        // Initialize all key-related variables.\n        $newKeys = $this->extractKeysFromRow($row);\n        $newKey = $this->buildStorageKey($newKeys);\n        $newFolder = $this->buildFolder($newKeys);\n        $newFilename = $this->buildFilename($newKeys);\n        $newFilepath = rtrim($newFolder, '/') !== $newFolder ? $newFolder . $newFilename : $newFolder . '/' . $newFilename;\n\n        try {\n            if ($key === '' && empty($row['root'])) {\n                throw new RuntimeException('Page has no path');\n            }\n\n            $grav = Grav::instance();\n\n            /** @var Debugger $debugger */\n            $debugger = $grav['debugger'];\n            $debugger->addMessage(\"Save page: {$newKey}\", 'debug');\n\n            // Check if the row already exists.\n            $oldKey = $row['__META']['storage_key'] ?? null;\n            if (is_string($oldKey)) {\n                // Initialize all old key-related variables.\n                $oldKeys = $this->extractKeysFromRow(['__META' => $row['__META']], false);\n                $oldFolder = $this->buildFolder($oldKeys);\n                $oldFilename = $this->buildFilename($oldKeys);\n\n                // Check if folder has changed.\n                if ($oldFolder !== $newFolder && file_exists($oldFolder)) {\n                    $isCopy = $row['__META']['copy'] ?? false;\n                    if ($isCopy) {\n                        if (strpos($newFolder, $oldFolder . '/') === 0) {\n                            throw new RuntimeException(sprintf('Page /%s cannot be copied to itself', $oldKey));\n                        }\n\n                        $this->copyRow($oldKey, $newKey);\n                        $debugger->addMessage(\"Page copied: {$oldFolder} => {$newFolder}\", 'debug');\n                    } else {\n                        if (strpos($newFolder, $oldFolder . '/') === 0) {\n                            throw new RuntimeException(sprintf('Page /%s cannot be moved to itself', $oldKey));\n                        }\n\n                        $this->renameRow($oldKey, $newKey);\n                        $debugger->addMessage(\"Page moved: {$oldFolder} => {$newFolder}\", 'debug');\n                    }\n                }\n\n                // Check if filename has changed.\n                if ($oldFilename !== $newFilename) {\n                    // Get instance of the old file (we have already copied/moved it).\n                    $oldFilepath = \"{$newFolder}/{$oldFilename}\";\n                    $file = $this->getFile($oldFilepath);\n\n                    // Rename the file if we aren't supposed to clone it.\n                    $isClone = $row['__META']['clone'] ?? false;\n                    if (!$isClone && $file->exists()) {\n                        /** @var UniformResourceLocator $locator */\n                        $locator = $grav['locator'];\n                        $toPath = $locator->isStream($newFilepath) ? $locator->findResource($newFilepath, true, true) : GRAV_ROOT . \"/{$newFilepath}\";\n                        $success = $file->rename($toPath);\n                        if (!$success) {\n                            throw new RuntimeException(\"Changing page template failed: {$oldFilepath} => {$newFilepath}\");\n                        }\n                        $debugger->addMessage(\"Page template changed: {$oldFilename} => {$newFilename}\", 'debug');\n                    } else {\n                        $file = null;\n                        $debugger->addMessage(\"Page template created: {$newFilename}\", 'debug');\n                    }\n                }\n            }\n\n            // Clean up the data to be saved.\n            $this->prepareRow($row);\n            unset($row['__META'], $row['__ERROR']);\n\n            if (!isset($file)) {\n                $file = $this->getFile($newFilepath);\n            }\n\n            // Compare existing file content to the new one and save the file only if content has been changed.\n            $file->free();\n            $oldRaw = $file->raw();\n            $file->content($row);\n            $newRaw = $file->raw();\n            if ($oldRaw !== $newRaw) {\n                $file->save($row);\n                $debugger->addMessage(\"Page content saved: {$newFilepath}\", 'debug');\n            } else {\n                $debugger->addMessage('Page content has not been changed, do not update the file', 'debug');\n            }\n        } catch (RuntimeException $e) {\n            $name = isset($file) ? $file->filename() : $newKey;\n\n            throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $name, $e->getMessage()));\n        } finally {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            $locator->clearCache();\n\n            if (isset($file)) {\n                $file->free();\n                unset($file);\n            }\n        }\n\n        $row['__META'] = $this->getObjectMeta($newKey, true);\n\n        return $row;\n    }\n\n    /**\n     * Check if page folder should be deleted.\n     *\n     * Deleting page can be done either by deleting everything or just a single language.\n     * If key contains the language, delete only it, unless it is the last language.\n     *\n     * @param string $key\n     * @return bool\n     */\n    protected function canDeleteFolder(string $key): bool\n    {\n        // Return true if there's no language in the key.\n        $keys = $this->extractKeysFromStorageKey($key);\n        if (!$keys['lang']) {\n            return true;\n        }\n\n        // Get the main key and reload meta.\n        $key = $this->buildStorageKey($keys);\n        $meta = $this->getObjectMeta($key, true);\n\n        // Return true if there aren't any markdown files left.\n        return empty($meta['markdown'] ?? []);\n    }\n\n    /**\n     * Get key from the filesystem path.\n     *\n     * @param  string $path\n     * @return string\n     */\n    protected function getKeyFromPath(string $path): string\n    {\n        if ($this->base_path) {\n            $path = $this->base_path . '/' . $path;\n        }\n\n        return $path;\n    }\n\n    /**\n     * Returns list of all stored keys in [key => timestamp] pairs.\n     *\n     * @return array\n     */\n    protected function buildIndex(): array\n    {\n        $this->clearCache();\n\n        return $this->getIndexMeta();\n    }\n\n    /**\n     * @param string $key\n     * @param bool $reload\n     * @return array\n     */\n    protected function getObjectMeta(string $key, bool $reload = false): array\n    {\n        $keys = $this->extractKeysFromStorageKey($key);\n        $key = $keys['key'];\n\n        if ($reload || !isset($this->meta[$key])) {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            if (mb_strpos($key, '@@') === false) {\n                $path = $this->getStoragePath($key);\n                if (is_string($path)) {\n                    $path = $locator->isStream($path) ? $locator->findResource($path) : GRAV_ROOT . \"/{$path}\";\n                } else {\n                    $path = null;\n                }\n            } else {\n                $path = null;\n            }\n\n            $modified = 0;\n            $markdown = [];\n            $children = [];\n\n            if (is_string($path) && is_dir($path)) {\n                $modified = filemtime($path);\n                $iterator = new FilesystemIterator($path, $this->flags);\n\n                /** @var SplFileInfo $info */\n                foreach ($iterator as $k => $info) {\n                    // Ignore all hidden files if set.\n                    if ($k === '' || ($this->ignore_hidden && $k[0] === '.')) {\n                        continue;\n                    }\n\n                    if ($info->isDir()) {\n                        // Ignore all folders in ignore list.\n                        if ($this->ignore_folders && in_array($k, $this->ignore_folders, true)) {\n                            continue;\n                        }\n\n                        $children[$k] = false;\n                    } else {\n                        // Ignore all files in ignore list.\n                        if ($this->ignore_files && in_array($k, $this->ignore_files, true)) {\n                            continue;\n                        }\n\n                        $timestamp = $info->getMTime();\n\n                        // Page is the one that matches to $page_extensions list with the lowest index number.\n                        if (preg_match($this->regex, $k, $matches)) {\n                            $mark = $matches[2] ?? '';\n                            $ext = $matches[1] ?? '';\n                            $ext .= $this->dataExt;\n                            $markdown[$mark][Utils::basename($k, $ext)] = $timestamp;\n                        }\n\n                        $modified = max($modified, $timestamp);\n                    }\n                }\n            }\n\n            $rawRoute = trim(preg_replace(PageIndex::PAGE_ROUTE_REGEX, '/', \"/{$key}\") ?? '', '/');\n            $route = PageIndex::normalizeRoute($rawRoute);\n\n            ksort($markdown, SORT_NATURAL | SORT_FLAG_CASE);\n            ksort($children, SORT_NATURAL | SORT_FLAG_CASE);\n\n            $file = array_key_first($markdown[''] ?? (reset($markdown) ?: []));\n\n            $meta = [\n                'key' => $route,\n                'storage_key' => $key,\n                'template' => $file,\n                'storage_timestamp' => $modified,\n            ];\n            if ($markdown) {\n                $meta['markdown'] = $markdown;\n            }\n            if ($children) {\n                $meta['children'] = $children;\n            }\n            $meta['checksum'] = md5(json_encode($meta) ?: '');\n\n            // Cache meta as copy.\n            $this->meta[$key] = $meta;\n        } else {\n            $meta = $this->meta[$key];\n        }\n\n        $params = $keys['params'];\n        if ($params) {\n            $language = $keys['lang'];\n            $template = $keys['template'] ?: array_key_first($meta['markdown'][$language]) ?? $meta['template'];\n            $meta['exists'] = ($template && !empty($meta['children'])) || isset($meta['markdown'][$language][$template]);\n            $meta['storage_key'] .= '|' . $params;\n            $meta['template'] = $template;\n            $meta['lang'] = $language;\n        }\n\n        return $meta;\n    }\n\n    /**\n     * @return array\n     */\n    protected function getIndexMeta(): array\n    {\n        $queue = [''];\n        $list = [];\n        do {\n            $current = array_pop($queue);\n            if ($current === null) {\n                break;\n            }\n\n            $meta = $this->getObjectMeta($current);\n            $storage_key = $meta['storage_key'];\n\n            if (!empty($meta['children'])) {\n                $prefix = $storage_key . ($storage_key !== '' ? '/' : '');\n\n                foreach ($meta['children'] as $child => $value) {\n                    $queue[] = $prefix . $child;\n                }\n            }\n\n            $list[$storage_key] = $meta;\n        } while ($queue);\n\n        ksort($list, SORT_NATURAL | SORT_FLAG_CASE);\n\n        // Update parent timestamps.\n        foreach (array_reverse($list) as $storage_key => $meta) {\n            if ($storage_key !== '') {\n                $filesystem = Filesystem::getInstance(false);\n\n                $storage_key = (string)$storage_key;\n                $parentKey = $filesystem->dirname($storage_key);\n                if ($parentKey === '.') {\n                    $parentKey = '';\n                }\n\n                /** @phpstan-var array{'storage_key': string, 'storage_timestamp': int, 'children': array<string, mixed>} $parent */\n                $parent = &$list[$parentKey];\n                $basename = Utils::basename($storage_key);\n\n                if (isset($parent['children'][$basename])) {\n                    $timestamp = $meta['storage_timestamp'];\n                    $parent['children'][$basename] = $timestamp;\n                    if ($basename && $basename[0] === '_') {\n                        $parent['storage_timestamp'] = max($parent['storage_timestamp'], $timestamp);\n                    }\n                }\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getNewKey(): string\n    {\n        throw new RuntimeException('Generating random key is disabled for pages');\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Pages/Traits/PageContentTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Pages\\Traits;\n\nuse Grav\\Common\\Utils;\n\n/**\n * Implements PageContentInterface.\n */\ntrait PageContentTrait\n{\n    /**\n     * @inheritdoc\n     */\n    public function id($var = null): string\n    {\n        $property = 'id';\n        $value = null === $var ? $this->getProperty($property) : null;\n        if (null === $value) {\n            $value = $this->language() . ($var ?? ($this->modified() . md5($this->filePath() ?? $this->getKey())));\n\n            $this->setProperty($property, $value);\n            if ($this->doHasProperty($property)) {\n                $value = $this->getProperty($property);\n            }\n        }\n\n        return $value;\n    }\n\n\n    /**\n     * @inheritdoc\n     */\n    public function date($var = null): int\n    {\n        return $this->loadHeaderProperty(\n            'date',\n            $var,\n            function ($value) {\n                $value = $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : false;\n\n                if (!$value) {\n                    // Get the specific translation updated date.\n                    $meta = $this->getMetaData();\n                    $language = $meta['lang'] ?? '';\n                    $template = $this->getProperty('template');\n                    $value = $meta['markdown'][$language][$template] ?? 0;\n                }\n\n                return $value ?: $this->modified();\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     * @param bool $bool\n     */\n    public function isPage(bool $bool = true): bool\n    {\n        $meta = $this->getMetaData();\n\n        return empty($meta['markdown']) !== $bool;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Pages/Traits/PageLegacyTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Pages\\Traits;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Collection;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexIndexInterface;\nuse InvalidArgumentException;\nuse function is_array;\nuse function is_string;\n\n/**\n * Implements PageLegacyInterface.\n */\ntrait PageLegacyTrait\n{\n    /**\n     * Returns children of this page.\n     *\n     * @return FlexIndexInterface|PageCollectionInterface|Collection\n     */\n    public function children()\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::children();\n        }\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n        $path = $this->path() ?? '';\n\n        return $pages->children($path);\n    }\n\n    /**\n     * Check to see if this item is the first in an array of sub-pages.\n     *\n     * @return bool True if item is first.\n     */\n    public function isFirst(): bool\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::isFirst();\n        }\n\n        $path = $this->path();\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if (null !== $path && $collection instanceof PageCollectionInterface) {\n            return $collection->isFirst($path);\n        }\n\n        return true;\n    }\n\n    /**\n     * Check to see if this item is the last in an array of sub-pages.\n     *\n     * @return bool True if item is last\n     */\n    public function isLast(): bool\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::isLast();\n        }\n\n        $path = $this->path();\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if (null !== $path && $collection instanceof PageCollectionInterface) {\n            return $collection->isLast($path);\n        }\n\n        return true;\n    }\n\n    /**\n     * Returns the adjacent sibling based on a direction.\n     *\n     * @param  int $direction either -1 or +1\n     * @return PageInterface|false             the sibling page\n     */\n    public function adjacentSibling($direction = 1)\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::adjacentSibling($direction);\n        }\n\n        $path = $this->path();\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if (null !== $path && $collection instanceof PageCollectionInterface) {\n            $child = $collection->adjacentSibling($path, $direction);\n            if ($child instanceof PageInterface) {\n                return $child;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Helper method to return an ancestor page.\n     *\n     * @param string|null $lookup Name of the parent folder\n     * @return PageInterface|null page you were looking for if it exists\n     */\n    public function ancestor($lookup = null)\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::ancestor($lookup);\n        }\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->ancestor($this->getProperty('parent_route'), $lookup);\n    }\n\n    /**\n     * Method that contains shared logic for inherited() and inheritedField()\n     *\n     * @param string $field Name of the parent folder\n     * @return array\n     */\n    protected function getInheritedParams($field): array\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::getInheritedParams($field);\n        }\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        $inherited = $pages->inherited($this->getProperty('parent_route'), $field);\n        $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : [];\n        $currentParams = (array)$this->getFormValue('header.' . $field);\n        if ($inheritedParams && is_array($inheritedParams)) {\n            $currentParams = array_replace_recursive($inheritedParams, $currentParams);\n        }\n\n        return [$inherited, $currentParams];\n    }\n\n    /**\n     * Helper method to return a page.\n     *\n     * @param string $url the url of the page\n     * @param bool $all\n     * @return PageInterface|null page you were looking for if it exists\n     */\n    public function find($url, $all = false)\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::find($url, $all);\n        }\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->find($url, $all);\n    }\n\n    /**\n     * Get a collection of pages in the current context.\n     *\n     * @param string|array $params\n     * @param bool $pagination\n     * @return PageCollectionInterface|Collection\n     * @throws InvalidArgumentException\n     */\n    public function collection($params = 'content', $pagination = true)\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::collection($params, $pagination);\n        }\n\n        if (is_string($params)) {\n            // Look into a page header field.\n            $params = (array)$this->getFormValue('header.' . $params);\n        } elseif (!is_array($params)) {\n            throw new InvalidArgumentException('Argument should be either header variable name or array of parameters');\n        }\n\n        $context = [\n            'pagination' => $pagination,\n            'self' => $this\n        ];\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->getCollection($params, $context);\n    }\n\n    /**\n     * @param string|array $value\n     * @param bool $only_published\n     * @return PageCollectionInterface|Collection\n     */\n    public function evaluate($value, $only_published = true)\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::collection($value, $only_published);\n        }\n\n        $params = [\n            'items' => $value,\n            'published' => $only_published\n        ];\n        $context = [\n            'event' => false,\n            'pagination' => false,\n            'url_taxonomy_filters' => false,\n            'self' => $this\n        ];\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->getCollection($params, $context);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Pages/Traits/PageRoutableTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Pages\\Traits;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse RuntimeException;\n\n/**\n * Implements PageRoutableInterface.\n */\ntrait PageRoutableTrait\n{\n    /**\n     * Gets and Sets the parent object for this page\n     *\n     * @param  PageInterface|null $var the parent page object\n     * @return PageInterface|null the parent page object if it exists.\n     */\n\n    public function parent(PageInterface $var = null)\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::parent();\n        }\n\n        if (null !== $var) {\n            throw new RuntimeException('Not Implemented');\n        }\n\n        if ($this->root()) {\n            return null;\n        }\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        $filesystem = Filesystem::getInstance(false);\n\n        // FIXME: this does not work, needs to use $pages->get() with cached parent id!\n        $key = $this->getKey();\n        $parent_route = $filesystem->dirname('/' . $key);\n\n        return $parent_route !== '/' ? $pages->find($parent_route) : $pages->root();\n    }\n\n    /**\n     * Returns the item in the current position.\n     *\n     * @return int|null   the index of the current page.\n     */\n    public function currentPosition(): ?int\n    {\n        $path = $this->path();\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if (null !== $path && $collection instanceof PageCollectionInterface) {\n            return $collection->currentPosition($path);\n        }\n\n        return 1;\n    }\n\n    /**\n     * Returns whether or not this page is the currently active page requested via the URL.\n     *\n     * @return bool True if it is active\n     */\n    public function active(): bool\n    {\n        $grav = Grav::instance();\n        $uri_path = rtrim(urldecode($grav['uri']->path()), '/') ?: '/';\n        $routes = $grav['pages']->routes();\n\n        return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path();\n    }\n\n    /**\n     * Returns whether or not this URI's URL contains the URL of the active page.\n     * Or in other words, is this page's URL in the current URL\n     *\n     * @return bool True if active child exists\n     */\n    public function activeChild(): bool\n    {\n        $grav = Grav::instance();\n        /** @var Uri $uri */\n        $uri = $grav['uri'];\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n        $uri_path = rtrim(urldecode($uri->path()), '/');\n        $routes = $pages->routes();\n\n        if (isset($routes[$uri_path])) {\n            $page = $pages->find($uri->route());\n            /** @var PageInterface|null $child_page */\n            $child_page = $page ? $page->parent() : null;\n            while ($child_page && !$child_page->root()) {\n                if ($this->path() === $child_page->path()) {\n                    return true;\n                }\n                $child_page = $child_page->parent();\n            }\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Pages/Traits/PageTranslateTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Pages\\Traits;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Page;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse SplFileInfo;\n\n/**\n * Implements PageTranslateInterface\n */\ntrait PageTranslateTrait\n{\n    /**\n     * Return an array with the routes of other translated languages\n     *\n     * @param bool $onlyPublished only return published translations\n     * @return array the page translated languages\n     */\n    public function translatedLanguages($onlyPublished = false): array\n    {\n        if (Utils::isAdminPlugin()) {\n            return parent::translatedLanguages();\n        }\n\n        $translated = $this->getLanguageTemplates();\n        if (!$translated) {\n            return $translated;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var Language $language */\n        $language = $grav['language'];\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        $languages = $language->getLanguages();\n        $languages[] = '';\n        $defaultCode = $language->getDefault();\n\n        if (isset($translated[$defaultCode])) {\n            unset($translated['']);\n        }\n\n        foreach ($translated as $key => &$template) {\n            $template .= $key !== '' ? \".{$key}.md\" : '.md';\n        }\n        unset($template);\n\n        $translated = array_intersect_key($translated, array_flip($languages));\n\n        $folder = $this->getStorageFolder();\n        if (!$folder) {\n            return [];\n        }\n        $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . \"/{$folder}\";\n\n        $list = array_fill_keys($languages, null);\n        foreach ($translated as $languageCode => $languageFile) {\n            $languageExtension = $languageCode ? \".{$languageCode}.md\" : '.md';\n            $path = \"{$folder}/{$languageFile}\";\n\n            // FIXME: use flex, also rawRoute() does not fully work?\n            $aPage = new Page();\n            $aPage->init(new SplFileInfo($path), $languageExtension);\n            if ($onlyPublished && !$aPage->published()) {\n                continue;\n            }\n\n            $header = $aPage->header();\n            // @phpstan-ignore-next-line\n            $routes = $header->routes ?? [];\n            $route = $routes['default'] ?? $aPage->rawRoute();\n            if (!$route) {\n                $route = $aPage->route();\n            }\n\n            $list[$languageCode ?: $defaultCode] = $route ?? '';\n        }\n\n        $list = array_filter($list, static function ($var) {\n            return null !== $var;\n        });\n\n        // Hack to get the same result as with old pages.\n        foreach ($list as &$path) {\n            if ($path === '') {\n                $path = null;\n            }\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/UserGroups/UserGroupCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\UserGroups;\n\nuse Grav\\Common\\Flex\\FlexCollection;\n\n/**\n * Class UserGroupCollection\n * @package Grav\\Common\\Flex\\Types\\UserGroups\n *\n * @extends FlexCollection<UserGroupObject>\n */\nclass UserGroupCollection extends FlexCollection\n{\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n            'authorize' => false,\n        ] + parent::getCachedMethods();\n    }\n\n    /**\n     * Checks user authorization to the action.\n     *\n     * @param  string $action\n     * @param  string|null $scope\n     * @return bool|null\n     */\n    public function authorize(string $action, string $scope = null): ?bool\n    {\n        $authorized = null;\n        /** @var UserGroupObject $object */\n        foreach ($this as $object) {\n            $auth = $object->authorize($action, $scope);\n            if ($auth === true) {\n                $authorized = true;\n            } elseif ($auth === false) {\n                return false;\n            }\n        }\n\n        return $authorized;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/UserGroups/UserGroupIndex.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\UserGroups;\n\nuse Grav\\Common\\Flex\\FlexIndex;\n\n/**\n * Class GroupIndex\n * @package Grav\\Common\\User\\FlexUser\n *\n * @extends FlexIndex<UserGroupObject,UserGroupCollection>\n */\nclass UserGroupIndex extends FlexIndex\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/UserGroups/UserGroupObject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\UserGroups;\n\nuse Grav\\Common\\Flex\\FlexObject;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Access;\nuse Grav\\Common\\User\\Interfaces\\UserGroupInterface;\nuse function is_bool;\n\n/**\n * Flex User Group\n *\n * @package Grav\\Common\\User\n *\n * @property string $groupname\n * @property Access $access\n */\nclass UserGroupObject extends FlexObject implements UserGroupInterface\n{\n    /** @var Access */\n    protected $_access;\n    /** @var array|null */\n    protected $access;\n\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n            'authorize' => false,\n        ] + parent::getCachedMethods();\n    }\n\n    /**\n     * @return string\n     */\n    public function getTitle(): string\n    {\n        return $this->getProperty('readableName');\n    }\n\n    /**\n     * Checks user authorization to the action.\n     *\n     * @param  string $action\n     * @param  string|null $scope\n     * @return bool|null\n     */\n    public function authorize(string $action, string $scope = null): ?bool\n    {\n        if ($scope === 'test') {\n            $scope = null;\n        } elseif (!$this->getProperty('enabled', true)) {\n            return null;\n        }\n\n        $access = $this->getAccess();\n\n        $authorized = $access->authorize($action, $scope);\n        if (is_bool($authorized)) {\n            return $authorized;\n        }\n\n        return $access->authorize('admin.super') ? true : null;\n    }\n\n    public static function groupNames(): array\n    {\n        $groups = [];\n        $user_groups = Grav::instance()['user_groups'] ?? [];\n\n        foreach ($user_groups as $key => $group) {\n            $groups[$key] = $group->readableName;\n        }\n\n        return $groups;\n    }\n\n    /**\n     * @return Access\n     */\n    protected function getAccess(): Access\n    {\n        if (null === $this->_access) {\n            $this->getProperty('access');\n        }\n\n        return $this->_access;\n    }\n\n    /**\n     * @param mixed $value\n     * @return array\n     */\n    protected function offsetLoad_access($value): array\n    {\n        if (!$value instanceof Access) {\n            $value = new Access($value);\n        }\n\n        $this->_access = $value;\n\n        return $value->jsonSerialize();\n    }\n\n    /**\n     * @param mixed $value\n     * @return array\n     */\n    protected function offsetPrepare_access($value): array\n    {\n        return $this->offsetLoad_access($value);\n    }\n\n    /**\n     * @param array|null $value\n     * @return array|null\n     */\n    protected function offsetSerialize_access(?array $value): ?array\n    {\n        return $value;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Users/Storage/UserFileStorage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Users\\Storage;\n\nuse Grav\\Framework\\Flex\\Storage\\FileStorage;\n\n/**\n * Class UserFileStorage\n * @package Grav\\Common\\Flex\\Types\\Users\\Storage\n */\nclass UserFileStorage extends FileStorage\n{\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::getMediaPath()\n     */\n    public function getMediaPath(string $key = null): ?string\n    {\n        // There is no media support for file storage (fallback to common location).\n        return null;\n    }\n\n    /**\n     * Prepares the row for saving and returns the storage key for the record.\n     *\n     * @param array $row\n     */\n    protected function prepareRow(array &$row): void\n    {\n        parent::prepareRow($row);\n\n        $access = $row['access'] ?? [];\n        unset($row['access']);\n        if ($access) {\n            $row['access'] = $access;\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Users/Storage/UserFolderStorage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Users\\Storage;\n\nuse Grav\\Framework\\Flex\\Storage\\FolderStorage;\n\n/**\n * Class UserFolderStorage\n * @package Grav\\Common\\Flex\\Types\\Users\\Storage\n */\nclass UserFolderStorage extends FolderStorage\n{\n    /**\n     * Prepares the row for saving and returns the storage key for the record.\n     *\n     * @param array $row\n     */\n    protected function prepareRow(array &$row): void\n    {\n        parent::prepareRow($row);\n\n        $access = $row['access'] ?? [];\n        unset($row['access']);\n        if ($access) {\n            $row['access'] = $access;\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Users/Traits/UserObjectLegacyTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Users\\Traits;\n\nuse Grav\\Common\\Page\\Medium\\ImageMedium;\nuse Grav\\Common\\Page\\Medium\\StaticImageMedium;\nuse function count;\n\n/**\n * Trait UserObjectLegacyTrait\n * @package Grav\\Common\\Flex\\Types\\Users\\Traits\n */\ntrait UserObjectLegacyTrait\n{\n    /**\n     * Merge two configurations together.\n     *\n     * @param array $data\n     * @return $this\n     * @deprecated 1.6 Use `->update($data)` instead (same but with data validation & filtering, file upload support).\n     */\n    public function merge(array $data)\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->update($data) method instead', E_USER_DEPRECATED);\n\n        $this->setElements($this->getBlueprint()->mergeData($this->toArray(), $data));\n\n        return $this;\n    }\n\n    /**\n     * Return media object for the User's avatar.\n     *\n     * @return ImageMedium|StaticImageMedium|null\n     * @deprecated 1.6 Use ->getAvatarImage() method instead.\n     */\n    public function getAvatarMedia()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarImage() method instead', E_USER_DEPRECATED);\n\n        return $this->getAvatarImage();\n    }\n\n    /**\n     * Return the User's avatar URL\n     *\n     * @return string\n     * @deprecated 1.6 Use ->getAvatarUrl() method instead.\n     */\n    public function avatarUrl()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getAvatarUrl() method instead', E_USER_DEPRECATED);\n\n        return $this->getAvatarUrl();\n    }\n\n    /**\n     * Checks user authorization to the action.\n     * Ensures backwards compatibility\n     *\n     * @param string $action\n     * @return bool\n     * @deprecated 1.5 Use ->authorize() method instead.\n     */\n    public function authorise($action)\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->authorize() method instead', E_USER_DEPRECATED);\n\n        return $this->authorize($action) ?? false;\n    }\n\n    /**\n     * Implements Countable interface.\n     *\n     * @return int\n     * @deprecated 1.6 Method makes no sense for user account.\n     */\n    #[\\ReturnTypeWillChange]\n    public function count()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED);\n\n        return count($this->jsonSerialize());\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Users/UserCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Users;\n\nuse Grav\\Common\\Flex\\FlexCollection;\nuse Grav\\Common\\User\\Interfaces\\UserCollectionInterface;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse function is_string;\n\n/**\n * Class UserCollection\n * @package Grav\\Common\\Flex\\Types\\Users\n *\n * @extends FlexCollection<UserObject>\n */\nclass UserCollection extends FlexCollection implements UserCollectionInterface\n{\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n                'authorize' => 'session',\n            ] + parent::getCachedMethods();\n    }\n\n    /**\n     * Load user account.\n     *\n     * Always creates user object. To check if user exists, use $this->exists().\n     *\n     * @param string $username\n     * @return UserObject\n     */\n    public function load($username): UserInterface\n    {\n        $username = (string)$username;\n\n        if ($username !== '') {\n            $key = $this->filterUsername($username);\n            $user = $this->get($key);\n            if ($user) {\n                return $user;\n            }\n        } else {\n            $key = '';\n        }\n\n        $directory = $this->getFlexDirectory();\n\n        /** @var UserObject $object */\n        $object = $directory->createObject(\n            [\n                'username' => $username,\n                'state' => 'enabled'\n            ],\n            $key\n        );\n\n        return $object;\n    }\n\n    /**\n     * Find a user by username, email, etc\n     *\n     * @param string $query the query to search for\n     * @param string|string[] $fields the fields to search\n     * @return UserObject\n     */\n    public function find($query, $fields = ['username', 'email']): UserInterface\n    {\n        if (is_string($query) && $query !== '') {\n            foreach ((array)$fields as $field) {\n                if ($field === 'key') {\n                    $user = $this->get($query);\n                } elseif ($field === 'storage_key') {\n                    $user = $this->withKeyField('storage_key')->get($query);\n                } elseif ($field === 'flex_key') {\n                    $user = $this->withKeyField('flex_key')->get($query);\n                } elseif ($field === 'username') {\n                    $user = $this->get($this->filterUsername($query));\n                } else {\n                    $user = parent::find($query, $field);\n                }\n                if ($user instanceof UserObject) {\n                    return $user;\n                }\n            }\n        }\n\n        return $this->load('');\n    }\n\n    /**\n     * Delete user account.\n     *\n     * @param string $username\n     * @return bool True if user account was found and was deleted.\n     */\n    public function delete($username): bool\n    {\n        $user = $this->load($username);\n\n        $exists = $user->exists();\n        if ($exists) {\n            $user->delete();\n        }\n\n        return $exists;\n    }\n\n    /**\n     * @param string $key\n     * @return string\n     */\n    protected function filterUsername(string $key): string\n    {\n        $storage = $this->getFlexDirectory()->getStorage();\n        if (method_exists($storage, 'normalizeKey')) {\n            return $storage->normalizeKey($key);\n        }\n\n        return mb_strtolower($key);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Users/UserIndex.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Users;\n\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Flex\\FlexIndex;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Interfaces\\UserCollectionInterface;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexStorageInterface;\nuse Monolog\\Logger;\nuse function count;\nuse function is_string;\n\n/**\n * Class UserIndex\n * @package Grav\\Common\\Flex\\Types\\Users\n *\n * @extends FlexIndex<UserObject,UserCollection>\n */\nclass UserIndex extends FlexIndex implements UserCollectionInterface\n{\n    public const VERSION = parent::VERSION . '.2';\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @return array\n     */\n    public static function loadEntriesFromStorage(FlexStorageInterface $storage): array\n    {\n        // Load saved index.\n        $index = static::loadIndex($storage);\n\n        $version = $index['version'] ?? 0;\n        $force = static::VERSION !== $version;\n\n        // TODO: Following check flex index to be out of sync after some saves, disabled until better solution is found.\n        //$timestamp = $index['timestamp'] ?? 0;\n        //if (!$force && $timestamp && $timestamp > time() - 1) {\n        //    return $index['index'];\n        //}\n\n        // Load up-to-date index.\n        $entries = parent::loadEntriesFromStorage($storage);\n\n        return static::updateIndexFile($storage, $index['index'], $entries, ['force_update' => $force]);\n    }\n\n    /**\n     * @param array $meta\n     * @param array $data\n     * @param FlexStorageInterface $storage\n     * @return void\n     */\n    public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage): void\n    {\n        // Username can also be number and stored as such.\n        $key = (string)($data['username'] ?? $meta['key'] ?? $meta['storage_key']);\n        $meta['key'] = static::filterUsername($key, $storage);\n        $meta['email'] = isset($data['email']) ? mb_strtolower($data['email']) : null;\n    }\n\n    /**\n     * Load user account.\n     *\n     * Always creates user object. To check if user exists, use $this->exists().\n     *\n     * @param string $username\n     * @return UserObject\n     */\n    public function load($username): UserInterface\n    {\n        $username = (string)$username;\n\n        if ($username !== '') {\n            $key = static::filterUsername($username, $this->getFlexDirectory()->getStorage());\n            $user = $this->get($key);\n            if ($user) {\n                return $user;\n            }\n        } else {\n            $key = '';\n        }\n\n        $directory = $this->getFlexDirectory();\n\n        /** @var UserObject $object */\n        $object = $directory->createObject(\n            [\n                'username' => $username,\n                'state' => 'enabled'\n            ],\n            $key\n        );\n\n        return $object;\n    }\n\n    /**\n     * Delete user account.\n     *\n     * @param string $username\n     * @return bool True if user account was found and was deleted.\n     */\n    public function delete($username): bool\n    {\n        $user = $this->load($username);\n\n        $exists = $user->exists();\n        if ($exists) {\n            $user->delete();\n        }\n\n        return $exists;\n    }\n\n    /**\n     * Find a user by username, email, etc\n     *\n     * @param string $query the query to search for\n     * @param array $fields the fields to search\n     * @return UserObject\n     */\n    public function find($query, $fields = ['username', 'email']): UserInterface\n    {\n        if (is_string($query) && $query !== '') {\n            foreach ((array)$fields as $field) {\n                if ($field === 'key') {\n                    $user = $this->get($query);\n                } elseif ($field === 'storage_key') {\n                    $user = $this->withKeyField('storage_key')->get($query);\n                } elseif ($field === 'flex_key') {\n                    $user = $this->withKeyField('flex_key')->get($query);\n                } elseif ($field === 'email') {\n                    $email = mb_strtolower($query);\n                    $user = $this->withKeyField('email')->get($email);\n                } elseif ($field === 'username') {\n                    $username = static::filterUsername($query, $this->getFlexDirectory()->getStorage());\n                    $user = $this->get($username);\n                } else {\n                    $user = $this->__call('find', [$query, $field]);\n                }\n                if ($user) {\n                    return $user;\n                }\n            }\n        }\n\n        return $this->load('');\n    }\n\n    /**\n     * @param string $key\n     * @param FlexStorageInterface $storage\n     * @return string\n     */\n    protected static function filterUsername(string $key, FlexStorageInterface $storage): string\n    {\n        return method_exists($storage, 'normalizeKey') ? $storage->normalizeKey($key) : $key;\n    }\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @return CompiledYamlFile|null\n     */\n    protected static function getIndexFile(FlexStorageInterface $storage)\n    {\n        // Load saved index file.\n        $grav = Grav::instance();\n        $locator = $grav['locator'];\n        $filename = $locator->findResource('user-data://flex/indexes/accounts.yaml', true, true);\n\n        return CompiledYamlFile::instance($filename);\n    }\n\n    /**\n     * @param array $entries\n     * @param array $added\n     * @param array $updated\n     * @param array $removed\n     */\n    protected static function onChanges(array $entries, array $added, array $updated, array $removed): void\n    {\n        $message = sprintf('Flex: User index updated, %d objects (%d added, %d updated, %d removed).', count($entries), count($added), count($updated), count($removed));\n\n        $grav = Grav::instance();\n\n        /** @var Logger $logger */\n        $logger = $grav['log'];\n        $logger->addDebug($message);\n\n        /** @var Debugger $debugger */\n        $debugger = $grav['debugger'];\n        $debugger->addMessage($message, 'debug');\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Flex/Types/Users/UserObject.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Flex\\Types\\Users;\n\nuse Closure;\nuse Countable;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Flex\\FlexObject;\nuse Grav\\Common\\Flex\\Traits\\FlexGravTrait;\nuse Grav\\Common\\Flex\\Traits\\FlexObjectTrait;\nuse Grav\\Common\\Flex\\Types\\Users\\Traits\\UserObjectLegacyTrait;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Media\\Interfaces\\MediaCollectionInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaUploadInterface;\nuse Grav\\Common\\Page\\Media;\nuse Grav\\Common\\Page\\Medium\\MediumFactory;\nuse Grav\\Common\\User\\Access;\nuse Grav\\Common\\User\\Authentication;\nuse Grav\\Common\\Flex\\Types\\UserGroups\\UserGroupCollection;\nuse Grav\\Common\\Flex\\Types\\UserGroups\\UserGroupIndex;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\User\\Traits\\UserTrait;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Contracts\\Relationships\\ToOneRelationshipInterface;\nuse Grav\\Framework\\File\\Formatter\\JsonFormatter;\nuse Grav\\Framework\\File\\Formatter\\YamlFormatter;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Grav\\Framework\\Flex\\Flex;\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse Grav\\Framework\\Flex\\Storage\\FileStorage;\nuse Grav\\Framework\\Flex\\Traits\\FlexMediaTrait;\nuse Grav\\Framework\\Flex\\Traits\\FlexRelationshipsTrait;\nuse Grav\\Framework\\Form\\FormFlashFile;\nuse Grav\\Framework\\Media\\MediaIdentifier;\nuse Grav\\Framework\\Media\\UploadedMediaObject;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\File\\FileInterface;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function is_array;\nuse function is_bool;\nuse function is_object;\n\n/**\n * Flex User\n *\n * Flex User is mostly compatible with the older User class, except on few key areas:\n *\n * - Constructor parameters have been changed. Old code creating a new user does not work.\n * - Serializer has been changed -- existing sessions will be killed.\n *\n * @package Grav\\Common\\User\n *\n * @property string $username\n * @property string $email\n * @property string $fullname\n * @property string $state\n * @property array $groups\n * @property array $access\n * @property bool $authenticated\n * @property bool $authorized\n */\nclass UserObject extends FlexObject implements UserInterface, Countable\n{\n    use FlexGravTrait;\n    use FlexObjectTrait;\n    use FlexMediaTrait {\n        getMedia as private getFlexMedia;\n        getMediaFolder as private getFlexMediaFolder;\n    }\n    use UserTrait;\n    use UserObjectLegacyTrait;\n    use FlexRelationshipsTrait;\n\n    /** @var Closure|null */\n    static public $authorizeCallable;\n    /** @var Closure|null */\n    static public $isAuthorizedCallable;\n\n    /** @var array|null */\n    protected $_uploads_original;\n    /** @var FileInterface|null */\n    protected $_storage;\n    /** @var UserGroupIndex */\n    protected $_groups;\n    /** @var Access */\n    protected $_access;\n    /** @var array|null */\n    protected $access;\n\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n            'authorize' => 'session',\n            'load' => false,\n            'find' => false,\n            'remove' => false,\n            'get' => true,\n            'set' => false,\n            'undef' => false,\n            'def' => false,\n        ] + parent::getCachedMethods();\n    }\n\n    /**\n     * UserObject constructor.\n     * @param array $elements\n     * @param string $key\n     * @param FlexDirectory $directory\n     * @param bool $validate\n     */\n    public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false)\n    {\n        // User can only be authenticated via login.\n        unset($elements['authenticated'], $elements['authorized']);\n\n        // Define username if it's not set.\n        if (!isset($elements['username'])) {\n            $storageKey = $elements['__META']['storage_key'] ?? null;\n            $storage = $directory->getStorage();\n            if (null !== $storageKey && method_exists($storage, 'normalizeKey') && $key === $storage->normalizeKey($storageKey)) {\n                $elements['username'] = $storageKey;\n            } else {\n                $elements['username'] = $key;\n            }\n        }\n\n        // Define state if it isn't set.\n        if (!isset($elements['state'])) {\n            $elements['state'] = 'enabled';\n        }\n\n        parent::__construct($elements, $key, $directory, $validate);\n    }\n\n    public function __clone()\n    {\n        $this->_access = null;\n        $this->_groups = null;\n\n        parent::__clone();\n    }\n\n    /**\n     * @return void\n     */\n    public function onPrepareRegistration(): void\n    {\n        if (!$this->getProperty('access')) {\n            /** @var Config $config */\n            $config = Grav::instance()['config'];\n\n            $groups = $config->get('plugins.login.user_registration.groups', '');\n            $access = $config->get('plugins.login.user_registration.access', ['site' => ['login' => true]]);\n\n            $this->setProperty('groups', $groups);\n            $this->setProperty('access', $access);\n        }\n    }\n\n    /**\n     * Helper to get content editor will fall back if not set\n     *\n     * @return string\n     */\n    public function getContentEditor(): string\n    {\n        return $this->getProperty('content_editor', 'default');\n    }\n\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @example $value = $this->get('this.is.my.nested.variable');\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $default    Default value (or null).\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return mixed  Value.\n     */\n    public function get($name, $default = null, $separator = null)\n    {\n        return $this->getNestedProperty($name, $default, $separator);\n    }\n\n    /**\n     * Set value by using dot notation for nested arrays/objects.\n     *\n     * @example $data->set('this.is.my.nested.variable', $value);\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $value      New value.\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function set($name, $value, $separator = null)\n    {\n        $this->setNestedProperty($name, $value, $separator);\n\n        return $this;\n    }\n\n    /**\n     * Unset value by using dot notation for nested arrays/objects.\n     *\n     * @example $data->undef('this.is.my.nested.variable');\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function undef($name, $separator = null)\n    {\n        $this->unsetNestedProperty($name, $separator);\n\n        return $this;\n    }\n\n    /**\n     * Set default value by using dot notation for nested arrays/objects.\n     *\n     * @example $data->def('this.is.my.nested.variable', 'default');\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $default    Default value (or null).\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function def($name, $default = null, $separator = null)\n    {\n        $this->defNestedProperty($name, $default, $separator);\n\n        return $this;\n    }\n\n    /**\n     * @param UserInterface|null $user\n     * @return bool\n     */\n    public function isMyself(?UserInterface $user = null): bool\n    {\n        if (null === $user) {\n            $user = $this->getActiveUser();\n            if ($user && !$user->authenticated) {\n                $user = null;\n            }\n        }\n\n        return $user && $this->username === $user->username;\n    }\n\n    /**\n     * Checks user authorization to the action.\n     *\n     * @param  string $action\n     * @param  string|null $scope\n     * @return bool|null\n     */\n    public function authorize(string $action, string $scope = null): ?bool\n    {\n        if ($scope === 'test') {\n            // Special scope to test user permissions.\n            $scope = null;\n        } else {\n            // User needs to be enabled.\n            if ($this->getProperty('state') !== 'enabled') {\n                return false;\n            }\n\n            // User needs to be logged in.\n            if (!$this->getProperty('authenticated')) {\n                return false;\n            }\n\n            if (strpos($action, 'login') === false && !$this->getProperty('authorized')) {\n                // User needs to be authorized (2FA).\n                return false;\n            }\n\n            // Workaround bug in Login::isUserAuthorizedForPage() <= Login v3.0.4\n            if ((string)(int)$action === $action) {\n                return false;\n            }\n        }\n\n        // Check custom application access.\n        $authorizeCallable = static::$authorizeCallable;\n        if ($authorizeCallable instanceof Closure) {\n            $callable = $authorizeCallable->bindTo($this, $this);\n            $authorized = $callable($action, $scope);\n            if (is_bool($authorized)) {\n                return $authorized;\n            }\n        }\n\n        // Check user access.\n        $access = $this->getAccess();\n        $authorized = $access->authorize($action, $scope);\n        if (is_bool($authorized)) {\n            return $authorized;\n        }\n\n        // Check group access.\n        $authorized = $this->getGroups()->authorize($action, $scope);\n        if (is_bool($authorized)) {\n            return $authorized;\n        }\n\n        // If any specific rule isn't hit, check if user is a superuser.\n        return $access->authorize('admin.super') === true;\n    }\n\n    /**\n     * @param string $property\n     * @param mixed $default\n     * @return mixed\n     */\n    public function getProperty($property, $default = null)\n    {\n        $value = parent::getProperty($property, $default);\n\n        if ($property === 'avatar') {\n            $settings = $this->getMediaFieldSettings($property);\n            $value = $this->parseFileProperty($value, $settings);\n        }\n\n        return $value;\n    }\n\n    /**\n     * @return UserGroupIndex\n     */\n    public function getRoles(): UserGroupIndex\n    {\n        return $this->getGroups();\n    }\n\n    /**\n     * Convert object into an array.\n     *\n     * @return array\n     */\n    public function toArray()\n    {\n        $array = $this->jsonSerialize();\n\n        $settings = $this->getMediaFieldSettings('avatar');\n        $array['avatar'] = $this->parseFileProperty($array['avatar'] ?? null, $settings);\n\n        return $array;\n    }\n\n    /**\n     * Convert object into YAML string.\n     *\n     * @param  int $inline  The level where you switch to inline YAML.\n     * @param  int $indent  The amount of spaces to use for indentation of nested nodes.\n     * @return string A YAML string representing the object.\n     */\n    public function toYaml($inline = 5, $indent = 2)\n    {\n        $yaml = new YamlFormatter(['inline' => $inline, 'indent' => $indent]);\n\n        return $yaml->encode($this->toArray());\n    }\n\n    /**\n     * Convert object into JSON string.\n     *\n     * @return string\n     */\n    public function toJson()\n    {\n        $json = new JsonFormatter();\n\n        return $json->encode($this->toArray());\n    }\n\n    /**\n     * Join nested values together by using blueprints.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $value      Value to be joined.\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function join($name, $value, $separator = null)\n    {\n        $separator = $separator ?? '.';\n        $old = $this->get($name, null, $separator);\n        if ($old !== null) {\n            if (!is_array($old)) {\n                throw new RuntimeException('Value ' . $old);\n            }\n\n            if (is_object($value)) {\n                $value = (array) $value;\n            } elseif (!is_array($value)) {\n                throw new RuntimeException('Value ' . $value);\n            }\n\n            $value = $this->getBlueprint()->mergeData($old, $value, $name, $separator);\n        }\n\n        $this->set($name, $value, $separator);\n\n        return $this;\n    }\n\n    /**\n     * Get nested structure containing default values defined in the blueprints.\n     *\n     * Fields without default value are ignored in the list.\n\n     * @return array\n     */\n    public function getDefaults()\n    {\n        return $this->getBlueprint()->getDefaults();\n    }\n\n    /**\n     * Set default values by using blueprints.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $value      Value to be joined.\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function joinDefaults($name, $value, $separator = null)\n    {\n        if (is_object($value)) {\n            $value = (array) $value;\n        }\n\n        $old = $this->get($name, null, $separator);\n        if ($old !== null) {\n            $value = $this->getBlueprint()->mergeData($value, $old, $name, $separator ?? '.');\n        }\n\n        $this->setNestedProperty($name, $value, $separator);\n\n        return $this;\n    }\n\n    /**\n     * Get value from the configuration and join it with given data.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param array|object $value Value to be joined.\n     * @param string  $separator  Separator, defaults to '.'\n     * @return array\n     * @throws RuntimeException\n     */\n    public function getJoined($name, $value, $separator = null)\n    {\n        if (is_object($value)) {\n            $value = (array) $value;\n        } elseif (!is_array($value)) {\n            throw new RuntimeException('Value ' . $value);\n        }\n\n        $old = $this->get($name, null, $separator);\n\n        if ($old === null) {\n            // No value set; no need to join data.\n            return $value;\n        }\n\n        if (!is_array($old)) {\n            throw new RuntimeException('Value ' . $old);\n        }\n\n        // Return joined data.\n        return $this->getBlueprint()->mergeData($old, $value, $name, $separator ?? '.');\n    }\n\n    /**\n     * Set default values to the configuration if variables were not set.\n     *\n     * @param array $data\n     * @return $this\n     */\n    public function setDefaults(array $data)\n    {\n        $this->setElements($this->getBlueprint()->mergeData($data, $this->toArray()));\n\n        return $this;\n    }\n\n    /**\n     * Validate by blueprints.\n     *\n     * @return $this\n     * @throws \\Exception\n     */\n    public function validate()\n    {\n        $this->getBlueprint()->validate($this->toArray());\n\n        return $this;\n    }\n\n    /**\n     * Filter all items by using blueprints.\n     * @return $this\n     */\n    public function filter()\n    {\n        $this->setElements($this->getBlueprint()->filter($this->toArray()));\n\n        return $this;\n    }\n\n    /**\n     * Get extra items which haven't been defined in blueprints.\n     *\n     * @return array\n     */\n    public function extra()\n    {\n        return $this->getBlueprint()->extra($this->toArray());\n    }\n\n    /**\n     * Return unmodified data as raw string.\n     *\n     * NOTE: This function only returns data which has been saved to the storage.\n     *\n     * @return string\n     */\n    public function raw()\n    {\n        $file = $this->file();\n\n        return $file ? $file->raw() : '';\n    }\n\n    /**\n     * Set or get the data storage.\n     *\n     * @param FileInterface|null $storage Optionally enter a new storage.\n     * @return FileInterface|null\n     */\n    public function file(FileInterface $storage = null)\n    {\n        if (null !== $storage) {\n            $this->_storage = $storage;\n        }\n\n        return $this->_storage;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isValid(): bool\n    {\n        return $this->getProperty('state') !== null;\n    }\n\n    /**\n     * Save user\n     *\n     * @return static\n     */\n    public function save()\n    {\n        // TODO: We may want to handle this in the storage layer in the future.\n        $key = $this->getStorageKey();\n        if (!$key || strpos($key, '@@')) {\n            $storage = $this->getFlexDirectory()->getStorage();\n            if ($storage instanceof FileStorage) {\n                $this->setStorageKey($this->getKey());\n            }\n        }\n\n        $password = $this->getProperty('password') ?? $this->getProperty('password1');\n        if (null !== $password && '' !== $password) {\n            $password2 = $this->getProperty('password2');\n            if (!\\is_string($password) || ($password2 && $password !== $password2)) {\n                throw new \\RuntimeException('Passwords did not match.');\n            }\n\n            $this->setProperty('hashed_password', Authentication::create($password));\n        }\n        $this->unsetProperty('password');\n        $this->unsetProperty('password1');\n        $this->unsetProperty('password2');\n\n        // Backwards compatibility with older plugins.\n        $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true);\n        $grav = $this->getContainer();\n        if ($fireEvents) {\n            $self = $this;\n            $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self]));\n            if ($self !== $this) {\n                throw new RuntimeException('Switching Flex User object during onAdminSave event is not supported! Please update plugin.');\n            }\n        }\n\n        $instance = parent::save();\n\n        // Backwards compatibility with older plugins.\n        if ($fireEvents) {\n            $grav->fireEvent('onAdminAfterSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => $this]));\n        }\n\n        return $instance;\n    }\n\n    /**\n     * @return array\n     */\n    public function prepareStorage(): array\n    {\n        $elements = parent::prepareStorage();\n\n        // Do not save authorization information.\n        unset($elements['authenticated'], $elements['authorized']);\n\n        return $elements;\n    }\n\n    /**\n     * @return MediaCollectionInterface\n     */\n    public function getMedia()\n    {\n        /** @var Media $media */\n        $media = $this->getFlexMedia();\n\n        // Deal with shared avatar folder.\n        $path = $this->getAvatarFile();\n        if ($path && !$media[$path] && is_file($path)) {\n            $medium = MediumFactory::fromFile($path);\n            if ($medium) {\n                $media->add($path, $medium);\n                $name = Utils::basename($path);\n                if ($name !== $path) {\n                    $media->add($name, $medium);\n                }\n            }\n        }\n\n        return $media;\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getMediaFolder(): ?string\n    {\n        $folder = $this->getFlexMediaFolder();\n\n        // Check for shared media\n        if (!$folder && !$this->getFlexDirectory()->getMediaFolder()) {\n            $this->_loadMedia = false;\n            $folder = $this->getBlueprint()->fields()['avatar']['destination'] ?? 'account://avatars';\n        }\n\n        return $folder;\n    }\n\n    /**\n     * @param string $name\n     * @return array|object|null\n     * @internal\n     */\n    public function initRelationship(string $name)\n    {\n        switch ($name) {\n            case 'media':\n                $list = [];\n                foreach ($this->getMedia()->all() as $filename => $object) {\n                    $list[] = $this->buildMediaObject(null, $filename, $object);\n                }\n\n                return $list;\n            case 'avatar':\n                return $this->buildMediaObject('avatar', basename($this->getAvatarUrl()), $this->getAvatarImage());\n        }\n\n        throw new \\InvalidArgumentException(sprintf('%s: Relationship %s does not exist', $this->getFlexType(), $name));\n    }\n\n    /**\n     * @return bool Return true if relationships were updated.\n     */\n    protected function updateRelationships(): bool\n    {\n        $modified = $this->getRelationships()->getModified();\n        if ($modified) {\n            foreach ($modified as $relationship) {\n                $name = $relationship->getName();\n                switch ($name) {\n                    case 'avatar':\n                        \\assert($relationship instanceof ToOneRelationshipInterface);\n                        $this->updateAvatarRelationship($relationship);\n                        break;\n                    default:\n                        throw new \\InvalidArgumentException(sprintf('%s: Relationship %s cannot be modified', $this->getFlexType(), $name), 400);\n                }\n            }\n\n            $this->resetRelationships();\n\n            return true;\n        }\n\n        return false;\n    }\n\n    /**\n     * @param ToOneRelationshipInterface $relationship\n     */\n    protected function updateAvatarRelationship(ToOneRelationshipInterface $relationship): void\n    {\n        $files = [];\n        $avatar = $this->getAvatarImage();\n        if ($avatar) {\n            $files['avatar'][$avatar->filename] = null;\n        }\n\n        $identifier = $relationship->getIdentifier();\n        if ($identifier) {\n            \\assert($identifier instanceof MediaIdentifier);\n            $object = $identifier->getObject();\n            if ($object instanceof UploadedMediaObject) {\n                $uploadedFile = $object->getUploadedFile();\n                if ($uploadedFile) {\n                    $files['avatar'][$uploadedFile->getClientFilename()] = $uploadedFile;\n                }\n            }\n        }\n\n        $this->update([], $files);\n    }\n\n    /**\n     * @param string $name\n     * @return Blueprint\n     */\n    protected function doGetBlueprint(string $name = ''): Blueprint\n    {\n        $blueprint = $this->getFlexDirectory()->getBlueprint($name ? '.' . $name : $name);\n\n        // HACK: With folder storage we need to ignore the avatar destination.\n        if ($this->getFlexDirectory()->getMediaFolder()) {\n            $field = $blueprint->get('form/fields/avatar');\n            if ($field) {\n                unset($field['destination']);\n                $blueprint->set('form/fields/avatar', $field);\n            }\n        }\n\n        return $blueprint;\n    }\n\n    /**\n     * @param UserInterface $user\n     * @param string $action\n     * @param string $scope\n     * @param bool $isMe\n     * @return bool|null\n     */\n    protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe = false): ?bool\n    {\n        // Check custom application access.\n        $isAuthorizedCallable = static::$isAuthorizedCallable;\n        if ($isAuthorizedCallable instanceof Closure) {\n            $callable = $isAuthorizedCallable->bindTo($this, $this);\n            $authorized = $callable($user, $action, $scope, $isMe);\n            if (is_bool($authorized)) {\n                return $authorized;\n            }\n        }\n\n        if ($user instanceof self && $user->getStorageKey() === $this->getStorageKey()) {\n            // User cannot delete his own account, otherwise he has full access.\n            return $action !== 'delete';\n        }\n\n        return parent::isAuthorizedOverride($user, $action, $scope, $isMe);\n    }\n\n    /**\n     * @return string|null\n     */\n    protected function getAvatarFile(): ?string\n    {\n        $avatars = $this->getElement('avatar');\n        if (is_array($avatars) && $avatars) {\n            $avatar = array_shift($avatars);\n\n            return $avatar['path'] ?? null;\n        }\n\n        return null;\n    }\n\n    /**\n     * Gets the associated media collection (original images).\n     *\n     * @return MediaCollectionInterface  Representation of associated media.\n     */\n    protected function getOriginalMedia()\n    {\n        $folder = $this->getMediaFolder();\n        if ($folder) {\n            $folder .= '/original';\n        }\n\n        return (new Media($folder ?? '', $this->getMediaOrder()))->setTimestamps();\n    }\n\n    /**\n     * @param array $files\n     * @return void\n     */\n    protected function setUpdatedMedia(array $files): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n\n        $media = $this->getMedia();\n        if (!$media instanceof MediaUploadInterface) {\n            return;\n        }\n\n        $filesystem = Filesystem::getInstance(false);\n\n        $list = [];\n        $list_original = [];\n        foreach ($files as $field => $group) {\n            // Ignore files without a field.\n            if ($field === '') {\n                continue;\n            }\n            $field = (string)$field;\n\n            // Load settings for the field.\n            $settings = $this->getMediaFieldSettings($field);\n            foreach ($group as $filename => $file) {\n                if ($file) {\n                    // File upload.\n                    $filename = $file->getClientFilename();\n\n                    /** @var FormFlashFile $file */\n                    $data = $file->jsonSerialize();\n                    unset($data['tmp_name'], $data['path']);\n                } else {\n                    // File delete.\n                    $data = null;\n                }\n\n                if ($file) {\n                    // Check file upload against media limits (except for max size).\n                    $filename = $media->checkUploadedFile($file, $filename, ['filesize' => 0] + $settings);\n                }\n\n                $self = $settings['self'];\n                if ($this->_loadMedia && $self) {\n                    $filepath = $filename;\n                } else {\n                    $filepath = \"{$settings['destination']}/{$filename}\";\n\n                    // For backwards compatibility we are always using relative path from the installation root.\n                    if ($locator->isStream($filepath)) {\n                        $filepath = $locator->findResource($filepath, false, true);\n                    }\n                }\n\n                // Special handling for original images.\n                if (strpos($field, '/original')) {\n                    if ($this->_loadMedia && $self) {\n                        $list_original[$filename] = [$file, $settings];\n                    }\n                    continue;\n                }\n\n                // Calculate path without the retina scaling factor.\n                $realpath = $filesystem->pathname($filepath) . str_replace(['@3x', '@2x'], '', Utils::basename($filepath));\n\n                $list[$filename] = [$file, $settings];\n\n                $path = str_replace('.', \"\\n\", $field);\n                if (null !== $data) {\n                    $data['name'] = $filename;\n                    $data['path'] = $filepath;\n\n                    $this->setNestedProperty(\"{$path}\\n{$realpath}\", $data, \"\\n\");\n                } else {\n                    $this->unsetNestedProperty(\"{$path}\\n{$realpath}\", \"\\n\");\n                }\n            }\n        }\n\n        $this->clearMediaCache();\n\n        $this->_uploads = $list;\n        $this->_uploads_original = $list_original;\n    }\n\n    protected function saveUpdatedMedia(): void\n    {\n        $media = $this->getMedia();\n        if (!$media instanceof MediaUploadInterface) {\n            throw new RuntimeException('Internal error UO101');\n        }\n\n        // Upload/delete original sized images.\n        /**\n         * @var string $filename\n         * @var UploadedFileInterface|array|null $file\n         */\n        foreach ($this->_uploads_original ?? [] as $filename => $file) {\n            $filename = 'original/' . $filename;\n            if (is_array($file)) {\n                [$file, $settings] = $file;\n            } else {\n                $settings = null;\n            }\n            if ($file instanceof UploadedFileInterface) {\n                $media->copyUploadedFile($file, $filename, $settings);\n            } else {\n                $media->deleteFile($filename, $settings);\n            }\n        }\n\n        // Upload/delete altered files.\n        /**\n         * @var string $filename\n         * @var UploadedFileInterface|array|null $file\n         */\n        foreach ($this->getUpdatedMedia() as $filename => $file) {\n            if (is_array($file)) {\n                [$file, $settings] = $file;\n            } else {\n                $settings = null;\n            }\n            if ($file instanceof UploadedFileInterface) {\n                $media->copyUploadedFile($file, $filename, $settings);\n            } else {\n                $media->deleteFile($filename, $settings);\n            }\n        }\n\n        $this->setUpdatedMedia([]);\n        $this->clearMediaCache();\n    }\n\n    /**\n     * @return array\n     */\n    protected function doSerialize(): array\n    {\n        return [\n            'type' => $this->getFlexType(),\n            'key' => $this->getKey(),\n            'elements' => $this->jsonSerialize(),\n            'storage' => $this->getMetaData()\n        ];\n    }\n\n    /**\n     * @return UserGroupIndex\n     */\n    protected function getUserGroups()\n    {\n        $grav = Grav::instance();\n\n        /** @var Flex $flex */\n        $flex = $grav['flex'];\n\n        /** @var UserGroupCollection|null $groups */\n        $groups = $flex->getDirectory('user-groups');\n        if ($groups) {\n            /** @var UserGroupIndex $index */\n            $index = $groups->getIndex();\n\n            return $index;\n        }\n\n        return $grav['user_groups'];\n    }\n\n    /**\n     * @return UserGroupIndex\n     */\n    protected function getGroups()\n    {\n        if (null === $this->_groups) {\n            /** @var UserGroupIndex $groups */\n            $groups = $this->getUserGroups()->select((array)$this->getProperty('groups'));\n            $this->_groups = $groups;\n        }\n\n        return $this->_groups;\n    }\n\n    /**\n     * @return Access\n     */\n    protected function getAccess(): Access\n    {\n        if (null === $this->_access) {\n            $this->_access = new Access($this->getProperty('access'));\n        }\n\n        return $this->_access;\n    }\n\n    /**\n     * @param mixed $value\n     * @return array\n     */\n    protected function offsetLoad_access($value): array\n    {\n        if (!$value instanceof Access) {\n            $value = new Access($value);\n        }\n\n        return $value->jsonSerialize();\n    }\n\n    /**\n     * @param mixed $value\n     * @return array\n     */\n    protected function offsetPrepare_access($value): array\n    {\n        return $this->offsetLoad_access($value);\n    }\n\n    /**\n     * @param array|null $value\n     * @return array|null\n     */\n    protected function offsetSerialize_access(?array $value): ?array\n    {\n        return $value;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Form/FormFlash.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Form\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Form;\n\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Form\\FormFlash as FrameworkFormFlash;\nuse function is_array;\n\n/**\n * Class FormFlash\n * @package Grav\\Common\\Form\n */\nclass FormFlash extends FrameworkFormFlash\n{\n    /**\n     * @return array\n     * @deprecated 1.6 For backwards compatibility only, do not use\n     */\n    public function getLegacyFiles(): array\n    {\n        $fields = [];\n        foreach ($this->files as $field => $files) {\n            if (strpos($field, '/')) {\n                continue;\n            }\n            foreach ($files as $file) {\n                if (is_array($file)) {\n                    $file['tmp_name'] = $this->getTmpDir() . '/' . $file['tmp_name'];\n                    $fields[$field][$file['path'] ?? $file['name']] = $file;\n                }\n            }\n        }\n\n        return $fields;\n    }\n\n    /**\n     * @param string $field\n     * @param string $filename\n     * @param array $upload\n     * @return bool\n     * @deprecated 1.6 For backwards compatibility only, do not use\n     */\n    public function uploadFile(string $field, string $filename, array $upload): bool\n    {\n        if (!$this->uniqueId) {\n            return false;\n        }\n\n        $tmp_dir = $this->getTmpDir();\n        Folder::create($tmp_dir);\n\n        $tmp_file = $upload['file']['tmp_name'];\n        $basename = Utils::basename($tmp_file);\n\n        if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) {\n            return false;\n        }\n\n        $upload['file']['tmp_name'] = $basename;\n        $upload['file']['name'] = $filename;\n\n        $this->addFileInternal($field, $filename, $upload['file']);\n\n        return true;\n    }\n\n    /**\n     * @param string $field\n     * @param string $filename\n     * @param array $upload\n     * @param array $crop\n     * @return bool\n     * @deprecated 1.6 For backwards compatibility only, do not use\n     */\n    public function cropFile(string $field, string $filename, array $upload, array $crop): bool\n    {\n        if (!$this->uniqueId) {\n            return false;\n        }\n\n        $tmp_dir = $this->getTmpDir();\n        Folder::create($tmp_dir);\n\n        $tmp_file = $upload['file']['tmp_name'];\n        $basename = Utils::basename($tmp_file);\n\n        if (!move_uploaded_file($tmp_file, $tmp_dir . '/' . $basename)) {\n            return false;\n        }\n\n        $upload['file']['tmp_name'] = $basename;\n        $upload['file']['name'] = $filename;\n\n        $this->addFileInternal($field, $filename, $upload['file'], $crop);\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/AbstractCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM;\n\nuse Grav\\Common\\Iterator;\n\n/**\n * Class AbstractCollection\n * @package Grav\\Common\\GPM\n */\nabstract class AbstractCollection extends Iterator\n{\n    /**\n     * @return string\n     */\n    public function toJson()\n    {\n        return json_encode($this->toArray(), JSON_THROW_ON_ERROR);\n    }\n\n    /**\n     * @return array\n     */\n    public function toArray()\n    {\n        $items = [];\n\n        foreach ($this->items as $name => $package) {\n            $items[$name] = $package->toArray();\n        }\n\n        return $items;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Common/AbstractPackageCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Common;\n\nuse Grav\\Common\\Iterator;\n\n/**\n * Class AbstractPackageCollection\n * @package Grav\\Common\\GPM\\Common\n */\nabstract class AbstractPackageCollection extends Iterator\n{\n    /** @var string */\n    protected $type;\n\n    /**\n     * @return string\n     */\n    public function toJson()\n    {\n        $items = [];\n\n        foreach ($this->items as $name => $package) {\n            $items[$name] = $package->toArray();\n        }\n\n        return json_encode($items, JSON_THROW_ON_ERROR);\n    }\n\n    /**\n     * @return array\n     */\n    public function toArray()\n    {\n        $items = [];\n\n        foreach ($this->items as $name => $package) {\n            $items[$name] = $package->toArray();\n        }\n\n        return $items;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Common/CachedCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Common;\n\nuse Grav\\Common\\Iterator;\n\n/**\n * Class CachedCollection\n * @package Grav\\Common\\GPM\\Common\n */\nclass CachedCollection extends Iterator\n{\n    /** @var array */\n    protected static $cache = [];\n\n    /**\n     * CachedCollection constructor.\n     *\n     * @param array $items\n     */\n    public function __construct($items)\n    {\n        parent::__construct();\n\n        $method = static::class . __METHOD__;\n\n        // local cache to speed things up\n        if (!isset(self::$cache[$method])) {\n            self::$cache[$method] = $items;\n        }\n\n        foreach (self::$cache[$method] as $name => $item) {\n            $this->append([$name => $item]);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Common/Package.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Common;\n\nuse Grav\\Common\\Data\\Data;\n\n/**\n * @property string $name\n */\nclass Package\n{\n    /** @var Data */\n    protected $data;\n\n    /**\n     * Package constructor.\n     * @param Data $package\n     * @param string|null $type\n     */\n    public function __construct(Data $package, $type = null)\n    {\n        $this->data = $package;\n\n        if ($type) {\n            $this->data->set('package_type', $type);\n        }\n    }\n\n    /**\n     * @return Data\n     */\n    public function getData()\n    {\n        return $this->data;\n    }\n\n    /**\n     * @param string $key\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function __get($key)\n    {\n        return $this->data->get($key);\n    }\n\n    /**\n     * @param string $key\n     * @param mixed $value\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __set($key, $value)\n    {\n        $this->data->set($key, $value);\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     */\n    #[\\ReturnTypeWillChange]\n    public function __isset($key)\n    {\n        return isset($this->data->{$key});\n    }\n\n    /**\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return $this->toJson();\n    }\n\n    /**\n     * @return string\n     */\n    public function toJson()\n    {\n        return $this->data->toJson();\n    }\n\n    /**\n     * @return array\n     */\n    public function toArray()\n    {\n        return $this->data->toArray();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/GPM.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM;\n\nuse Exception;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\HTTP\\Response;\nuse Grav\\Common\\Inflector;\nuse Grav\\Common\\Iterator;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse RuntimeException;\nuse stdClass;\nuse function array_key_exists;\nuse function count;\nuse function in_array;\nuse function is_array;\nuse function is_object;\n\n/**\n * Class GPM\n * @package Grav\\Common\\GPM\n */\nclass GPM extends Iterator\n{\n    /** @var Local\\Packages Local installed Packages */\n    private $installed;\n    /** @var Remote\\Packages|null Remote available Packages */\n    private $repository;\n    /** @var Remote\\GravCore|null Remove Grav Packages */\n    private $grav;\n    /** @var bool */\n    private $refresh;\n    /** @var callable|null */\n    private $callback;\n\n    /** @var array Internal cache */\n    protected $cache;\n    /** @var array */\n    protected $install_paths = [\n        'plugins' => 'user/plugins/%name%',\n        'themes' => 'user/themes/%name%',\n        'skeletons' => 'user/'\n    ];\n\n    /**\n     * Creates a new GPM instance with Local and Remote packages available\n     *\n     * @param bool $refresh Applies to Remote Packages only and forces a refetch of data\n     * @param callable|null $callback Either a function or callback in array notation\n     */\n    public function __construct($refresh = false, $callback = null)\n    {\n        parent::__construct();\n\n        Folder::create(CACHE_DIR . '/gpm');\n\n        $this->cache = [];\n        $this->installed = new Local\\Packages();\n        $this->refresh = $refresh;\n        $this->callback = $callback;\n    }\n\n    /**\n     * Magic getter method\n     *\n     * @param string $offset Asset name value\n     * @return mixed Asset value\n     */\n    #[\\ReturnTypeWillChange]\n    public function __get($offset)\n    {\n        switch ($offset) {\n            case 'grav':\n                return $this->getGrav();\n        }\n\n        return parent::__get($offset);\n    }\n\n    /**\n     * Magic method to determine if the attribute is set\n     *\n     * @param string $offset Asset name value\n     * @return bool True if the value is set\n     */\n    #[\\ReturnTypeWillChange]\n    public function __isset($offset)\n    {\n        switch ($offset) {\n            case 'grav':\n                return $this->getGrav() !== null;\n        }\n\n        return parent::__isset($offset);\n    }\n\n    /**\n     * Return the locally installed packages\n     *\n     * @return Local\\Packages\n     */\n    public function getInstalled()\n    {\n        return $this->installed;\n    }\n\n    /**\n     * Returns the Locally installable packages\n     *\n     * @param array $list_type_installed\n     * @return array The installed packages\n     */\n    public function getInstallable($list_type_installed = ['plugins' => true, 'themes' => true])\n    {\n        $items = ['total' => 0];\n        foreach ($list_type_installed as $type => $type_installed) {\n            if ($type_installed === false) {\n                continue;\n            }\n            $methodInstallableType = 'getInstalled' . ucfirst($type);\n            $to_install = $this->$methodInstallableType();\n            $items[$type] = $to_install;\n            $items['total'] += count($to_install);\n        }\n\n        return $items;\n    }\n\n    /**\n     * Returns the amount of locally installed packages\n     *\n     * @return int Amount of installed packages\n     */\n    public function countInstalled()\n    {\n        $installed = $this->getInstalled();\n\n        return count($installed['plugins']) + count($installed['themes']);\n    }\n\n    /**\n     * Return the instance of a specific Package\n     *\n     * @param  string $slug The slug of the Package\n     * @return Local\\Package|null The instance of the Package\n     */\n    public function getInstalledPackage($slug)\n    {\n        return $this->getInstalledPlugin($slug) ?? $this->getInstalledTheme($slug);\n    }\n\n    /**\n     * Return the instance of a specific Plugin\n     *\n     * @param  string $slug The slug of the Plugin\n     * @return Local\\Package|null The instance of the Plugin\n     */\n    public function getInstalledPlugin($slug)\n    {\n        return $this->installed['plugins'][$slug] ?? null;\n    }\n\n    /**\n     * Returns the Locally installed plugins\n     * @return Iterator The installed plugins\n     */\n    public function getInstalledPlugins()\n    {\n        return $this->installed['plugins'];\n    }\n\n\n    /**\n     * Returns the plugin's enabled state\n     *\n     * @param  string $slug\n     * @return bool True if the Plugin is Enabled. False if manually set to enable:false. Null otherwise.\n     */\n    public function isPluginEnabled($slug): bool\n    {\n        $grav = Grav::instance();\n\n        return ($grav['config']['plugins'][$slug]['enabled'] ?? false) === true;\n    }\n\n    /**\n     * Checks if a Plugin is installed\n     *\n     * @param  string $slug The slug of the Plugin\n     * @return bool True if the Plugin has been installed. False otherwise\n     */\n    public function isPluginInstalled($slug): bool\n    {\n        return isset($this->installed['plugins'][$slug]);\n    }\n\n    /**\n     * @param string $slug\n     * @return bool\n     */\n    public function isPluginInstalledAsSymlink($slug)\n    {\n        $plugin = $this->getInstalledPlugin($slug);\n\n        return (bool)($plugin->symlink ?? false);\n    }\n\n    /**\n     * Return the instance of a specific Theme\n     *\n     * @param  string $slug The slug of the Theme\n     * @return Local\\Package|null The instance of the Theme\n     */\n    public function getInstalledTheme($slug)\n    {\n        return $this->installed['themes'][$slug] ?? null;\n    }\n\n    /**\n     * Returns the Locally installed themes\n     *\n     * @return Iterator The installed themes\n     */\n    public function getInstalledThemes()\n    {\n        return $this->installed['themes'];\n    }\n\n    /**\n     * Checks if a Theme is enabled\n     *\n     * @param  string $slug The slug of the Theme\n     * @return bool True if the Theme has been set to the default theme. False if installed, but not enabled. Null otherwise.\n     */\n    public function isThemeEnabled($slug): bool\n    {\n        $grav = Grav::instance();\n\n        $current_theme = $grav['config']['system']['pages']['theme'] ?? null;\n\n        return $current_theme === $slug;\n    }\n\n    /**\n     * Checks if a Theme is installed\n     *\n     * @param  string $slug The slug of the Theme\n     * @return bool True if the Theme has been installed. False otherwise\n     */\n    public function isThemeInstalled($slug): bool\n    {\n        return isset($this->installed['themes'][$slug]);\n    }\n\n    /**\n     * Returns the amount of updates available\n     *\n     * @return int Amount of available updates\n     */\n    public function countUpdates()\n    {\n        return count($this->getUpdatablePlugins()) + count($this->getUpdatableThemes());\n    }\n\n    /**\n     * Returns an array of Plugins and Themes that can be updated.\n     * Plugins and Themes are extended with the `available` property that relies to the remote version\n     *\n     * @param array $list_type_update specifies what type of package to update\n     * @return array Array of updatable Plugins and Themes.\n     *               Format: ['total' => int, 'plugins' => array, 'themes' => array]\n     */\n    public function getUpdatable($list_type_update = ['plugins' => true, 'themes' => true])\n    {\n        $items = ['total' => 0];\n        foreach ($list_type_update as $type => $type_updatable) {\n            if ($type_updatable === false) {\n                continue;\n            }\n            $methodUpdatableType = 'getUpdatable' . ucfirst($type);\n            $to_update = $this->$methodUpdatableType();\n            $items[$type] = $to_update;\n            $items['total'] += count($to_update);\n        }\n\n        return $items;\n    }\n\n    /**\n     * Returns an array of Plugins that can be updated.\n     * The Plugins are extended with the `available` property that relies to the remote version\n     *\n     * @return array Array of updatable Plugins\n     */\n    public function getUpdatablePlugins()\n    {\n        $items = [];\n\n        $repository = $this->getRepository();\n        if (null === $repository) {\n            return $items;\n        }\n\n        $plugins = $repository['plugins'];\n\n        // local cache to speed things up\n        if (isset($this->cache[__METHOD__])) {\n            return $this->cache[__METHOD__];\n        }\n\n        foreach ($this->installed['plugins'] as $slug => $plugin) {\n            if (!isset($plugins[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {\n                continue;\n            }\n\n            $local_version = $plugin->version ?? 'Unknown';\n            $remote_version = $plugins[$slug]->version;\n\n            if (version_compare($local_version, $remote_version) < 0) {\n                $plugins[$slug]->available = $remote_version;\n                $plugins[$slug]->version = $local_version;\n                $plugins[$slug]->type = $plugins[$slug]->release_type;\n                $items[$slug] = $plugins[$slug];\n            }\n        }\n\n        $this->cache[__METHOD__] = $items;\n\n        return $items;\n    }\n\n    /**\n     * Get the latest release of a package from the GPM\n     *\n     * @param string $package_name\n     * @return string|null\n     */\n    public function getLatestVersionOfPackage($package_name)\n    {\n        $repository = $this->getRepository();\n        if (null === $repository) {\n            return null;\n        }\n\n        $plugins = $repository['plugins'];\n        if (isset($plugins[$package_name])) {\n            return $plugins[$package_name]->available ?: $plugins[$package_name]->version;\n        }\n\n        //Not a plugin, it's a theme?\n        $themes = $repository['themes'];\n        if (isset($themes[$package_name])) {\n            return $themes[$package_name]->available ?: $themes[$package_name]->version;\n        }\n\n        return null;\n    }\n\n    /**\n     * Check if a Plugin or Theme is updatable\n     *\n     * @param  string $slug The slug of the package\n     * @return bool True if updatable. False otherwise or if not found\n     */\n    public function isUpdatable($slug)\n    {\n        return $this->isPluginUpdatable($slug) || $this->isThemeUpdatable($slug);\n    }\n\n    /**\n     * Checks if a Plugin is updatable\n     *\n     * @param  string $plugin The slug of the Plugin\n     * @return bool True if the Plugin is updatable. False otherwise\n     */\n    public function isPluginUpdatable($plugin)\n    {\n        return array_key_exists($plugin, (array)$this->getUpdatablePlugins());\n    }\n\n    /**\n     * Returns an array of Themes that can be updated.\n     * The Themes are extended with the `available` property that relies to the remote version\n     *\n     * @return array Array of updatable Themes\n     */\n    public function getUpdatableThemes()\n    {\n        $items = [];\n\n        $repository = $this->getRepository();\n        if (null === $repository) {\n            return $items;\n        }\n\n        $themes = $repository['themes'];\n\n        // local cache to speed things up\n        if (isset($this->cache[__METHOD__])) {\n            return $this->cache[__METHOD__];\n        }\n\n        foreach ($this->installed['themes'] as $slug => $plugin) {\n            if (!isset($themes[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {\n                continue;\n            }\n\n            $local_version = $plugin->version ?? 'Unknown';\n            $remote_version = $themes[$slug]->version;\n\n            if (version_compare($local_version, $remote_version) < 0) {\n                $themes[$slug]->available = $remote_version;\n                $themes[$slug]->version = $local_version;\n                $themes[$slug]->type = $themes[$slug]->release_type;\n                $items[$slug] = $themes[$slug];\n            }\n        }\n\n        $this->cache[__METHOD__] = $items;\n\n        return $items;\n    }\n\n    /**\n     * Checks if a Theme is Updatable\n     *\n     * @param  string $theme The slug of the Theme\n     * @return bool True if the Theme is updatable. False otherwise\n     */\n    public function isThemeUpdatable($theme)\n    {\n        return array_key_exists($theme, (array)$this->getUpdatableThemes());\n    }\n\n    /**\n     * Get the release type of a package (stable / testing)\n     *\n     * @param string $package_name\n     * @return string|null\n     */\n    public function getReleaseType($package_name)\n    {\n        $repository = $this->getRepository();\n        if (null === $repository) {\n            return null;\n        }\n\n        $plugins = $repository['plugins'];\n        if (isset($plugins[$package_name])) {\n            return $plugins[$package_name]->release_type;\n        }\n\n        //Not a plugin, it's a theme?\n        $themes = $repository['themes'];\n        if (isset($themes[$package_name])) {\n            return $themes[$package_name]->release_type;\n        }\n\n        return null;\n    }\n\n    /**\n     * Returns true if the package latest release is stable\n     *\n     * @param string $package_name\n     * @return bool\n     */\n    public function isStableRelease($package_name)\n    {\n        return $this->getReleaseType($package_name) === 'stable';\n    }\n\n    /**\n     * Returns true if the package latest release is testing\n     *\n     * @param string $package_name\n     * @return bool\n     */\n    public function isTestingRelease($package_name)\n    {\n        $package = $this->getInstalledPackage($package_name);\n        $testing = $package->testing ?? false;\n\n        return $this->getReleaseType($package_name) === 'testing' || $testing;\n    }\n\n    /**\n     * Returns a Plugin from the repository\n     *\n     * @param  string $slug The slug of the Plugin\n     * @return Remote\\Package|null  Package if found, NULL if not\n     */\n    public function getRepositoryPlugin($slug)\n    {\n        $packages = $this->getRepositoryPlugins();\n\n        return $packages ? ($packages[$slug] ?? null) : null;\n    }\n\n    /**\n     * Returns the list of Plugins available in the repository\n     *\n     * @return Iterator|null The Plugins remotely available\n     */\n    public function getRepositoryPlugins()\n    {\n        return $this->getRepository()['plugins'] ?? null;\n    }\n\n    /**\n     * Returns a Theme from the repository\n     *\n     * @param  string $slug The slug of the Theme\n     * @return Remote\\Package|null  Package if found, NULL if not\n     */\n    public function getRepositoryTheme($slug)\n    {\n        $packages = $this->getRepositoryThemes();\n\n        return $packages ? ($packages[$slug] ?? null) : null;\n    }\n\n    /**\n     * Returns the list of Themes available in the repository\n     *\n     * @return Iterator|null The Themes remotely available\n     */\n    public function getRepositoryThemes()\n    {\n        return $this->getRepository()['themes'] ?? null;\n    }\n\n    /**\n     * Returns the list of Plugins and Themes available in the repository\n     *\n     * @return Remote\\Packages|null Available Plugins and Themes\n     *               Format: ['plugins' => array, 'themes' => array]\n     */\n    public function getRepository()\n    {\n        if (null === $this->repository) {\n            try {\n                $this->repository = new Remote\\Packages($this->refresh, $this->callback);\n            } catch (Exception $e) {}\n        }\n\n        return $this->repository;\n    }\n\n    /**\n     * Returns Grav version available in the repository\n     *\n     * @return Remote\\GravCore|null\n     */\n    public function getGrav()\n    {\n        if (null === $this->grav) {\n            try {\n                $this->grav = new Remote\\GravCore($this->refresh, $this->callback);\n            } catch (Exception $e) {}\n        }\n\n        return $this->grav;\n    }\n\n    /**\n     * Searches for a Package in the repository\n     *\n     * @param  string $search Can be either the slug or the name\n     * @param  bool $ignore_exception True if should not fire an exception (for use in Twig)\n     * @return Remote\\Package|false Package if found, FALSE if not\n     */\n    public function findPackage($search, $ignore_exception = false)\n    {\n        $search = strtolower($search);\n\n        $found = $this->getRepositoryPlugin($search) ?? $this->getRepositoryTheme($search);\n        if ($found) {\n            return $found;\n        }\n\n        $themes = $this->getRepositoryThemes();\n        $plugins = $this->getRepositoryPlugins();\n\n        if (null === $themes || null === $plugins) {\n            if (!is_writable(GRAV_ROOT . '/cache/gpm')) {\n                throw new RuntimeException('The cache/gpm folder is not writable. Please check the folder permissions.');\n            }\n\n            if ($ignore_exception) {\n                return false;\n            }\n\n            throw new RuntimeException('GPM not reachable. Please check your internet connection or check the Grav site is reachable');\n        }\n\n        foreach ($themes as $slug => $theme) {\n            if ($search === $slug || $search === $theme->name) {\n                return $theme;\n            }\n        }\n\n        foreach ($plugins as $slug => $plugin) {\n            if ($search === $slug || $search === $plugin->name) {\n                return $plugin;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Download the zip package via the URL\n     *\n     * @param string $package_file\n     * @param string $tmp\n     * @return string|null\n     */\n    public static function downloadPackage($package_file, $tmp)\n    {\n        $package = parse_url($package_file);\n        if (!is_array($package)) {\n            throw new \\RuntimeException(\"Malformed GPM URL: {$package_file}\");\n        }\n\n        $filename = Utils::basename($package['path'] ?? '');\n\n        if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && ($package['host'] ?? null) !== 'getgrav.org') {\n            throw new RuntimeException('Only official GPM URLs are allowed. You can modify this behavior in the System configuration.');\n        }\n\n        $output = Response::get($package_file, []);\n\n        if ($output) {\n            Folder::create($tmp);\n            file_put_contents($tmp . DS . $filename, $output);\n            return $tmp . DS . $filename;\n        }\n\n        return null;\n    }\n\n    /**\n     * Copy the local zip package to tmp\n     *\n     * @param string $package_file\n     * @param string $tmp\n     * @return string|null\n     */\n    public static function copyPackage($package_file, $tmp)\n    {\n        $package_file = realpath($package_file);\n\n        if ($package_file && file_exists($package_file)) {\n            $filename = Utils::basename($package_file);\n            Folder::create($tmp);\n            copy($package_file, $tmp . DS . $filename);\n            return $tmp . DS . $filename;\n        }\n\n        return null;\n    }\n\n    /**\n     * Try to guess the package type from the source files\n     *\n     * @param string $source\n     * @return string|false\n     */\n    public static function getPackageType($source)\n    {\n        $plugin_regex = '/^class\\\\s{1,}[a-zA-Z0-9]{1,}\\\\s{1,}extends.+Plugin/m';\n        $theme_regex = '/^class\\\\s{1,}[a-zA-Z0-9]{1,}\\\\s{1,}extends.+Theme/m';\n\n        if (file_exists($source . 'system/defines.php') &&\n            file_exists($source . 'system/config/system.yaml')\n        ) {\n            return 'grav';\n        }\n\n        // must have a blueprint\n        if (!file_exists($source . 'blueprints.yaml')) {\n            return false;\n        }\n\n        // either theme or plugin\n        $name = Utils::basename($source);\n        if (Utils::contains($name, 'theme')) {\n            return 'theme';\n        }\n        if (Utils::contains($name, 'plugin')) {\n            return 'plugin';\n        }\n\n        $glob = glob($source . '*.php') ?: [];\n        foreach ($glob as $filename) {\n            $contents = file_get_contents($filename);\n            if (!$contents) {\n                continue;\n            }\n            if (preg_match($theme_regex, $contents)) {\n                return 'theme';\n            }\n            if (preg_match($plugin_regex, $contents)) {\n                return 'plugin';\n            }\n        }\n\n        // Assume it's a theme\n        return 'theme';\n    }\n\n    /**\n     * Try to guess the package name from the source files\n     *\n     * @param string $source\n     * @return string|false\n     */\n    public static function getPackageName($source)\n    {\n        $ignore_yaml_files = ['blueprints', 'languages'];\n\n        $glob = glob($source . '*.yaml') ?: [];\n        foreach ($glob as $filename) {\n            $name = strtolower(Utils::basename($filename, '.yaml'));\n            if (in_array($name, $ignore_yaml_files)) {\n                continue;\n            }\n\n            return $name;\n        }\n\n        return false;\n    }\n\n    /**\n     * Find/Parse the blueprint file\n     *\n     * @param string $source\n     * @return array|false\n     */\n    public static function getBlueprints($source)\n    {\n        $blueprint_file = $source . 'blueprints.yaml';\n        if (!file_exists($blueprint_file)) {\n            return false;\n        }\n\n        $file = YamlFile::instance($blueprint_file);\n        $blueprint = (array)$file->content();\n        $file->free();\n\n        return $blueprint;\n    }\n\n    /**\n     * Get the install path for a name and a particular type of package\n     *\n     * @param string $type\n     * @param string $name\n     * @return string\n     */\n    public static function getInstallPath($type, $name)\n    {\n        $locator = Grav::instance()['locator'];\n\n        if ($type === 'theme') {\n            $install_path = $locator->findResource('themes://', false) . DS . $name;\n        } else {\n            $install_path = $locator->findResource('plugins://', false) . DS . $name;\n        }\n\n        return $install_path;\n    }\n\n    /**\n     * Searches for a list of Packages in the repository\n     *\n     * @param  array $searches An array of either slugs or names\n     * @return array Array of found Packages\n     *                        Format: ['total' => int, 'not_found' => array, <found-slugs>]\n     */\n    public function findPackages($searches = [])\n    {\n        $packages = ['total' => 0, 'not_found' => []];\n        $inflector = new Inflector();\n\n        foreach ($searches as $search) {\n            $repository = '';\n            // if this is an object, get the search data from the key\n            if (is_object($search)) {\n                $search = (array)$search;\n                $key = key($search);\n                $repository = $search[$key];\n                $search = $key;\n            }\n\n            $found = $this->findPackage($search);\n            if ($found) {\n                // set override repository if provided\n                if ($repository) {\n                    $found->override_repository = $repository;\n                }\n                if (!isset($packages[$found->package_type])) {\n                    $packages[$found->package_type] = [];\n                }\n\n                $packages[$found->package_type][$found->slug] = $found;\n                $packages['total']++;\n            } else {\n                // make a best guess at the type based on the repo URL\n                if (Utils::contains($repository, '-theme')) {\n                    $type = 'themes';\n                } else {\n                    $type = 'plugins';\n                }\n\n                $not_found = new stdClass();\n                $not_found->name = $inflector::camelize($search);\n                $not_found->slug = $search;\n                $not_found->package_type = $type;\n                $not_found->install_path = str_replace('%name%', $search, $this->install_paths[$type]);\n                $not_found->override_repository = $repository;\n                $packages['not_found'][$search] = $not_found;\n            }\n        }\n\n        return $packages;\n    }\n\n    /**\n     * Return the list of packages that have the passed one as dependency\n     *\n     * @param string $slug The slug name of the package\n     * @return array\n     */\n    public function getPackagesThatDependOnPackage($slug)\n    {\n        $plugins = $this->getInstalledPlugins();\n        $themes = $this->getInstalledThemes();\n        $packages = array_merge($plugins->toArray(), $themes->toArray());\n\n        $list = [];\n        foreach ($packages as $package_name => $package) {\n            $dependencies = $package['dependencies'] ?? [];\n            foreach ($dependencies as $dependency) {\n                if (is_array($dependency) && isset($dependency['name'])) {\n                    $dependency = $dependency['name'];\n                }\n\n                if ($dependency === $slug) {\n                    $list[] = $package_name;\n                }\n            }\n        }\n\n        return $list;\n    }\n\n\n    /**\n     * Get the required version of a dependency of a package\n     *\n     * @param string $package_slug\n     * @param string $dependency_slug\n     * @return mixed|null\n     */\n    public function getVersionOfDependencyRequiredByPackage($package_slug, $dependency_slug)\n    {\n        $dependencies = $this->getInstalledPackage($package_slug)->dependencies ?? [];\n        foreach ($dependencies as $dependency) {\n            if (isset($dependency[$dependency_slug])) {\n                return $dependency[$dependency_slug];\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * Check the package identified by $slug can be updated to the version passed as argument.\n     * Thrown an exception if it cannot be updated because another package installed requires it to be at an older version.\n     *\n     * @param string $slug\n     * @param string $version_with_operator\n     * @param array $ignore_packages_list\n     * @return bool\n     * @throws RuntimeException\n     */\n    public function checkNoOtherPackageNeedsThisDependencyInALowerVersion($slug, $version_with_operator, $ignore_packages_list)\n    {\n        // check if any of the currently installed package need this in a lower version than the one we need. In case, abort and tell which package\n        $dependent_packages = $this->getPackagesThatDependOnPackage($slug);\n        $version = $this->calculateVersionNumberFromDependencyVersion($version_with_operator);\n\n        if (count($dependent_packages)) {\n            foreach ($dependent_packages as $dependent_package) {\n                $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package, $slug);\n                $other_dependency_version = $this->calculateVersionNumberFromDependencyVersion($other_dependency_version_with_operator);\n\n                // check version is compatible with the one needed by the current package\n                if ($this->versionFormatIsNextSignificantRelease($other_dependency_version_with_operator)) {\n                    $compatible = $this->checkNextSignificantReleasesAreCompatible($version, $other_dependency_version);\n                    if (!$compatible && !in_array($dependent_package, $ignore_packages_list, true)) {\n                        throw new RuntimeException(\n                            \"Package <cyan>$slug</cyan> is required in an older version by package <cyan>$dependent_package</cyan>. This package needs a newer version, and because of this it cannot be installed. The <cyan>$dependent_package</cyan> package must be updated to use a newer release of <cyan>$slug</cyan>.\",\n                            2\n                        );\n                    }\n                }\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * Check the passed packages list can be updated\n     *\n     * @param array $packages_names_list\n     * @return void\n     * @throws Exception\n     */\n    public function checkPackagesCanBeInstalled($packages_names_list)\n    {\n        foreach ($packages_names_list as $package_name) {\n            $latest = $this->getLatestVersionOfPackage($package_name);\n            $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name, $latest, $packages_names_list);\n        }\n    }\n\n    /**\n     * Fetch the dependencies, check the installed packages and return an array with\n     * the list of packages with associated an information on what to do: install, update or ignore.\n     *\n     * `ignore` means the package is already installed and can be safely left as-is.\n     * `install` means the package is not installed and must be installed.\n     * `update` means the package is already installed and must be updated as a dependency needs a higher version.\n     *\n     * @param array $packages\n     * @return array\n     * @throws RuntimeException\n     */\n    public function getDependencies($packages)\n    {\n        $dependencies = $this->calculateMergedDependenciesOfPackages($packages);\n        foreach ($dependencies as $dependency_slug => $dependencyVersionWithOperator) {\n            $dependency_slug = (string)$dependency_slug;\n            if (in_array($dependency_slug, $packages, true)) {\n                unset($dependencies[$dependency_slug]);\n                continue;\n            }\n\n            // Check PHP version\n            if ($dependency_slug === 'php') {\n                $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);\n                if (version_compare($testVersion, PHP_VERSION) === 1) {\n                    //Needs a Grav update first\n                    throw new RuntimeException(\"<red>One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this\");\n                }\n\n                unset($dependencies[$dependency_slug]);\n                continue;\n            }\n\n            //First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell.\n            if ($dependency_slug === 'grav') {\n                $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);\n                if (version_compare($testVersion, GRAV_VERSION) === 1) {\n                    //Needs a Grav update first\n                    throw new RuntimeException(\"<red>One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release.\");\n                }\n\n                unset($dependencies[$dependency_slug]);\n                continue;\n            }\n\n            if ($this->isPluginInstalled($dependency_slug)) {\n                if ($this->isPluginInstalledAsSymlink($dependency_slug)) {\n                    unset($dependencies[$dependency_slug]);\n                    continue;\n                }\n\n                $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);\n\n                // get currently installed version\n                $locator = Grav::instance()['locator'];\n                $blueprints_path = $locator->findResource('plugins://' . $dependency_slug . DS . 'blueprints.yaml');\n                $file = YamlFile::instance($blueprints_path);\n                $package_yaml = $file->content();\n                $file->free();\n                $currentlyInstalledVersion = $package_yaml['version'];\n\n                // if requirement is next significant release, check is compatible with currently installed version, might not be\n                if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)\n                    && $this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) {\n                    $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion, $currentlyInstalledVersion);\n\n                    if (!$compatible) {\n                        throw new RuntimeException(\n                            'Dependency <cyan>' . $dependency_slug . '</cyan> is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.',\n                            2\n                        );\n                    }\n                }\n\n                //if I already have the latest release, remove the dependency\n                $latestRelease = $this->getLatestVersionOfPackage($dependency_slug);\n\n                if ($this->firstVersionIsLower($latestRelease, $dependencyVersion)) {\n                    //throw an exception if a required version cannot be found in the GPM yet\n                    throw new RuntimeException(\n                        'Dependency <cyan>' . $package_yaml['name'] . '</cyan> is required in version <cyan>' . $dependencyVersion . '</cyan> which is higher than the latest release, <cyan>' . $latestRelease . '</cyan>. Try running `bin/gpm -f index` to force a refresh of the GPM cache',\n                        1\n                    );\n                }\n\n                if ($this->firstVersionIsLower($currentlyInstalledVersion, $dependencyVersion)) {\n                    $dependencies[$dependency_slug] = 'update';\n                } elseif ($currentlyInstalledVersion === $latestRelease) {\n                    unset($dependencies[$dependency_slug]);\n                } else {\n                    // an update is not strictly required mark as 'ignore'\n                    $dependencies[$dependency_slug] = 'ignore';\n                }\n            } else {\n                $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);\n\n                // if requirement is next significant release, check is compatible with latest available version, might not be\n                if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) {\n                    $latestVersionOfPackage = $this->getLatestVersionOfPackage($dependency_slug);\n                    if ($this->firstVersionIsLower($dependencyVersion, $latestVersionOfPackage)) {\n                        $compatible = $this->checkNextSignificantReleasesAreCompatible(\n                            $dependencyVersion,\n                            $latestVersionOfPackage\n                        );\n\n                        if (!$compatible) {\n                            throw new RuntimeException(\n                                'Dependency <cyan>' . $dependency_slug . '</cyan> is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.',\n                                2\n                            );\n                        }\n                    }\n                }\n\n                $dependencies[$dependency_slug] = 'install';\n            }\n        }\n\n        $dependencies_slugs = array_keys($dependencies);\n        $this->checkNoOtherPackageNeedsTheseDependenciesInALowerVersion(array_merge($packages, $dependencies_slugs));\n\n        return $dependencies;\n    }\n\n    /**\n     * @param array $dependencies_slugs\n     * @return void\n     */\n    public function checkNoOtherPackageNeedsTheseDependenciesInALowerVersion($dependencies_slugs)\n    {\n        foreach ($dependencies_slugs as $dependency_slug) {\n            $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion(\n                $dependency_slug,\n                $this->getLatestVersionOfPackage($dependency_slug),\n                $dependencies_slugs\n            );\n        }\n    }\n\n    /**\n     * @param string $firstVersion\n     * @param string $secondVersion\n     * @return bool\n     */\n    private function firstVersionIsLower($firstVersion, $secondVersion)\n    {\n        return version_compare($firstVersion, $secondVersion) === -1;\n    }\n\n    /**\n     * Calculates and merges the dependencies of a package\n     *\n     * @param string $packageName The package information\n     * @param array $dependencies The dependencies array\n     * @return array\n     */\n    private function calculateMergedDependenciesOfPackage($packageName, $dependencies)\n    {\n        $packageData = $this->findPackage($packageName);\n\n        if (empty($packageData->dependencies)) {\n            return $dependencies;\n        }\n\n        foreach ($packageData->dependencies as $dependency) {\n            $dependencyName = $dependency['name'] ?? null;\n            if (!$dependencyName) {\n                continue;\n            }\n\n            $dependencyVersion = $dependency['version'] ?? '*';\n\n            if (!isset($dependencies[$dependencyName])) {\n                // Dependency added for the first time\n                $dependencies[$dependencyName] = $dependencyVersion;\n\n                //Factor in the package dependencies too\n                $dependencies = $this->calculateMergedDependenciesOfPackage($dependencyName, $dependencies);\n            } elseif ($dependencyVersion !== '*') {\n                // Dependency already added by another package\n                // If this package requires a version higher than the currently stored one, store this requirement instead\n                $currentDependencyVersion = $dependencies[$dependencyName];\n                $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currentDependencyVersion);\n\n                $currently_stored_version_is_in_next_significant_release_format = false;\n                if ($this->versionFormatIsNextSignificantRelease($currentDependencyVersion)) {\n                    $currently_stored_version_is_in_next_significant_release_format = true;\n                }\n\n                if (!$currently_stored_version_number) {\n                    $currently_stored_version_number = '*';\n                }\n\n                $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($dependencyVersion);\n                if (!$current_package_version_number) {\n                    throw new RuntimeException(\"Bad format for version of dependency {$dependencyName} for package {$packageName}\", 1);\n                }\n\n                $current_package_version_is_in_next_significant_release_format = false;\n                if ($this->versionFormatIsNextSignificantRelease($dependencyVersion)) {\n                    $current_package_version_is_in_next_significant_release_format = true;\n                }\n\n                //If I had stored '*', change right away with the more specific version required\n                if ($currently_stored_version_number === '*') {\n                    $dependencies[$dependencyName] = $dependencyVersion;\n                } elseif (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) {\n                    //Comparing versions equals or higher, a simple version_compare is enough\n                    if (version_compare($currently_stored_version_number, $current_package_version_number) === -1) {\n                        //Current package version is higher\n                        $dependencies[$dependencyName] = $dependencyVersion;\n                    }\n                } else {\n                    $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number, $current_package_version_number);\n                    if (!$compatible) {\n                        throw new RuntimeException(\"Dependency {$dependencyName} is required in two incompatible versions\", 2);\n                    }\n                }\n            }\n        }\n\n        return $dependencies;\n    }\n\n    /**\n     * Calculates and merges the dependencies of the passed packages\n     *\n     * @param array $packages\n     * @return array\n     */\n    public function calculateMergedDependenciesOfPackages($packages)\n    {\n        $dependencies = [];\n\n        foreach ($packages as $package) {\n            $dependencies = $this->calculateMergedDependenciesOfPackage($package, $dependencies);\n        }\n\n        return $dependencies;\n    }\n\n    /**\n     * Returns the actual version from a dependency version string.\n     * Examples:\n     *      $versionInformation == '~2.0' => returns '2.0'\n     *      $versionInformation == '>=2.0.2' => returns '2.0.2'\n     *      $versionInformation == '2.0.2' => returns '2.0.2'\n     *      $versionInformation == '*' => returns null\n     *      $versionInformation == '' => returns null\n     *\n     * @param string $version\n     * @return string|null\n     */\n    public function calculateVersionNumberFromDependencyVersion($version)\n    {\n        if ($version === '*') {\n            return null;\n        }\n        if ($version === '') {\n            return null;\n        }\n        if ($this->versionFormatIsNextSignificantRelease($version)) {\n            return trim(substr($version, 1));\n        }\n        if ($this->versionFormatIsEqualOrHigher($version)) {\n            return trim(substr($version, 2));\n        }\n\n        return $version;\n    }\n\n    /**\n     * Check if the passed version information contains next significant release (tilde) operator\n     *\n     * Example: returns true for $version: '~2.0'\n     *\n     * @param string $version\n     * @return bool\n     */\n    public function versionFormatIsNextSignificantRelease($version): bool\n    {\n        return strpos($version, '~') === 0;\n    }\n\n    /**\n     * Check if the passed version information contains equal or higher operator\n     *\n     * Example: returns true for $version: '>=2.0'\n     *\n     * @param string $version\n     * @return bool\n     */\n    public function versionFormatIsEqualOrHigher($version): bool\n    {\n        return strpos($version, '>=') === 0;\n    }\n\n    /**\n     * Check if two releases are compatible by next significant release\n     *\n     * ~1.2 is equivalent to >=1.2 <2.0.0\n     * ~1.2.3 is equivalent to >=1.2.3 <1.3.0\n     *\n     * In short, allows the last digit specified to go up\n     *\n     * @param string $version1 the version string (e.g. '2.0.0' or '1.0')\n     * @param string $version2 the version string (e.g. '2.0.0' or '1.0')\n     * @return bool\n     */\n    public function checkNextSignificantReleasesAreCompatible($version1, $version2): bool\n    {\n        $version1array = explode('.', $version1);\n        $version2array = explode('.', $version2);\n\n        if (count($version1array) > count($version2array)) {\n            [$version1array, $version2array] = [$version2array, $version1array];\n        }\n\n        $i = 0;\n        while ($i < count($version1array) - 1) {\n            if ($version1array[$i] !== $version2array[$i]) {\n                return false;\n            }\n            $i++;\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Installer.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM;\n\nuse DirectoryIterator;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse RuntimeException;\nuse ZipArchive;\nuse function count;\nuse function in_array;\nuse function is_string;\n\n/**\n * Class Installer\n * @package Grav\\Common\\GPM\n */\nclass Installer\n{\n    /** @const No error */\n    public const OK = 0;\n    /** @const Target already exists */\n    public const EXISTS = 1;\n    /** @const Target is a symbolic link */\n    public const IS_LINK = 2;\n    /** @const Target doesn't exist */\n    public const NOT_FOUND = 4;\n    /** @const Target is not a directory */\n    public const NOT_DIRECTORY = 8;\n    /** @const Target is not a Grav instance */\n    public const NOT_GRAV_ROOT = 16;\n    /** @const Error while trying to open the ZIP package */\n    public const ZIP_OPEN_ERROR = 32;\n    /** @const Error while trying to extract the ZIP package */\n    public const ZIP_EXTRACT_ERROR = 64;\n    /** @const Invalid source file */\n    public const INVALID_SOURCE = 128;\n\n    /** @var string Destination folder on which validation checks are applied */\n    protected static $target;\n\n    /** @var int|string Error code or string */\n    protected static $error = 0;\n\n    /** @var int Zip Error Code */\n    protected static $error_zip = 0;\n\n    /** @var string Post install message */\n    protected static $message = '';\n\n    /** @var array Default options for the install */\n    protected static $options = [\n        'overwrite'       => true,\n        'ignore_symlinks' => true,\n        'sophisticated'   => false,\n        'theme'           => false,\n        'install_path'    => '',\n        'ignores'         => [],\n        'exclude_checks'  => [self::EXISTS, self::NOT_FOUND, self::IS_LINK]\n    ];\n\n    /**\n     * Installs a given package to a given destination.\n     *\n     * @param  string $zip the local path to ZIP package\n     * @param  string $destination The local path to the Grav Instance\n     * @param  array $options Options to use for installing. ie, ['install_path' => 'user/themes/antimatter']\n     * @param  string|null $extracted The local path to the extacted ZIP package\n     * @param  bool $keepExtracted True if you want to keep the original files\n     * @return bool True if everything went fine, False otherwise.\n     */\n    public static function install($zip, $destination, $options = [], $extracted = null, $keepExtracted = false)\n    {\n        $destination = rtrim($destination, DS);\n        $options = array_merge(self::$options, $options);\n        $install_path = rtrim($destination . DS . ltrim($options['install_path'], DS), DS);\n\n        if (!self::isGravInstance($destination) || !self::isValidDestination(\n            $install_path,\n            $options['exclude_checks']\n        )\n        ) {\n            return false;\n        }\n\n        if ((self::lastErrorCode() === self::IS_LINK && $options['ignore_symlinks']) ||\n            (self::lastErrorCode() === self::EXISTS && !$options['overwrite'])\n        ) {\n            return false;\n        }\n\n        // Create a tmp location\n        $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);\n        $tmp = $tmp_dir . '/Grav-' . uniqid('', false);\n\n        if (!$extracted) {\n            $extracted = self::unZip($zip, $tmp);\n            if (!$extracted) {\n                Folder::delete($tmp);\n                return false;\n            }\n        }\n\n        if (!file_exists($extracted)) {\n            self::$error = self::INVALID_SOURCE;\n            return false;\n        }\n\n        $is_install = true;\n        $installer = self::loadInstaller($extracted, $is_install);\n\n        if (isset($options['is_update']) && $options['is_update'] === true) {\n            $method = 'preUpdate';\n        } else {\n            $method = 'preInstall';\n        }\n\n        if ($installer && method_exists($installer, $method)) {\n            $method_result = $installer::$method();\n            if ($method_result !== true) {\n                self::$error = 'An error occurred';\n                if (is_string($method_result)) {\n                    self::$error = $method_result;\n                }\n\n                return false;\n            }\n        }\n\n        if (!$options['sophisticated']) {\n            $isTheme = $options['theme'] ?? false;\n            // Make sure that themes are always being copied, even if option was not set!\n            $isTheme = $isTheme || preg_match('|/themes/[^/]+|ui', $install_path);\n            if ($isTheme) {\n                self::copyInstall($extracted, $install_path);\n            } else {\n                self::moveInstall($extracted, $install_path);\n            }\n        } else {\n            self::sophisticatedInstall($extracted, $install_path, $options['ignores'], $keepExtracted);\n        }\n\n        Folder::delete($tmp);\n\n        if (isset($options['is_update']) && $options['is_update'] === true) {\n            $method = 'postUpdate';\n        } else {\n            $method = 'postInstall';\n        }\n\n        self::$message = '';\n        if ($installer && method_exists($installer, $method)) {\n            self::$message = $installer::$method();\n        }\n\n        self::$error = self::OK;\n\n        return true;\n    }\n\n    /**\n     * Unzip a file to somewhere\n     *\n     * @param string $zip_file\n     * @param string $destination\n     * @return string|false\n     */\n    public static function unZip($zip_file, $destination)\n    {\n        $zip = new ZipArchive();\n        $archive = $zip->open($zip_file);\n\n        if ($archive === true) {\n            Folder::create($destination);\n\n            $unzip = $zip->extractTo($destination);\n\n\n            if (!$unzip) {\n                self::$error = self::ZIP_EXTRACT_ERROR;\n                Folder::delete($destination);\n                $zip->close();\n                return false;\n            }\n\n            $package_folder_name = $zip->getNameIndex(0);\n            if ($package_folder_name === false) {\n                throw new \\RuntimeException('Bad package file: ' . Utils::basename($zip_file));\n            }\n            $package_folder_name = preg_replace('#\\./$#', '', $package_folder_name);\n            $zip->close();\n\n            return $destination . '/' . $package_folder_name;\n        }\n\n        self::$error = self::ZIP_EXTRACT_ERROR;\n        self::$error_zip = $archive;\n\n        return false;\n    }\n\n    /**\n     * Instantiates and returns the package installer class\n     *\n     * @param string $installer_file_folder The folder path that contains install.php\n     * @param bool $is_install True if install, false if removal\n     * @return string|null\n     */\n    private static function loadInstaller($installer_file_folder, $is_install)\n    {\n        $installer_file_folder = rtrim($installer_file_folder, DS);\n\n        $install_file = $installer_file_folder . DS . 'install.php';\n\n        if (!file_exists($install_file)) {\n            return null;\n        }\n\n        require_once $install_file;\n\n        if ($is_install) {\n            $slug = '';\n            if (($pos = strpos($installer_file_folder, 'grav-plugin-')) !== false) {\n                $slug = substr($installer_file_folder, $pos + strlen('grav-plugin-'));\n            } elseif (($pos = strpos($installer_file_folder, 'grav-theme-')) !== false) {\n                $slug = substr($installer_file_folder, $pos + strlen('grav-theme-'));\n            }\n        } else {\n            $path_elements = explode('/', $installer_file_folder);\n            $slug = end($path_elements);\n        }\n\n        if (!$slug) {\n            return null;\n        }\n\n        $class_name = ucfirst($slug) . 'Install';\n\n        if (class_exists($class_name)) {\n            return $class_name;\n        }\n\n        $class_name_alphanumeric = preg_replace('/[^a-zA-Z0-9]+/', '', $class_name) ?? $class_name;\n\n        if (class_exists($class_name_alphanumeric)) {\n            return $class_name_alphanumeric;\n        }\n\n        return null;\n    }\n\n    /**\n     * @param string            $source_path\n     * @param string            $install_path\n     * @return bool\n     */\n    public static function moveInstall($source_path, $install_path)\n    {\n        if (file_exists($install_path)) {\n            Folder::delete($install_path);\n        }\n\n        Folder::move($source_path, $install_path);\n\n        return true;\n    }\n\n    /**\n     * @param string            $source_path\n     * @param string            $install_path\n     * @return bool\n     */\n    public static function copyInstall($source_path, $install_path)\n    {\n        if (empty($source_path)) {\n            throw new RuntimeException(\"Directory $source_path is missing\");\n        }\n\n        Folder::rcopy($source_path, $install_path);\n\n        return true;\n    }\n\n    /**\n     * @param string            $source_path\n     * @param string            $install_path\n     * @param array             $ignores\n     * @param bool              $keep_source\n     * @return bool\n     */\n    public static function sophisticatedInstall($source_path, $install_path, $ignores = [], $keep_source = false)\n    {\n        // Set maintenance mode flag and clear opcache before file operations\n        @file_put_contents(GRAV_ROOT . '/.upgrading', date('Y-m-d H:i:s'));\n        if (function_exists('opcache_reset')) {\n            @opcache_reset();\n        }\n\n        foreach (new DirectoryIterator($source_path) as $file) {\n            if ($file->isLink() || $file->isDot() || in_array($file->getFilename(), $ignores, true)) {\n                continue;\n            }\n\n            $path = $install_path . DS . $file->getFilename();\n\n            if ($file->isDir()) {\n                Folder::delete($path);\n                if ($keep_source) {\n                    Folder::copy($file->getPathname(), $path);\n                } else {\n                    Folder::move($file->getPathname(), $path);\n                }\n\n                if ($file->getFilename() === 'bin') {\n                    $glob = glob($path . DS . '*') ?: [];\n                    foreach ($glob as $bin_file) {\n                        @chmod($bin_file, 0755);\n                    }\n                }\n            } else {\n                @unlink($path);\n                @copy($file->getPathname(), $path);\n            }\n        }\n\n        // Remove maintenance mode flag and clear opcache after file operations\n        @unlink(GRAV_ROOT . '/.upgrading');\n        clearstatcache(true);\n        if (function_exists('opcache_reset')) {\n            @opcache_reset();\n        }\n\n        return true;\n    }\n\n    /**\n     * Uninstalls one or more given package\n     *\n     * @param  string $path    The slug of the package(s)\n     * @param  array  $options Options to use for uninstalling\n     * @return bool True if everything went fine, False otherwise.\n     */\n    public static function uninstall($path, $options = [])\n    {\n        $options = array_merge(self::$options, $options);\n        if (!self::isValidDestination($path, $options['exclude_checks'])\n        ) {\n            return false;\n        }\n\n        $installer_file_folder = $path;\n        $is_install = false;\n        $installer = self::loadInstaller($installer_file_folder, $is_install);\n\n        if ($installer && method_exists($installer, 'preUninstall')) {\n            $method_result = $installer::preUninstall();\n            if ($method_result !== true) {\n                self::$error = 'An error occurred';\n                if (is_string($method_result)) {\n                    self::$error = $method_result;\n                }\n\n                return false;\n            }\n        }\n\n        $result = Folder::delete($path);\n\n        self::$message = '';\n        if ($result && $installer && method_exists($installer, 'postUninstall')) {\n            self::$message = $installer::postUninstall();\n        }\n\n        return $result;\n    }\n\n    /**\n     * Runs a set of checks on the destination and sets the Error if any\n     *\n     * @param  string $destination The directory to run validations at\n     * @param  array  $exclude     An array of constants to exclude from the validation\n     * @return bool True if validation passed. False otherwise\n     */\n    public static function isValidDestination($destination, $exclude = [])\n    {\n        self::$error = 0;\n        self::$target = $destination;\n\n        if (is_link($destination)) {\n            self::$error = self::IS_LINK;\n        } elseif (file_exists($destination)) {\n            self::$error = self::EXISTS;\n        } elseif (!file_exists($destination)) {\n            self::$error = self::NOT_FOUND;\n        } elseif (!is_dir($destination)) {\n            self::$error = self::NOT_DIRECTORY;\n        }\n\n        if (count($exclude) && in_array(self::$error, $exclude, true)) {\n            return true;\n        }\n\n        return !self::$error;\n    }\n\n    /**\n     * Validates if the given path is a Grav Instance\n     *\n     * @param  string $target The local path to the Grav Instance\n     * @return bool True if is a Grav Instance. False otherwise\n     */\n    public static function isGravInstance($target)\n    {\n        self::$error = 0;\n        self::$target = $target;\n\n        if (!file_exists($target . DS . 'index.php') ||\n            !file_exists($target . DS . 'bin') ||\n            !file_exists($target . DS . 'user') ||\n            !file_exists($target . DS . 'system' . DS . 'config' . DS . 'system.yaml')\n        ) {\n            self::$error = self::NOT_GRAV_ROOT;\n        }\n\n        return !self::$error;\n    }\n\n    /**\n     * Returns the last message added by the installer\n     *\n     * @return string The message\n     */\n    public static function getMessage()\n    {\n        return self::$message;\n    }\n\n    /**\n     * Returns the last error occurred in a string message format\n     *\n     * @return string The message of the last error\n     */\n    public static function lastErrorMsg()\n    {\n        if (is_string(self::$error)) {\n            return self::$error;\n        }\n\n        switch (self::$error) {\n            case 0:\n                $msg = 'No Error';\n                break;\n\n            case self::EXISTS:\n                $msg = 'The target path \"' . self::$target . '\" already exists';\n                break;\n\n            case self::IS_LINK:\n                $msg = 'The target path \"' . self::$target . '\" is a symbolic link';\n                break;\n\n            case self::NOT_FOUND:\n                $msg = 'The target path \"' . self::$target . '\" does not appear to exist';\n                break;\n\n            case self::NOT_DIRECTORY:\n                $msg = 'The target path \"' . self::$target . '\" does not appear to be a folder';\n                break;\n\n            case self::NOT_GRAV_ROOT:\n                $msg = 'The target path \"' . self::$target . '\" does not appear to be a Grav instance';\n                break;\n\n            case self::ZIP_OPEN_ERROR:\n                $msg = 'Unable to open the package file';\n                break;\n\n            case self::ZIP_EXTRACT_ERROR:\n                $msg = 'Unable to extract the package. ';\n                if (self::$error_zip) {\n                    switch (self::$error_zip) {\n                        case ZipArchive::ER_EXISTS:\n                            $msg .= 'File already exists.';\n                            break;\n\n                        case ZipArchive::ER_INCONS:\n                            $msg .= 'Zip archive inconsistent.';\n                            break;\n\n                        case ZipArchive::ER_MEMORY:\n                            $msg .= 'Memory allocation failure.';\n                            break;\n\n                        case ZipArchive::ER_NOENT:\n                            $msg .= 'No such file.';\n                            break;\n\n                        case ZipArchive::ER_NOZIP:\n                            $msg .= 'Not a zip archive.';\n                            break;\n\n                        case ZipArchive::ER_OPEN:\n                            $msg .= \"Can't open file.\";\n                            break;\n\n                        case ZipArchive::ER_READ:\n                            $msg .= 'Read error.';\n                            break;\n\n                        case ZipArchive::ER_SEEK:\n                            $msg .= 'Seek error.';\n                            break;\n                    }\n                }\n                break;\n\n            case self::INVALID_SOURCE:\n                $msg = 'Invalid source file';\n                break;\n\n            default:\n                $msg = 'Unknown Error';\n                break;\n        }\n\n        return $msg;\n    }\n\n    /**\n     * Returns the last error code of the occurred error\n     *\n     * @return int|string The code of the last error\n     */\n    public static function lastErrorCode()\n    {\n        return self::$error;\n    }\n\n    /**\n     * Allows to manually set an error\n     *\n     * @param int|string $error the Error code\n     * @return void\n     */\n    public static function setError($error)\n    {\n        self::$error = $error;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Licenses.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM;\n\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse RocketTheme\\Toolbox\\File\\FileInterface;\nuse function is_string;\n\n/**\n * Class Licenses\n *\n * @package Grav\\Common\\GPM\n */\nclass Licenses\n{\n    /** @var string Regex to validate the format of a License */\n    protected static $regex = '^(?:[A-F0-9]{8}-){3}(?:[A-F0-9]{8}){1}$';\n    /** @var FileInterface */\n    protected static $file;\n\n    /**\n     * Returns the license for a Premium package\n     *\n     * @param string $slug\n     * @param string $license\n     * @return bool\n     */\n    public static function set($slug, $license)\n    {\n        $licenses = self::getLicenseFile();\n        $data = (array)$licenses->content();\n        $slug = strtolower($slug);\n\n        if ($license && !self::validate($license)) {\n            return false;\n        }\n\n        if (!is_string($license)) {\n            if (isset($data['licenses'][$slug])) {\n                unset($data['licenses'][$slug]);\n            } else {\n                return false;\n            }\n        } else {\n            $data['licenses'][$slug] = $license;\n        }\n\n        $licenses->save($data);\n        $licenses->free();\n\n        return true;\n    }\n\n    /**\n     * Returns the license for a Premium package\n     *\n     * @param string|null $slug\n     * @return string[]|string\n     */\n    public static function get($slug = null)\n    {\n        $licenses = self::getLicenseFile();\n        $data = (array)$licenses->content();\n        $licenses->free();\n\n        if (null === $slug) {\n            return $data['licenses'] ?? [];\n        }\n\n        $slug = strtolower($slug);\n\n        return $data['licenses'][$slug] ?? '';\n    }\n\n\n    /**\n     * Validates the License format\n     *\n     * @param string|null $license\n     * @return bool\n     */\n    public static function validate($license = null)\n    {\n        if (!is_string($license)) {\n            return false;\n        }\n\n        return (bool)preg_match('#' . self::$regex. '#', $license);\n    }\n\n    /**\n     * Get the License File object\n     *\n     * @return FileInterface\n     */\n    public static function getLicenseFile()\n    {\n        if (!isset(self::$file)) {\n            $path = Grav::instance()['locator']->findResource('user-data://') . '/licenses.yaml';\n            if (!file_exists($path)) {\n                touch($path);\n            }\n            self::$file = CompiledYamlFile::instance($path);\n        }\n\n        return self::$file;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Local;\n\nuse Grav\\Common\\GPM\\Common\\AbstractPackageCollection as BaseCollection;\n\n/**\n * Class AbstractPackageCollection\n * @package Grav\\Common\\GPM\\Local\n */\nabstract class AbstractPackageCollection extends BaseCollection\n{\n    /**\n     * AbstractPackageCollection constructor.\n     *\n     * @param array $items\n     */\n    public function __construct($items)\n    {\n        parent::__construct();\n\n        foreach ($items as $name => $data) {\n            $data->set('slug', $name);\n            $this->items[$name] = new Package($data, $this->type);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Local/Package.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Local;\n\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\GPM\\Common\\Package as BasePackage;\nuse Parsedown;\n\n/**\n * Class Package\n * @package Grav\\Common\\GPM\\Local\n */\nclass Package extends BasePackage\n{\n    /** @var array */\n    protected $settings;\n\n    /**\n     * Package constructor.\n     * @param Data $package\n     * @param string|null $package_type\n     */\n    public function __construct(Data $package, $package_type = null)\n    {\n        $data = new Data($package->blueprints()->toArray());\n        parent::__construct($data, $package_type);\n\n        $this->settings = $package->toArray();\n\n        $html_description = Parsedown::instance()->line($this->__get('description'));\n        $this->data->set('slug', $package->__get('slug'));\n        $this->data->set('description_html', $html_description);\n        $this->data->set('description_plain', strip_tags($html_description));\n        $this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->__get('slug')));\n    }\n\n    /**\n     * @return bool\n     */\n    public function isEnabled()\n    {\n        return (bool)$this->settings['enabled'];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Local/Packages.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Local;\n\nuse Grav\\Common\\GPM\\Common\\CachedCollection;\n\n/**\n * Class Packages\n * @package Grav\\Common\\GPM\\Local\n */\nclass Packages extends CachedCollection\n{\n    public function __construct()\n    {\n        $items = [\n            'plugins' => new Plugins(),\n            'themes' => new Themes()\n        ];\n\n        parent::__construct($items);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Local/Plugins.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Local;\n\nuse Grav\\Common\\Grav;\n\n/**\n * Class Plugins\n * @package Grav\\Common\\GPM\\Local\n */\nclass Plugins extends AbstractPackageCollection\n{\n    /** @var string */\n    protected $type = 'plugins';\n\n    /**\n     * Local Plugins Constructor\n     */\n    public function __construct()\n    {\n        /** @var \\Grav\\Common\\Plugins $plugins */\n        $plugins = Grav::instance()['plugins'];\n\n        parent::__construct($plugins->all());\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Local/Themes.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Local;\n\nuse Grav\\Common\\Grav;\n\n/**\n * Class Themes\n * @package Grav\\Common\\GPM\\Local\n */\nclass Themes extends AbstractPackageCollection\n{\n    /** @var string */\n    protected $type = 'themes';\n\n    /**\n     * Local Themes Constructor\n     */\n    public function __construct()\n    {\n        /** @var \\Grav\\Common\\Themes $themes */\n        $themes = Grav::instance()['themes'];\n\n        parent::__construct($themes->all());\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Remote;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\HTTP\\Response;\nuse Grav\\Common\\GPM\\Common\\AbstractPackageCollection as BaseCollection;\nuse \\Doctrine\\Common\\Cache\\FilesystemCache;\nuse RuntimeException;\n\n/**\n * Class AbstractPackageCollection\n * @package Grav\\Common\\GPM\\Remote\n */\nclass AbstractPackageCollection extends BaseCollection\n{\n    /** @var string The cached data previously fetched */\n    protected $raw;\n    /** @var string */\n    protected $repository;\n    /** @var FilesystemCache */\n    protected $cache;\n\n    /** @var int The lifetime to store the entry in seconds */\n    private $lifetime = 86400;\n\n    /**\n     * AbstractPackageCollection constructor.\n     *\n     * @param string|null $repository\n     * @param bool $refresh\n     * @param callable|null $callback\n     */\n    public function __construct($repository = null, $refresh = false, $callback = null)\n    {\n        parent::__construct();\n        if ($repository === null) {\n            throw new RuntimeException('A repository is required to indicate the origin of the remote collection');\n        }\n\n        $channel = Grav::instance()['config']->get('system.gpm.releases', 'stable');\n        $cache_dir = Grav::instance()['locator']->findResource('cache://gpm', true, true);\n        $this->cache = new FilesystemCache($cache_dir);\n\n        $this->repository = $repository . '?v=' . GRAV_VERSION . '&' . $channel . '=1';\n        $this->raw        = $this->cache->fetch(md5($this->repository));\n\n        $this->fetch($refresh, $callback);\n        foreach (json_decode($this->raw, true) as $slug => $data) {\n            // Temporarily fix for using multi-sites\n            if (isset($data['install_path'])) {\n                $path = preg_replace('~^user/~i', 'user://', $data['install_path']);\n                $data['install_path'] = Grav::instance()['locator']->findResource($path, false, true);\n            }\n            $this->items[$slug] = new Package($data, $this->type);\n        }\n    }\n\n    /**\n     * @param bool $refresh\n     * @param callable|null $callback\n     * @return string\n     */\n    public function fetch($refresh = false, $callback = null)\n    {\n        if (!$this->raw || $refresh) {\n            $response  = Response::get($this->repository, [], $callback);\n            $this->raw = $response;\n            $this->cache->save(md5($this->repository), $this->raw, $this->lifetime);\n        }\n\n        return $this->raw;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Remote/GravCore.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Remote;\n\nuse Grav\\Common\\Grav;\nuse \\Doctrine\\Common\\Cache\\FilesystemCache;\nuse InvalidArgumentException;\n\n/**\n * Class GravCore\n * @package Grav\\Common\\GPM\\Remote\n */\nclass GravCore extends AbstractPackageCollection\n{\n    /** @var string */\n    protected $repository = 'https://getgrav.org/downloads/grav.json';\n\n    /** @var array */\n    private $data;\n    /** @var string */\n    private $version;\n    /** @var string */\n    private $date;\n    /** @var string|null */\n    private $min_php;\n\n    /**\n     * @param bool $refresh\n     * @param callable|null $callback\n     * @throws InvalidArgumentException\n     */\n    public function __construct($refresh = false, $callback = null)\n    {\n        $channel = Grav::instance()['config']->get('system.gpm.releases', 'stable');\n        $cache_dir   = Grav::instance()['locator']->findResource('cache://gpm', true, true);\n        $this->cache = new FilesystemCache($cache_dir);\n        $this->repository .= '?v=' . GRAV_VERSION . '&' . $channel . '=1';\n        $this->raw = $this->cache->fetch(md5($this->repository));\n\n        $this->fetch($refresh, $callback);\n\n        $this->data    = json_decode($this->raw, true);\n        $this->version = $this->data['version'] ?? '-';\n        $this->date    = $this->data['date'] ?? '-';\n        $this->min_php = $this->data['min_php'] ?? null;\n\n        if (isset($this->data['assets'])) {\n            foreach ((array)$this->data['assets'] as $slug => $data) {\n                $this->items[$slug] = new Package($data);\n            }\n        }\n    }\n\n    /**\n     * Returns the list of assets associated to the latest version of Grav\n     *\n     * @return array list of assets\n     */\n    public function getAssets()\n    {\n        return $this->data['assets'];\n    }\n\n    /**\n     * Returns the changelog list for each version of Grav\n     *\n     * @param string|null $diff the version number to start the diff from\n     * @return array changelog list for each version\n     */\n    public function getChangelog($diff = null)\n    {\n        if (!$diff) {\n            return $this->data['changelog'];\n        }\n\n        $diffLog = [];\n        foreach ((array)$this->data['changelog'] as $version => $changelog) {\n            preg_match(\"/[\\w\\-\\.]+/\", $version, $cleanVersion);\n\n            if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) {\n                continue;\n            }\n\n            $diffLog[$version] = $changelog;\n        }\n\n        return $diffLog;\n    }\n\n    /**\n     * Return the release date of the latest Grav\n     *\n     * @return string\n     */\n    public function getDate()\n    {\n        return $this->date;\n    }\n\n    /**\n     * Determine if this version of Grav is eligible to be updated\n     *\n     * @return mixed\n     */\n    public function isUpdatable()\n    {\n        return version_compare(GRAV_VERSION, $this->getVersion(), '<');\n    }\n\n    /**\n     * Returns the latest version of Grav available remotely\n     *\n     * @return string\n     */\n    public function getVersion()\n    {\n        return $this->version;\n    }\n\n    /**\n     * Returns the minimum PHP version\n     *\n     * @return string\n     */\n    public function getMinPHPVersion()\n    {\n        // If non min set, assume current PHP version\n        if (null === $this->min_php) {\n            $this->min_php = PHP_VERSION;\n        }\n\n        return $this->min_php;\n    }\n\n    /**\n     * Is this installation symlinked?\n     *\n     * @return bool\n     */\n    public function isSymlink()\n    {\n        return is_link(GRAV_ROOT . DS . 'index.php');\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Remote/Package.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Remote;\n\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\GPM\\Common\\Package as BasePackage;\n\n/**\n * Class Package\n * @package Grav\\Common\\GPM\\Remote\n */\nclass Package extends BasePackage implements \\JsonSerializable\n{\n    /**\n     * Package constructor.\n     * @param array $package\n     * @param string|null $package_type\n     */\n    public function __construct($package, $package_type = null)\n    {\n        $data = new Data($package);\n        parent::__construct($data, $package_type);\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->data->toArray();\n    }\n\n    /**\n     * Returns the changelog list for each version of a package\n     *\n     * @param string|null $diff the version number to start the diff from\n     * @return array changelog list for each version\n     */\n    public function getChangelog($diff = null)\n    {\n        if (!$diff) {\n            return $this->data['changelog'];\n        }\n\n        $diffLog = [];\n        foreach ((array)$this->data['changelog'] as $version => $changelog) {\n            preg_match(\"/[\\w\\-.]+/\", $version, $cleanVersion);\n\n            if (!$cleanVersion || version_compare($diff, $cleanVersion[0], '>=')) {\n                continue;\n            }\n\n            $diffLog[$version] = $changelog;\n        }\n\n        return $diffLog;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Remote/Packages.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Remote;\n\nuse Grav\\Common\\GPM\\Common\\CachedCollection;\n\n/**\n * Class Packages\n * @package Grav\\Common\\GPM\\Remote\n */\nclass Packages extends CachedCollection\n{\n    /**\n     * Packages constructor.\n     * @param bool $refresh\n     * @param callable|null $callback\n     */\n    public function __construct($refresh = false, $callback = null)\n    {\n        $items = [\n            'plugins' => new Plugins($refresh, $callback),\n            'themes' => new Themes($refresh, $callback)\n        ];\n\n        parent::__construct($items);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Remote/Plugins.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Remote;\n\n/**\n * Class Plugins\n * @package Grav\\Common\\GPM\\Remote\n */\nclass Plugins extends AbstractPackageCollection\n{\n    /** @var string */\n    protected $type = 'plugins';\n    /** @var string */\n    protected $repository = 'https://getgrav.org/downloads/plugins.json';\n\n    /**\n     * Local Plugins Constructor\n     * @param bool $refresh\n     * @param callable|null $callback Either a function or callback in array notation\n     */\n    public function __construct($refresh = false, $callback = null)\n    {\n        parent::__construct($this->repository, $refresh, $callback);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Remote/Themes.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM\\Remote;\n\n/**\n * Class Themes\n * @package Grav\\Common\\GPM\\Remote\n */\nclass Themes extends AbstractPackageCollection\n{\n    /** @var string */\n    protected $type = 'themes';\n    /** @var string */\n    protected $repository = 'https://getgrav.org/downloads/themes.json';\n\n    /**\n     * Local Themes Constructor\n     * @param bool $refresh\n     * @param callable|null $callback Either a function or callback in array notation\n     */\n    public function __construct($refresh = false, $callback = null)\n    {\n        parent::__construct($this->repository, $refresh, $callback);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Response.php",
    "content": "<?php\n// Create alias for the deprecated class.\nclass_alias(\\Grav\\Common\\HTTP\\Response::class, \\Grav\\Common\\GPM\\Response::class);\n"
  },
  {
    "path": "system/src/Grav/Common/GPM/Upgrader.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\GPM\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\GPM;\n\nuse Grav\\Common\\GPM\\Remote\\GravCore;\nuse InvalidArgumentException;\n\n/**\n * Class Upgrader\n *\n * @package Grav\\Common\\GPM\n */\nclass Upgrader\n{\n    /** @var GravCore Remote details about latest Grav version */\n    private $remote;\n\n    /** @var string|null */\n    private $min_php;\n\n    /**\n     * Creates a new GPM instance with Local and Remote packages available\n     *\n     * @param boolean  $refresh  Applies to Remote Packages only and forces a refetch of data\n     * @param callable|null $callback Either a function or callback in array notation\n     * @throws InvalidArgumentException\n     */\n    public function __construct($refresh = false, $callback = null)\n    {\n        $this->remote = new Remote\\GravCore($refresh, $callback);\n    }\n\n    /**\n     * Returns the release date of the latest version of Grav\n     *\n     * @return string\n     */\n    public function getReleaseDate()\n    {\n        return $this->remote->getDate();\n    }\n\n    /**\n     * Returns the version of the installed Grav\n     *\n     * @return string\n     */\n    public function getLocalVersion()\n    {\n        return GRAV_VERSION;\n    }\n\n    /**\n     * Returns the version of the remotely available Grav\n     *\n     * @return string\n     */\n    public function getRemoteVersion()\n    {\n        return $this->remote->getVersion();\n    }\n\n    /**\n     * Returns an array of assets available to download remotely\n     *\n     * @return array\n     */\n    public function getAssets()\n    {\n        return $this->remote->getAssets();\n    }\n\n    /**\n     * Returns the changelog list for each version of Grav\n     *\n     * @param string|null $diff the version number to start the diff from\n     * @return array return the changelog list for each version\n     */\n    public function getChangelog($diff = null)\n    {\n        return $this->remote->getChangelog($diff);\n    }\n\n    /**\n     * Make sure this meets minimum PHP requirements\n     *\n     * @return bool\n     */\n    public function meetsRequirements()\n    {\n        if (version_compare(PHP_VERSION, $this->minPHPVersion(), '<')) {\n            return false;\n        }\n\n        return true;\n    }\n\n    /**\n     * Get minimum PHP version from remote\n     *\n     * @return string\n     */\n    public function minPHPVersion()\n    {\n        if (null === $this->min_php) {\n            $this->min_php = $this->remote->getMinPHPVersion();\n        }\n\n        return $this->min_php;\n    }\n\n    /**\n     * Checks if the currently installed Grav is upgradable to a newer version\n     *\n     * @return bool True if it's upgradable, False otherwise.\n     */\n    public function isUpgradable()\n    {\n        return version_compare($this->getLocalVersion(), $this->getRemoteVersion(), '<');\n    }\n\n    /**\n     * Checks if Grav is currently symbolically linked\n     *\n     * @return bool True if Grav is symlinked, False otherwise.\n     */\n    public function isSymlink()\n    {\n        return $this->remote->isSymlink();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Getters.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse ArrayAccess;\nuse Countable;\nuse function count;\n\n/**\n * Class Getters\n * @package Grav\\Common\n */\nabstract class Getters implements ArrayAccess, Countable\n{\n    /** @var string Define variable used in getters. */\n    protected $gettersVariable = null;\n\n    /**\n     * Magic setter method\n     *\n     * @param int|string $offset Medium name value\n     * @param mixed $value  Medium value\n     */\n    #[\\ReturnTypeWillChange]\n    public function __set($offset, $value)\n    {\n        $this->offsetSet($offset, $value);\n    }\n\n    /**\n     * Magic getter method\n     *\n     * @param  int|string $offset Medium name value\n     * @return mixed         Medium value\n     */\n    #[\\ReturnTypeWillChange]\n    public function __get($offset)\n    {\n        return $this->offsetGet($offset);\n    }\n\n    /**\n     * Magic method to determine if the attribute is set\n     *\n     * @param  int|string $offset Medium name value\n     * @return boolean         True if the value is set\n     */\n    #[\\ReturnTypeWillChange]\n    public function __isset($offset)\n    {\n        return $this->offsetExists($offset);\n    }\n\n    /**\n     * Magic method to unset the attribute\n     *\n     * @param int|string $offset The name value to unset\n     */\n    #[\\ReturnTypeWillChange]\n    public function __unset($offset)\n    {\n        $this->offsetUnset($offset);\n    }\n\n    /**\n     * @param int|string $offset\n     * @return bool\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset)\n    {\n        if ($this->gettersVariable) {\n            $var = $this->gettersVariable;\n\n            return isset($this->{$var}[$offset]);\n        }\n\n        return isset($this->{$offset});\n    }\n\n    /**\n     * @param int|string $offset\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        if ($this->gettersVariable) {\n            $var = $this->gettersVariable;\n\n            return $this->{$var}[$offset] ?? null;\n        }\n\n        return $this->{$offset} ?? null;\n    }\n\n    /**\n     * @param int|string $offset\n     * @param mixed $value\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetSet($offset, $value)\n    {\n        if ($this->gettersVariable) {\n            $var = $this->gettersVariable;\n            $this->{$var}[$offset] = $value;\n        } else {\n            $this->{$offset} = $value;\n        }\n    }\n\n    /**\n     * @param int|string $offset\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetUnset($offset)\n    {\n        if ($this->gettersVariable) {\n            $var = $this->gettersVariable;\n            unset($this->{$var}[$offset]);\n        } else {\n            unset($this->{$offset});\n        }\n    }\n\n    /**\n     * @return int\n     */\n    #[\\ReturnTypeWillChange]\n    public function count()\n    {\n        if ($this->gettersVariable) {\n            $var = $this->gettersVariable;\n            return count($this->{$var});\n        }\n\n        return count($this->toArray());\n    }\n\n    /**\n     * Returns an associative array of object properties.\n     *\n     * @return array\n     */\n    public function toArray()\n    {\n        if ($this->gettersVariable) {\n            $var = $this->gettersVariable;\n\n            return $this->{$var};\n        }\n\n        $properties = (array)$this;\n        $list = [];\n        foreach ($properties as $property => $value) {\n            if ($property[0] !== \"\\0\") {\n                $list[$property] = $value;\n            }\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Grav.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Composer\\Autoload\\ClassLoader;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Config\\Setup;\nuse Grav\\Common\\Helpers\\Exif;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Medium\\ImageMedium;\nuse Grav\\Common\\Page\\Medium\\Medium;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Processors\\AssetsProcessor;\nuse Grav\\Common\\Processors\\BackupsProcessor;\nuse Grav\\Common\\Processors\\DebuggerAssetsProcessor;\nuse Grav\\Common\\Processors\\InitializeProcessor;\nuse Grav\\Common\\Processors\\PagesProcessor;\nuse Grav\\Common\\Processors\\PluginsProcessor;\nuse Grav\\Common\\Processors\\RenderProcessor;\nuse Grav\\Common\\Processors\\RequestProcessor;\nuse Grav\\Common\\Processors\\SchedulerProcessor;\nuse Grav\\Common\\Processors\\TasksProcessor;\nuse Grav\\Common\\Processors\\ThemesProcessor;\nuse Grav\\Common\\Processors\\TwigProcessor;\nuse Grav\\Common\\Scheduler\\Scheduler;\nuse Grav\\Common\\Service\\AccountsServiceProvider;\nuse Grav\\Common\\Service\\AssetsServiceProvider;\nuse Grav\\Common\\Service\\BackupsServiceProvider;\nuse Grav\\Common\\Service\\ConfigServiceProvider;\nuse Grav\\Common\\Service\\ErrorServiceProvider;\nuse Grav\\Common\\Service\\FilesystemServiceProvider;\nuse Grav\\Common\\Service\\FlexServiceProvider;\nuse Grav\\Common\\Service\\InflectorServiceProvider;\nuse Grav\\Common\\Service\\LoggerServiceProvider;\nuse Grav\\Common\\Service\\OutputServiceProvider;\nuse Grav\\Common\\Service\\PagesServiceProvider;\nuse Grav\\Common\\Service\\RequestServiceProvider;\nuse Grav\\Common\\Service\\SessionServiceProvider;\nuse Grav\\Common\\Service\\StreamsServiceProvider;\nuse Grav\\Common\\Service\\TaskServiceProvider;\nuse Grav\\Common\\Twig\\Twig;\nuse Grav\\Framework\\DI\\Container;\nuse Grav\\Framework\\Psr7\\Response;\nuse Grav\\Framework\\RequestHandler\\Middlewares\\MultipartRequestSupport;\nuse Grav\\Framework\\RequestHandler\\RequestHandler;\nuse Grav\\Framework\\Route\\Route;\nuse Grav\\Framework\\Session\\Messages;\nuse InvalidArgumentException;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcherInterface;\nuse function array_key_exists;\nuse function call_user_func_array;\nuse function function_exists;\nuse function get_class;\nuse function in_array;\nuse function is_array;\nuse function is_callable;\nuse function is_int;\nuse function is_string;\nuse function strlen;\n\n/**\n * Grav container is the heart of Grav.\n *\n * @package Grav\\Common\n */\nclass Grav extends Container\n{\n    /** @var string Processed output for the page. */\n    public $output;\n\n    /** @var static The singleton instance */\n    protected static $instance;\n\n    /**\n     * @var array Contains all Services and ServicesProviders that are mapped\n     *            to the dependency injection container.\n     */\n    protected static $diMap = [\n        AccountsServiceProvider::class,\n        AssetsServiceProvider::class,\n        BackupsServiceProvider::class,\n        ConfigServiceProvider::class,\n        ErrorServiceProvider::class,\n        FilesystemServiceProvider::class,\n        FlexServiceProvider::class,\n        InflectorServiceProvider::class,\n        LoggerServiceProvider::class,\n        OutputServiceProvider::class,\n        PagesServiceProvider::class,\n        RequestServiceProvider::class,\n        SessionServiceProvider::class,\n        StreamsServiceProvider::class,\n        TaskServiceProvider::class,\n        'browser'    => Browser::class,\n        'cache'      => Cache::class,\n        'events'     => EventDispatcher::class,\n        'exif'       => Exif::class,\n        'plugins'    => Plugins::class,\n        'scheduler'  => Scheduler::class,\n        'taxonomy'   => Taxonomy::class,\n        'themes'     => Themes::class,\n        'twig'       => Twig::class,\n        'uri'        => Uri::class,\n    ];\n\n    /**\n     * @var array All middleware processors that are processed in $this->process()\n     */\n    protected $middleware = [\n        'multipartRequestSupport',\n        'initializeProcessor',\n        'pluginsProcessor',\n        'themesProcessor',\n        'requestProcessor',\n        'tasksProcessor',\n        'backupsProcessor',\n        'schedulerProcessor',\n        'assetsProcessor',\n        'twigProcessor',\n        'pagesProcessor',\n        'debuggerAssetsProcessor',\n        'renderProcessor',\n    ];\n\n    /** @var array */\n    protected $initialized = [];\n\n    /**\n     * Reset the Grav instance.\n     *\n     * @return void\n     */\n    public static function resetInstance(): void\n    {\n        if (self::$instance) {\n            // @phpstan-ignore-next-line\n            self::$instance = null;\n        }\n    }\n\n    /**\n     * Return the Grav instance. Create it if it's not already instanced\n     *\n     * @param array $values\n     * @return Grav\n     */\n    public static function instance(array $values = [])\n    {\n        if (null === self::$instance) {\n            self::$instance = static::load($values);\n\n            /** @var ClassLoader|null $loader */\n            $loader = self::$instance['loader'] ?? null;\n            if ($loader) {\n                // Load fix for Deferred Twig Extension\n                $loader->addPsr4('Phive\\\\Twig\\\\Extensions\\\\Deferred\\\\', LIB_DIR . 'Phive/Twig/Extensions/Deferred/', true);\n            }\n        } elseif ($values) {\n            $instance = self::$instance;\n            foreach ($values as $key => $value) {\n                $instance->offsetSet($key, $value);\n            }\n        }\n\n        return self::$instance;\n    }\n\n    /**\n     * Get Grav version.\n     *\n     * @return string\n     */\n    public function getVersion(): string\n    {\n        return GRAV_VERSION;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isSetup(): bool\n    {\n        return isset($this->initialized['setup']);\n    }\n\n    /**\n     * Setup Grav instance using specific environment.\n     *\n     * @param string|null $environment\n     * @return $this\n     */\n    public function setup(string $environment = null)\n    {\n        if (isset($this->initialized['setup'])) {\n            return $this;\n        }\n\n        $this->initialized['setup'] = true;\n\n        // Force environment if passed to the method.\n        if ($environment) {\n            Setup::$environment = $environment;\n        }\n\n        // Initialize setup and streams.\n        $this['setup'];\n        $this['streams'];\n\n        return $this;\n    }\n\n    /**\n     * Initialize CLI environment.\n     *\n     * Call after `$grav->setup($environment)`\n     *\n     * - Load configuration\n     * - Initialize logger\n     * - Disable debugger\n     * - Set timezone, locale\n     * - Load plugins (call PluginsLoadedEvent)\n     * - Set Pages and Users type to be used in the site\n     *\n     * This method WILL NOT initialize assets, twig or pages.\n     *\n     * @return $this\n     */\n    public function initializeCli()\n    {\n        InitializeProcessor::initializeCli($this);\n\n        return $this;\n    }\n\n    /**\n     * Process a request\n     *\n     * @return void\n     */\n    public function process(): void\n    {\n        if (isset($this->initialized['process'])) {\n            return;\n        }\n\n        // Initialize Grav if needed.\n        $this->setup();\n\n        $this->initialized['process'] = true;\n\n        $container = new Container(\n            [\n                'multipartRequestSupport' => function () {\n                    return new MultipartRequestSupport();\n                },\n                'initializeProcessor' => function () {\n                    return new InitializeProcessor($this);\n                },\n                'backupsProcessor' => function () {\n                    return new BackupsProcessor($this);\n                },\n                'pluginsProcessor' => function () {\n                    return new PluginsProcessor($this);\n                },\n                'themesProcessor' => function () {\n                    return new ThemesProcessor($this);\n                },\n                'schedulerProcessor' => function () {\n                    return new SchedulerProcessor($this);\n                },\n                'requestProcessor' => function () {\n                    return new RequestProcessor($this);\n                },\n                'tasksProcessor' => function () {\n                    return new TasksProcessor($this);\n                },\n                'assetsProcessor' => function () {\n                    return new AssetsProcessor($this);\n                },\n                'twigProcessor' => function () {\n                    return new TwigProcessor($this);\n                },\n                'pagesProcessor' => function () {\n                    return new PagesProcessor($this);\n                },\n                'debuggerAssetsProcessor' => function () {\n                    return new DebuggerAssetsProcessor($this);\n                },\n                'renderProcessor' => function () {\n                    return new RenderProcessor($this);\n                },\n            ]\n        );\n\n        $default = static function () {\n            return new Response(404, ['Expires' => 0, 'Cache-Control' => 'no-store, max-age=0'], 'Not Found');\n        };\n\n        $collection = new RequestHandler($this->middleware, $default, $container);\n\n        $response = $collection->handle($this['request']);\n        $body = $response->getBody();\n\n        /** @var Messages $messages */\n        $messages = $this['messages'];\n\n        // Prevent caching if session messages were displayed in the page.\n        $noCache = $messages->isCleared();\n        if ($noCache) {\n            $response = $response->withHeader('Cache-Control', 'no-store, max-age=0');\n        }\n\n        // Handle ETag and If-None-Match headers.\n        if ($response->getHeaderLine('ETag') === '1') {\n            $etag = md5($body);\n            $response = $response->withHeader('ETag', '\"' . $etag . '\"');\n\n            $search = trim($this['request']->getHeaderLine('If-None-Match'), '\"');\n            if ($noCache === false && $search === $etag) {\n                $response = $response->withStatus(304);\n                $body = '';\n            }\n        }\n\n        // Echo page content.\n        $this->header($response);\n        echo $body;\n\n        $this['debugger']->render();\n\n        // Response object can turn off all shutdown processing. This can be used for example to speed up AJAX responses.\n        // Note that using this feature will also turn off response compression.\n        if ($response->getHeaderLine('Grav-Internal-SkipShutdown') !== '1') {\n            register_shutdown_function([$this, 'shutdown']);\n        }\n    }\n\n    /**\n     * Clean any output buffers. Useful when exiting from the application.\n     *\n     * Please use $grav->close() and $grav->redirect() instead of calling this one!\n     *\n     * @return void\n     */\n    public function cleanOutputBuffers(): void\n    {\n        // Make sure nothing extra gets written to the response.\n        while (ob_get_level()) {\n            ob_end_clean();\n        }\n        // Work around PHP bug #8218 (8.0.17 & 8.1.4).\n        header_remove('Content-Encoding');\n    }\n\n    /**\n     * Terminates Grav request with a response.\n     *\n     * Please use this method instead of calling `die();` or `exit();`. Note that you need to create a response object.\n     *\n     * @param ResponseInterface $response\n     * @return never-return\n     */\n    public function close(ResponseInterface $response): void\n    {\n        $this->cleanOutputBuffers();\n\n        // Close the session.\n        if (isset($this['session'])) {\n            $this['session']->close();\n        }\n\n        /** @var ServerRequestInterface $request */\n        $request = $this['request'];\n\n        /** @var Debugger $debugger */\n        $debugger = $this['debugger'];\n        $response = $debugger->logRequest($request, $response);\n\n        $body = $response->getBody();\n\n        /** @var Messages $messages */\n        $messages = $this['messages'];\n\n        // Prevent caching if session messages were displayed in the page.\n        $noCache = $messages->isCleared();\n        if ($noCache) {\n            $response = $response->withHeader('Cache-Control', 'no-store, max-age=0');\n        }\n\n        // Handle ETag and If-None-Match headers.\n        if ($response->getHeaderLine('ETag') === '1') {\n            $etag = md5($body);\n            $response = $response->withHeader('ETag', '\"' . $etag . '\"');\n\n            $search = trim($this['request']->getHeaderLine('If-None-Match'), '\"');\n            if ($noCache === false && $search === $etag) {\n                $response = $response->withStatus(304);\n                $body = '';\n            }\n        }\n\n        // Echo page content.\n        $this->header($response);\n        echo $body;\n        exit();\n    }\n\n    /**\n     * @param ResponseInterface $response\n     * @return never-return\n     * @deprecated 1.7 Use $grav->close() instead.\n     */\n    public function exit(ResponseInterface $response): void\n    {\n        $this->close($response);\n    }\n\n    /**\n     * Terminates Grav request and redirects browser to another location.\n     *\n     * Please use this method instead of calling `header(\"Location: {$url}\", true, 302); exit();`.\n     *\n     * @param Route|string $route Internal route.\n     * @param int|null $code  Redirection code (30x)\n     * @return never-return\n     */\n    public function redirect($route, $code = null): void\n    {\n        $response = $this->getRedirectResponse($route, $code);\n\n        $this->close($response);\n    }\n\n    /**\n     * Returns redirect response object from Grav.\n     *\n     * @param Route|string $route Internal route.\n     * @param int|null $code  Redirection code (30x)\n     * @return ResponseInterface\n     */\n    public function getRedirectResponse($route, $code = null): ResponseInterface\n    {\n        /** @var Uri $uri */\n        $uri = $this['uri'];\n\n        if (is_string($route)) {\n            // Clean route for redirect\n            $route = preg_replace(\"#^\\/[\\\\\\/]+\\/#\", '/', $route);\n\n            if (null === $code) {\n                // Check for redirect code in the route: e.g. /new/[301], /new[301]/route or /new[301].html\n                $regex = '/.*(\\[(30[1-7])\\])(.\\w+|\\/.*?)?$/';\n                preg_match($regex, $route, $matches);\n                if ($matches) {\n                    $route = str_replace($matches[1], '', $matches[0]);\n                    $code = $matches[2];\n                }\n            }\n\n            if ($uri::isExternal($route)) {\n                $url = $route;\n            } else {\n                $url = rtrim($uri->rootUrl(), '/') . '/';\n\n                if ($this['config']->get('system.pages.redirect_trailing_slash', true)) {\n                    $url .= trim($route, '/'); // Remove trailing slash\n                } else {\n                    $url .= ltrim($route, '/'); // Support trailing slash default routes\n                }\n            }\n        } elseif ($route instanceof Route) {\n            $url = $route->toString(true);\n        } else {\n            throw new InvalidArgumentException('Bad $route');\n        }\n\n        if ($code < 300 || $code > 399) {\n            $code = null;\n        }\n\n        if ($code === null) {\n            $code = $this['config']->get('system.pages.redirect_default_code', 302);\n        }\n\n        if ($uri->extension() === 'json') {\n            return new Response(200, ['Content-Type' => 'application/json'], json_encode(['code' => $code, 'redirect' => $url], JSON_THROW_ON_ERROR));\n        }\n\n        return new Response($code, ['Location' => $url]);\n    }\n\n    /**\n     * Redirect browser to another location taking language into account (preferred)\n     *\n     * @param string $route Internal route.\n     * @param int    $code  Redirection code (30x)\n     * @return void\n     */\n    public function redirectLangSafe($route, $code = null): void\n    {\n        if (!$this['uri']->isExternal($route)) {\n            $this->redirect($this['pages']->route($route), $code);\n        } else {\n            $this->redirect($route, $code);\n        }\n    }\n\n    /**\n     * Set response header.\n     *\n     * @param ResponseInterface|null $response\n     * @return void\n     */\n    public function header(ResponseInterface $response = null): void\n    {\n        if (null === $response) {\n            /** @var PageInterface $page */\n            $page = $this['page'];\n            $response = new Response($page->httpResponseCode(), $page->httpHeaders(), '');\n        }\n\n        header(\"HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}\");\n        foreach ($response->getHeaders() as $key => $values) {\n            // Skip internal Grav headers.\n            if (strpos($key, 'Grav-Internal-') === 0) {\n                continue;\n            }\n            foreach ($values as $i => $value) {\n                header($key . ': ' . $value, $i === 0);\n            }\n        }\n    }\n\n    /**\n     * Set the system locale based on the language and configuration\n     *\n     * @return void\n     */\n    public function setLocale(): void\n    {\n        // Initialize Locale if set and configured.\n        if ($this['language']->enabled() && $this['config']->get('system.languages.override_locale')) {\n            $language = $this['language']->getLanguage();\n            setlocale(LC_ALL, strlen($language) < 3 ? ($language . '_' . strtoupper($language)) : $language);\n        } elseif ($this['config']->get('system.default_locale')) {\n            setlocale(LC_ALL, $this['config']->get('system.default_locale'));\n        }\n    }\n\n    /**\n     * @param object $event\n     * @return object\n     */\n    public function dispatchEvent($event)\n    {\n        /** @var EventDispatcherInterface $events */\n        $events = $this['events'];\n        $eventName = get_class($event);\n\n        $timestamp = microtime(true);\n        $event = $events->dispatch($event);\n\n        /** @var Debugger $debugger */\n        $debugger = $this['debugger'];\n        $debugger->addEvent($eventName, $event, $events, $timestamp);\n\n        return $event;\n    }\n\n    /**\n     * Fires an event with optional parameters.\n     *\n     * @param  string $eventName\n     * @param  Event|null $event\n     * @return Event\n     */\n    public function fireEvent($eventName, Event $event = null)\n    {\n        /** @var EventDispatcherInterface $events */\n        $events = $this['events'];\n        if (null === $event) {\n            $event = new Event();\n        }\n\n        $timestamp = microtime(true);\n        $events->dispatch($event, $eventName);\n\n        /** @var Debugger $debugger */\n        $debugger = $this['debugger'];\n        $debugger->addEvent($eventName, $event, $events, $timestamp);\n\n        return $event;\n    }\n\n    /**\n     * Set the final content length for the page and flush the buffer\n     *\n     * @return void\n     */\n    public function shutdown(): void\n    {\n        // Prevent user abort allowing onShutdown event to run without interruptions.\n        if (function_exists('ignore_user_abort')) {\n            @ignore_user_abort(true);\n        }\n\n        // Close the session allowing new requests to be handled.\n        if (isset($this['session'])) {\n            $this['session']->close();\n        }\n\n        /** @var Config $config */\n        $config = $this['config'];\n        if ($config->get('system.debugger.shutdown.close_connection', true)) {\n            // Flush the response and close the connection to allow time consuming tasks to be performed without leaving\n            // the connection to the client open. This will make page loads to feel much faster.\n\n            // FastCGI allows us to flush all response data to the client and finish the request.\n            $success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false;\n            if (!$success) {\n                // Unfortunately without FastCGI there is no way to force close the connection.\n                // We need to ask browser to close the connection for us.\n\n                if ($config->get('system.cache.gzip')) {\n                    // Flush gzhandler buffer if gzip setting was enabled to get the size of the compressed output.\n                    ob_end_flush();\n                } elseif ($config->get('system.cache.allow_webserver_gzip')) {\n                    // Let web server to do the hard work.\n                    header('Content-Encoding: identity');\n                } elseif (function_exists('apache_setenv')) {\n                    // Without gzip we have no other choice than to prevent server from compressing the output.\n                    // This action turns off mod_deflate which would prevent us from closing the connection.\n                    @apache_setenv('no-gzip', '1');\n                } else {\n                    // Fall back to unknown content encoding, it prevents most servers from deflating the content.\n                    header('Content-Encoding: none');\n                }\n\n                // Get length and close the connection.\n                header('Content-Length: ' . ob_get_length());\n                header('Connection: close');\n\n                ob_end_flush();\n                @ob_flush();\n                flush();\n            }\n        }\n\n        // Run any time consuming tasks.\n        $this->fireEvent('onShutdown');\n    }\n\n    /**\n     * Magic Catch All Function\n     *\n     * Used to call closures.\n     *\n     * Source: http://stackoverflow.com/questions/419804/closures-as-class-members\n     *\n     * @param string $method\n     * @param array $args\n     * @return mixed|null\n     */\n    #[\\ReturnTypeWillChange]\n    public function __call($method, $args)\n    {\n        $closure = $this->{$method} ?? null;\n\n        return is_callable($closure) ? $closure(...$args) : null;\n    }\n\n    /**\n     * Measure how long it takes to do an action.\n     *\n     * @param string $timerId\n     * @param string $timerTitle\n     * @param callable $callback\n     * @return mixed   Returns value returned by the callable.\n     */\n    public function measureTime(string $timerId, string $timerTitle, callable $callback)\n    {\n        $debugger = $this['debugger'];\n        $debugger->startTimer($timerId, $timerTitle);\n        $result = $callback();\n        $debugger->stopTimer($timerId);\n\n        return $result;\n    }\n\n    /**\n     * Initialize and return a Grav instance\n     *\n     * @param  array $values\n     * @return static\n     */\n    protected static function load(array $values)\n    {\n        $container = new static($values);\n\n        $container['debugger'] = new Debugger();\n        $container['grav'] = function (Container $container) {\n            user_error('Calling $grav[\\'grav\\'] or {{ grav.grav }} is deprecated since Grav 1.6, just use $grav or {{ grav }}', E_USER_DEPRECATED);\n\n            return $container;\n        };\n\n        $container->registerServices();\n\n        return $container;\n    }\n\n    /**\n     * Register all services\n     * Services are defined in the diMap. They can either only the class\n     * of a Service Provider or a pair of serviceKey => serviceClass that\n     * gets directly mapped into the container.\n     *\n     * @return void\n     */\n    protected function registerServices(): void\n    {\n        foreach (self::$diMap as $serviceKey => $serviceClass) {\n            if (is_int($serviceKey)) {\n                $this->register(new $serviceClass);\n            } else {\n                $this[$serviceKey] = function ($c) use ($serviceClass) {\n                    return new $serviceClass($c);\n                };\n            }\n        }\n    }\n\n    /**\n     * This attempts to find media, other files, and download them\n     *\n     * @param string $path\n     * @return PageInterface|false\n     */\n    public function fallbackUrl($path)\n    {\n        $path_parts = Utils::pathinfo($path);\n        if (!is_array($path_parts)) {\n            return false;\n        }\n\n        /** @var Uri $uri */\n        $uri = $this['uri'];\n\n        /** @var Config $config */\n        $config = $this['config'];\n\n        /** @var Pages $pages */\n        $pages = $this['pages'];\n        $page = $pages->find($path_parts['dirname'], true);\n\n        $uri_extension = strtolower($uri->extension() ?? '');\n        $fallback_types = $config->get('system.media.allowed_fallback_types');\n        $supported_types = $config->get('media.types');\n\n        $parsed_url = parse_url(rawurldecode($uri->basename()));\n        $media_file = isset($parsed_url['path']) ? $parsed_url['path'] : '';\n\n        $event = new Event([\n            'uri' => $uri,\n            'page' => &$page,\n            'filename' => &$media_file,\n            'extension' => $uri_extension,\n            'allowed_fallback_types' => &$fallback_types,\n            'media_types' => &$supported_types\n        ]);\n\n        $this->fireEvent('onPageFallBackUrl', $event);\n\n        // Check whitelist first, then ensure extension is a valid media type\n        if (!empty($fallback_types) && !in_array($uri_extension, $fallback_types, true)) {\n            return false;\n        }\n        if (!array_key_exists($uri_extension, $supported_types)) {\n            return false;\n        }\n\n        if ($page) {\n            $media = $page->media()->all();\n\n            // if this is a media object, try actions first\n            if (isset($media[$media_file])) {\n                /** @var Medium $medium */\n                $medium = $media[$media_file];\n                foreach ($uri->query(null, true) as $action => $params) {\n                    if (in_array($action, ImageMedium::$magic_actions, true)) {\n                        call_user_func_array([&$medium, $action], explode(',', $params));\n                    }\n                }\n                Utils::download($medium->path(), false);\n            }\n\n            // unsupported media type, try to download it...\n            if ($uri_extension) {\n                $extension = $uri_extension;\n            } elseif (isset($path_parts['extension'])) {\n                $extension = $path_parts['extension'];\n            } else {\n                $extension = null;\n            }\n\n            if ($extension) {\n                $download = true;\n                if (in_array(ltrim($extension, '.'), $config->get('system.media.unsupported_inline_types', []), true)) {\n                    $download = false;\n                }\n                Utils::download($page->path() . DIRECTORY_SEPARATOR . $uri->basename(), $download);\n            }\n        }\n\n        // Nothing found\n        return false;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/GravTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\n/**\n * @deprecated 1.4 Use Grav::instance() instead.\n */\ntrait GravTrait\n{\n    /** @var Grav */\n    protected static $grav;\n\n    /**\n     * @return Grav\n     * @deprecated 1.4 Use Grav::instance() instead.\n     */\n    public static function getGrav()\n    {\n        user_error(__TRAIT__ . ' is deprecated since Grav 1.4, use Grav::instance() instead', E_USER_DEPRECATED);\n\n        if (null === self::$grav) {\n            self::$grav = Grav::instance();\n        }\n\n        return self::$grav;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/HTTP/Client.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\HTTP\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\HTTP;\n\nuse Grav\\Common\\Grav;\nuse Symfony\\Component\\HttpClient\\CurlHttpClient;\nuse Symfony\\Component\\HttpClient\\HttpClient;\nuse Symfony\\Component\\HttpClient\\HttpOptions;\nuse Symfony\\Component\\HttpClient\\NativeHttpClient;\nuse Symfony\\Contracts\\HttpClient\\HttpClientInterface;\n\nclass Client\n{\n    /** @var callable    The callback for the progress, either a function or callback in array notation */\n    public static $callback = null;\n    /** @var string[] */\n    private static $headers = [\n        'User-Agent' => 'Grav CMS'\n    ];\n\n    public static function getClient(array $overrides = [], int $connections = 6, callable $callback = null): HttpClientInterface\n    {\n        $config = Grav::instance()['config'];\n        $options = static::getOptions();\n\n        // Use callback if provided\n        if ($callback) {\n            self::$callback = $callback;\n            $options->setOnProgress([Client::class, 'progress']);\n        }\n\n        $settings = array_merge($options->toArray(), $overrides);\n        $preferred_method = $config->get('system.http.method');\n        // Try old GPM setting if value is the same as system default\n        if ($preferred_method === 'auto') {\n            $preferred_method = $config->get('system.gpm.method', 'auto');\n        }\n\n        switch ($preferred_method) {\n            case 'curl':\n                $client = new CurlHttpClient($settings, $connections);\n                break;\n            case 'fopen':\n            case 'native':\n                $client = new NativeHttpClient($settings, $connections);\n                break;\n            default:\n                $client = HttpClient::create($settings, $connections);\n        }\n\n        return $client;\n    }\n\n    /**\n     * Get HTTP Options\n     *\n     * @return HttpOptions\n     */\n    public static function getOptions(): HttpOptions\n    {\n        $config = Grav::instance()['config'];\n        $referer = defined('GRAV_CLI') ? 'grav_cli' : Grav::instance()['uri']->rootUrl(true);\n\n        $options = new HttpOptions();\n\n        // Set default Headers\n        $options->setHeaders(array_merge([ 'Referer' => $referer ], self::$headers));\n\n        // Disable verify Peer if required\n        $verify_peer = $config->get('system.http.verify_peer');\n        // Try old GPM setting if value is default\n        if ($verify_peer === true) {\n            $verify_peer = $config->get('system.gpm.verify_peer', null) ?? $verify_peer;\n        }\n        $options->verifyPeer($verify_peer);\n\n        // Set verify Host\n        $verify_host = $config->get('system.http.verify_host', true);\n        $options->verifyHost($verify_host);\n\n        // New setting and must be enabled for Proxy to work\n        if ($config->get('system.http.enable_proxy', true)) {\n            // Set proxy url if provided\n            $proxy_url = $config->get('system.http.proxy_url', $config->get('system.gpm.proxy_url', null));\n            if ($proxy_url !== null) {\n                $options->setProxy($proxy_url);\n            }\n\n            // Certificate\n            $proxy_cert = $config->get('system.http.proxy_cert_path', null);\n            if ($proxy_cert !== null) {\n                $options->setCaPath($proxy_cert);\n            }\n        }\n\n        return $options;\n    }\n\n    /**\n     * Progress normalized for cURL and Fopen\n     * Accepts a variable length of arguments passed in by stream method\n     *\n     * @return void\n     */\n    public static function progress(int $bytes_transferred, int $filesize, array $info)\n    {\n\n        if ($bytes_transferred > 0) {\n            $percent = $filesize <= 0 ? 0 : (int)(($bytes_transferred * 100) / $filesize);\n\n            $progress = [\n                'code'        => $info['http_code'],\n                'filesize'    => $filesize,\n                'transferred' => $bytes_transferred,\n                'percent'     => $percent < 100 ? $percent : 100\n            ];\n\n            if (self::$callback !== null) {\n                call_user_func(self::$callback, $progress);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/HTTP/Response.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\HTTP\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\HTTP;\n\nuse Exception;\nuse Grav\\Common\\Utils;\nuse Grav\\Common\\Grav;\nuse Symfony\\Component\\HttpClient\\CurlHttpClient;\nuse Symfony\\Component\\HttpClient\\Exception\\TransportException;\nuse Symfony\\Component\\HttpClient\\HttpClient;\nuse Symfony\\Component\\HttpClient\\HttpOptions;\nuse Symfony\\Component\\HttpClient\\NativeHttpClient;\nuse Symfony\\Contracts\\HttpClient\\Exception\\ClientExceptionInterface;\nuse Symfony\\Contracts\\HttpClient\\Exception\\RedirectionExceptionInterface;\nuse Symfony\\Contracts\\HttpClient\\Exception\\ServerExceptionInterface;\nuse Symfony\\Contracts\\HttpClient\\Exception\\TransportExceptionInterface;\nuse Symfony\\Contracts\\HttpClient\\HttpClientInterface;\nuse Symfony\\Contracts\\HttpClient\\ResponseInterface;\nuse function call_user_func;\nuse function defined;\n\n/**\n * Class Response\n * @package Grav\\Common\\GPM\n */\nclass Response\n{\n    /**\n     * Backwards compatible helper method\n     *\n     * @param string $uri\n     * @param array $overrides\n     * @param callable|null $callback\n     * @return string\n     * @throws TransportExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface|ClientExceptionInterface\n     */\n    public static function get(string $uri = '', array $overrides = [], callable $callback = null): string\n    {\n        $response = static::request('GET', $uri, $overrides, $callback);\n        return $response->getContent();\n    }\n\n\n    /**\n     * Makes a request to the URL by using the preferred method\n     *\n     * @param string $method method to call such as GET, PUT, etc\n     * @param string $uri URL to call\n     * @param array $overrides An array of parameters for both `curl` and `fopen`\n     * @param callable|null $callback Either a function or callback in array notation\n     * @return ResponseInterface\n     * @throws TransportExceptionInterface\n     */\n    public static function request(string $method, string $uri, array $overrides = [], callable $callback = null): ResponseInterface\n    {\n        if (empty($method)) {\n            throw new TransportException('missing method (GET, PUT, etc.)');\n        }\n\n        if (empty($uri)) {\n            throw new TransportException('missing URI');\n        }\n\n        // check if this function is available, if so use it to stop any timeouts\n        try {\n            if (Utils::functionExists('set_time_limit')) {\n                @set_time_limit(0);\n            }\n        } catch (Exception $e) {}\n\n        $client = Client::getClient($overrides, 6, $callback);\n\n        return $client->request($method, $uri);\n    }\n\n\n    /**\n     * Is this a remote file or not\n     *\n     * @param string $file\n     * @return bool\n     */\n    public static function isRemote($file): bool\n    {\n        return (bool) filter_var($file, FILTER_VALIDATE_URL);\n    }\n\n\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Helpers/Base32.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Helpers\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Helpers;\n\nuse function chr;\nuse function count;\nuse function ord;\nuse function strlen;\n\n/**\n * Class Base32\n * @package Grav\\Common\\Helpers\n */\nclass Base32\n{\n    /** @var string */\n    protected static $base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';\n    /** @var array */\n    protected static $base32Lookup = [\n        0xFF,0xFF,0x1A,0x1B,0x1C,0x1D,0x1E,0x1F, // '0', '1', '2', '3', '4', '5', '6', '7'\n        0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, // '8', '9', ':', ';', '<', '=', '>', '?'\n        0xFF,0x00,0x01,0x02,0x03,0x04,0x05,0x06, // '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G'\n        0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,0x0E, // 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O'\n        0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16, // 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W'\n        0x17,0x18,0x19,0xFF,0xFF,0xFF,0xFF,0xFF, // 'X', 'Y', 'Z', '[', '\\', ']', '^', '_'\n        0xFF,0x00,0x01,0x02,0x03,0x04,0x05,0x06, // '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g'\n        0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,0x0E, // 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o'\n        0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16, // 'p', 'q', 'r', 's', 't', 'u', 'v', 'w'\n        0x17,0x18,0x19,0xFF,0xFF,0xFF,0xFF,0xFF  // 'x', 'y', 'z', '{', '|', '}', '~', 'DEL'\n    ];\n\n    /**\n     * Encode in Base32\n     *\n     * @param string $bytes\n     * @return string\n     */\n    public static function encode($bytes)\n    {\n        $i = 0;\n        $index = 0;\n        $base32 = '';\n        $bytesLen = strlen($bytes);\n\n        while ($i < $bytesLen) {\n            $currByte = ord($bytes[$i]);\n\n            /* Is the current digit going to span a byte boundary? */\n            if ($index > 3) {\n                if (($i + 1) < $bytesLen) {\n                    $nextByte = ord($bytes[$i+1]);\n                } else {\n                    $nextByte = 0;\n                }\n\n                $digit = $currByte & (0xFF >> $index);\n                $index = ($index + 5) % 8;\n                $digit <<= $index;\n                $digit |= $nextByte >> (8 - $index);\n                $i++;\n            } else {\n                $digit = ($currByte >> (8 - ($index + 5))) & 0x1F;\n                $index = ($index + 5) % 8;\n                if ($index === 0) {\n                    $i++;\n                }\n            }\n\n            $base32 .= self::$base32Chars[$digit];\n        }\n        return $base32;\n    }\n\n    /**\n     * Decode in Base32\n     *\n     * @param string $base32\n     * @return string\n     */\n    public static function decode($base32)\n    {\n        $bytes = [];\n        $base32Len = strlen($base32);\n        $base32LookupLen = count(self::$base32Lookup);\n\n        for ($i = $base32Len * 5 / 8 - 1; $i >= 0; --$i) {\n            $bytes[] = 0;\n        }\n\n        for ($i = 0, $index = 0, $offset = 0; $i < $base32Len; $i++) {\n            $lookup = ord($base32[$i]) - ord('0');\n\n            /* Skip chars outside the lookup table */\n            if ($lookup < 0 || $lookup >= $base32LookupLen) {\n                continue;\n            }\n\n            $digit = self::$base32Lookup[$lookup];\n\n            /* If this digit is not in the table, ignore it */\n            if ($digit === 0xFF) {\n                continue;\n            }\n\n            if ($index <= 3) {\n                $index = ($index + 5) % 8;\n                if ($index === 0) {\n                    $bytes[$offset] |= $digit;\n                    $offset++;\n                    if ($offset >= count($bytes)) {\n                        break;\n                    }\n                } else {\n                    $bytes[$offset] |= $digit << (8 - $index);\n                }\n            } else {\n                $index = ($index + 5) % 8;\n                $bytes[$offset] |= ($digit >> $index);\n                $offset++;\n                if ($offset >= count($bytes)) {\n                    break;\n                }\n                $bytes[$offset] |= $digit << (8 - $index);\n            }\n        }\n\n        $bites = '';\n        foreach ($bytes as $byte) {\n            $bites .= chr($byte);\n        }\n\n        return $bites;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Helpers/Excerpts.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Helpers\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Helpers;\n\nuse DOMDocument;\nuse DOMElement;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Markdown\\Excerpts as ExcerptsObject;\nuse Grav\\Common\\Page\\Medium\\Link;\nuse Grav\\Common\\Page\\Medium\\Medium;\nuse function is_array;\n\n/**\n * Class Excerpts\n * @package Grav\\Common\\Helpers\n */\nclass Excerpts\n{\n    /**\n     * Process Grav image media URL from HTML tag\n     *\n     * @param string $html              HTML tag e.g. `<img src=\"image.jpg\" />`\n     * @param PageInterface|null $page  Page, defaults to the current page object\n     * @return string                   Returns final HTML string\n     */\n    public static function processImageHtml($html, PageInterface $page = null)\n    {\n        $excerpt = static::getExcerptFromHtml($html, 'img');\n        if (null === $excerpt) {\n            return '';\n        }\n\n        $original_src = $excerpt['element']['attributes']['src'];\n        $excerpt['element']['attributes']['href'] = $original_src;\n\n        $excerpt = static::processLinkExcerpt($excerpt, $page, 'image');\n\n        $excerpt['element']['attributes']['src'] = $excerpt['element']['attributes']['href'];\n        unset($excerpt['element']['attributes']['href']);\n\n        $excerpt = static::processImageExcerpt($excerpt, $page);\n\n        $excerpt['element']['attributes']['data-src'] = $original_src;\n\n        $html = static::getHtmlFromExcerpt($excerpt);\n\n        return $html;\n    }\n\n    /**\n     * Process Grav page link URL from HTML tag\n     *\n     * @param string $html              HTML tag e.g. `<a href=\"../foo\">Page Link</a>`\n     * @param PageInterface|null $page  Page, defaults to the current page object\n     * @return string                   Returns final HTML string\n     */\n    public static function processLinkHtml($html, PageInterface $page = null)\n    {\n        $excerpt = static::getExcerptFromHtml($html, 'a');\n        if (null === $excerpt) {\n            return '';\n        }\n\n        $original_href = $excerpt['element']['attributes']['href'];\n        $excerpt = static::processLinkExcerpt($excerpt, $page, 'link');\n        $excerpt['element']['attributes']['data-href'] = $original_href;\n\n        $html = static::getHtmlFromExcerpt($excerpt);\n\n        return $html;\n    }\n\n    /**\n     * Get an Excerpt array from a chunk of HTML\n     *\n     * @param string $html         Chunk of HTML\n     * @param string $tag          A tag, for example `img`\n     * @return array|null   returns nested array excerpt\n     */\n    public static function getExcerptFromHtml($html, $tag)\n    {\n        $doc = new DOMDocument('1.0', 'UTF-8');\n        $internalErrors = libxml_use_internal_errors(true);\n        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));\n        libxml_use_internal_errors($internalErrors);\n\n        $elements = $doc->getElementsByTagName($tag);\n        $excerpt = null;\n        $inner = [];\n\n        foreach ($elements as $element) {\n            $attributes = [];\n            foreach ($element->attributes as $name => $value) {\n                $attributes[$name] = $value->value;\n            }\n            $excerpt = [\n                'element' => [\n                    'name'       => $element->tagName,\n                    'attributes' => $attributes\n                ]\n            ];\n\n            foreach ($element->childNodes as $node) {\n                    $inner[] = $doc->saveHTML($node);\n            }\n\n            $excerpt = array_merge_recursive($excerpt, ['element' => ['text' => implode('', $inner)]]);\n\n\n        }\n\n        return $excerpt;\n    }\n\n    /**\n     * Rebuild HTML tag from an excerpt array\n     *\n     * @param array $excerpt\n     * @return string\n     */\n    public static function getHtmlFromExcerpt($excerpt)\n    {\n        $element = $excerpt['element'];\n        $html = '<'.$element['name'];\n\n        if (isset($element['attributes'])) {\n            foreach ($element['attributes'] as $name => $value) {\n                if ($value === null) {\n                    continue;\n                }\n                $html .= ' '.$name.'=\"'.$value.'\"';\n            }\n        }\n\n        if (isset($element['text'])) {\n            $html .= '>';\n            $html .= is_array($element['text']) ? static::getHtmlFromExcerpt(['element' => $element['text']]) : $element['text'];\n            $html .= '</'.$element['name'].'>';\n        } else {\n            $html .= ' />';\n        }\n\n        return $html;\n    }\n\n    /**\n     * Process a Link excerpt\n     *\n     * @param array $excerpt\n     * @param PageInterface|null $page  Page, defaults to the current page object\n     * @param string $type\n     * @return mixed\n     */\n    public static function processLinkExcerpt($excerpt, PageInterface $page = null, $type = 'link')\n    {\n        $excerpts = new ExcerptsObject($page);\n\n        return $excerpts->processLinkExcerpt($excerpt, $type);\n    }\n\n    /**\n     * Process an image excerpt\n     *\n     * @param array $excerpt\n     * @param PageInterface|null $page  Page, defaults to the current page object\n     * @return array\n     */\n    public static function processImageExcerpt(array $excerpt, PageInterface $page = null)\n    {\n        $excerpts = new ExcerptsObject($page);\n\n        return $excerpts->processImageExcerpt($excerpt);\n    }\n\n    /**\n     * Process media actions\n     *\n     * @param Medium $medium\n     * @param string|array $url\n     * @param PageInterface|null $page  Page, defaults to the current page object\n     * @return Medium|Link\n     */\n    public static function processMediaActions($medium, $url, PageInterface $page = null)\n    {\n        $excerpts = new ExcerptsObject($page);\n\n        return $excerpts->processMediaActions($medium, $url);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Helpers/Exif.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Helpers\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Helpers;\n\nuse Grav\\Common\\Grav;\nuse PHPExif\\Reader\\Reader;\nuse RuntimeException;\nuse function function_exists;\n\n/**\n * Class Exif\n * @package Grav\\Common\\Helpers\n */\nclass Exif\n{\n    /** @var Reader */\n    public $reader;\n\n    /**\n     * Exif constructor.\n     * @throws RuntimeException\n     */\n    public function __construct()\n    {\n        if (Grav::instance()['config']->get('system.media.auto_metadata_exif')) {\n            if (function_exists('exif_read_data') && class_exists(Reader::class)) {\n                $this->reader = Reader::factory(Reader::TYPE_NATIVE);\n            } else {\n                throw new RuntimeException('Please enable the Exif extension for PHP or disable Exif support in Grav system configuration');\n            }\n        }\n    }\n\n    /**\n     * @return Reader\n     */\n    public function getReader()\n    {\n        return $this->reader;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Helpers/LogViewer.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Helpers\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Helpers;\n\nuse DateTime;\nuse DateMalformedStringException;\n\nuse function array_slice;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class LogViewer\n * @package Grav\\Common\\Helpers\n */\nclass LogViewer\n{\n    /** @var string */\n    protected $pattern = '/\\[(?P<date>.*?)\\] (?P<logger>\\w+)\\.(?P<level>\\w+): (?P<message>.*[^ ]+) (?P<context>[^ ]+) (?P<extra>[^ ]+)/';\n\n    /**\n     * Get the objects of a tailed file\n     *\n     * @param string $filepath\n     * @param int $lines\n     * @param bool $desc\n     * @return array\n     */\n    public function objectTail($filepath, $lines = 1, $desc = true)\n    {\n        $data = $this->tail($filepath, $lines);\n        $tailed_log = $data ? explode(PHP_EOL, $data) : [];\n        $line_objects = [];\n\n        foreach ($tailed_log as $line) {\n            $line_objects[] = $this->parse($line);\n        }\n\n        return $desc ? $line_objects : array_reverse($line_objects);\n    }\n\n    /**\n     * Optimized way to get just the last few entries of a log file\n     *\n     * @param string $filepath\n     * @param int $lines\n     * @return string|false\n     */\n    public function tail($filepath, $lines = 1)\n    {\n        $f = $filepath ? @fopen($filepath, 'rb') : false;\n        if ($f === false) {\n            return false;\n        }\n\n        $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096));\n\n        fseek($f, -1, SEEK_END);\n        if (fread($f, 1) !== \"\\n\") {\n            --$lines;\n        }\n\n        // Start reading\n        $output = '';\n        // While we would like more\n        while (ftell($f) > 0 && $lines >= 0) {\n            // Figure out how far back we should jump\n            $seek = min(ftell($f), $buffer);\n            // Do the jump (backwards, relative to where we are)\n            fseek($f, -$seek, SEEK_CUR);\n            // Read a chunk and prepend it to our output\n            $chunk = fread($f, $seek);\n            if ($chunk === false) {\n                throw new \\RuntimeException('Cannot read file');\n            }\n            $output = $chunk . $output;\n            // Jump back to where we started reading\n            fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR);\n            // Decrease our line counter\n            $lines -= substr_count($chunk, \"\\n\");\n        }\n        // While we have too many lines\n        // (Because of buffer size we might have read too many)\n        while ($lines++ < 0) {\n            // Find first newline and remove all text before that\n            $output = substr($output, strpos($output, \"\\n\") + 1);\n        }\n        // Close file and return\n        fclose($f);\n\n        return trim($output);\n    }\n\n    /**\n     * Helper class to get level color\n     *\n     * @param string $level\n     * @return string\n     */\n    public static function levelColor($level)\n    {\n        $colors = [\n            'DEBUG'     => 'green',\n            'INFO'      => 'cyan',\n            'NOTICE'    => 'yellow',\n            'WARNING'   => 'yellow',\n            'ERROR'     => 'red',\n            'CRITICAL'  => 'red',\n            'ALERT'     => 'red',\n            'EMERGENCY' => 'magenta'\n        ];\n        return $colors[$level] ?? 'white';\n    }\n\n    /**\n     * Parse a monolog row into array bits\n     *\n     * @param string $line\n     * @return array\n     */\n    public function parse($line)\n    {\n        if (!is_string($line) || $line === '') {\n            return [];\n        }\n\n        preg_match($this->pattern, $line, $data);\n        if (!isset($data['date'])) {\n            return [];\n        }\n\n        preg_match('/(.*)- Trace:(.*)/', $data['message'], $matches);\n        if (is_array($matches) && isset($matches[1])) {\n            $data['message'] = trim($matches[1]);\n            $data['trace'] = trim($matches[2]);\n        }\n\n        try {\n            $date = new DateTime($data['date']);\n        } catch (DateMalformedStringException $e) {\n            $date = null;\n        }\n\n        return [\n            'date' => $date,\n            'logger' => $data['logger'],\n            'level' => $data['level'],\n            'message' => $data['message'],\n            'trace' => isset($data['trace']) ? self::parseTrace($data['trace']) : null,\n            'context' => json_decode($data['context'], true),\n            'extra' => json_decode($data['extra'], true)\n        ];\n    }\n\n    /**\n     * Parse text of trace into an array of lines\n     *\n     * @param string $trace\n     * @param int $rows\n     * @return array\n     */\n    public static function parseTrace($trace, $rows = 10)\n    {\n        $lines = array_filter(preg_split('/#\\d*/m', $trace));\n\n        return array_slice($lines, 0, $rows);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Helpers/Truncator.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Helpers\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Helpers;\n\nuse DOMText;\nuse DOMDocument;\nuse DOMElement;\nuse DOMNode;\nuse DOMWordsIterator;\nuse DOMLettersIterator;\nuse function in_array;\nuse function strlen;\n\n/**\n * This file is part of https://github.com/Bluetel-Solutions/twig-truncate-extension\n *\n * Copyright (c) 2015 Bluetel Solutions developers@bluetel.co.uk\n * Copyright (c) 2015 Alex Wilson ajw@bluetel.co.uk\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\nclass Truncator\n{\n    /**\n     * Safely truncates HTML by a given number of words.\n     *\n     * @param  string  $html     Input HTML.\n     * @param  int     $limit    Limit to how many words we preserve.\n     * @param  string  $ellipsis String to use as ellipsis (if any).\n     * @return string            Safe truncated HTML.\n     */\n    public static function truncateWords($html, $limit = 0, $ellipsis = '')\n    {\n        if ($limit <= 0) {\n            return $html;\n        }\n\n        $doc = self::htmlToDomDocument($html);\n        $container = $doc->getElementsByTagName('div')->item(0);\n        $container = $container->parentNode->removeChild($container);\n\n        // Iterate over words.\n        $words = new DOMWordsIterator($container);\n        $truncated = false;\n        foreach ($words as $word) {\n            // If we have exceeded the limit, we delete the remainder of the content.\n            if ($words->key() >= $limit) {\n                // Grab current position.\n                $currentWordPosition = $words->currentWordPosition();\n                $curNode = $currentWordPosition[0];\n                $offset = $currentWordPosition[1];\n                $words = $currentWordPosition[2];\n\n                $curNode->nodeValue = substr(\n                    $curNode->nodeValue,\n                    0,\n                    $words[$offset][1] + strlen($words[$offset][0])\n                );\n\n                self::removeProceedingNodes($curNode, $container);\n\n                if (!empty($ellipsis)) {\n                    self::insertEllipsis($curNode, $ellipsis);\n                }\n\n                $truncated = true;\n\n                break;\n            }\n        }\n\n        // Return original HTML if not truncated.\n        if ($truncated) {\n            $html = self::getCleanedHtml($doc, $container);\n        }\n\n        return $html;\n    }\n\n    /**\n     * Safely truncates HTML by a given number of letters.\n     *\n     * @param  string  $html     Input HTML.\n     * @param  int     $limit    Limit to how many letters we preserve.\n     * @param  string  $ellipsis String to use as ellipsis (if any).\n     * @return string            Safe truncated HTML.\n     */\n    public static function truncateLetters($html, $limit = 0, $ellipsis = '')\n    {\n        if ($limit <= 0) {\n            return $html;\n        }\n\n        $doc = self::htmlToDomDocument($html);\n        $container = $doc->getElementsByTagName('div')->item(0);\n        $container = $container->parentNode->removeChild($container);\n\n        // Iterate over letters.\n        $letters = new DOMLettersIterator($container);\n        $truncated = false;\n        foreach ($letters as $letter) {\n            // If we have exceeded the limit, we want to delete the remainder of this document.\n            if ($letters->key() >= $limit) {\n                $currentText = $letters->currentTextPosition();\n                $currentText[0]->nodeValue = mb_substr($currentText[0]->nodeValue, 0, $currentText[1] + 1);\n                self::removeProceedingNodes($currentText[0], $container);\n\n                if (!empty($ellipsis)) {\n                    self::insertEllipsis($currentText[0], $ellipsis);\n                }\n\n                $truncated = true;\n\n                break;\n            }\n        }\n\n        // Return original HTML if not truncated.\n        if ($truncated) {\n            $html = self::getCleanedHtml($doc, $container);\n        }\n\n        return $html;\n    }\n\n    /**\n     * Builds a DOMDocument object from a string containing HTML.\n     *\n     * @param string $html HTML to load\n     * @return DOMDocument Returns a DOMDocument object.\n     */\n    public static function htmlToDomDocument($html)\n    {\n        if (!$html) {\n            $html = '';\n        }\n\n        // Transform multibyte entities which otherwise display incorrectly.\n        $html = mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, ~0], 'UTF-8');\n\n        // Internal errors enabled as HTML5 not fully supported.\n        libxml_use_internal_errors(true);\n\n        // Instantiate new DOMDocument object, and then load in UTF-8 HTML.\n        $dom = new DOMDocument();\n        $dom->encoding = 'UTF-8';\n        $dom->loadHTML(\"<div>$html</div>\");\n\n        return $dom;\n    }\n\n    /**\n     * Removes all nodes after the current node.\n     *\n     * @param  DOMNode|DOMElement $domNode\n     * @param  DOMNode|DOMElement $topNode\n     * @return void\n     */\n    private static function removeProceedingNodes($domNode, $topNode)\n    {\n        /** @var DOMNode|null $nextNode */\n        $nextNode = $domNode->nextSibling;\n\n        if ($nextNode !== null) {\n            self::removeProceedingNodes($nextNode, $topNode);\n            $domNode->parentNode->removeChild($nextNode);\n        } else {\n            //scan upwards till we find a sibling\n            $curNode = $domNode->parentNode;\n            while ($curNode !== $topNode) {\n                if ($curNode->nextSibling !== null) {\n                    $curNode = $curNode->nextSibling;\n                    self::removeProceedingNodes($curNode, $topNode);\n                    $curNode->parentNode->removeChild($curNode);\n                    break;\n                }\n                $curNode = $curNode->parentNode;\n            }\n        }\n    }\n\n    /**\n     * Clean extra code\n     *\n     * @param DOMDocument $doc\n     * @param DOMNode $container\n     * @return string\n     */\n    private static function getCleanedHTML(DOMDocument $doc, DOMNode $container)\n    {\n        while ($doc->firstChild) {\n            $doc->removeChild($doc->firstChild);\n        }\n\n        while ($container->firstChild) {\n            $doc->appendChild($container->firstChild);\n        }\n\n        return trim($doc->saveHTML());\n    }\n\n    /**\n     * Inserts an ellipsis\n     *\n     * @param  DOMNode|DOMElement $domNode  Element to insert after.\n     * @param  string             $ellipsis Text used to suffix our document.\n     * @return void\n     */\n    private static function insertEllipsis($domNode, $ellipsis)\n    {\n        $avoid = array('a', 'strong', 'em', 'h1', 'h2', 'h3', 'h4', 'h5'); //html tags to avoid appending the ellipsis to\n\n        if ($domNode->parentNode->parentNode !== null && in_array($domNode->parentNode->nodeName, $avoid, true)) {\n            // Append as text node to parent instead\n            $textNode = new DOMText($ellipsis);\n\n            /** @var DOMNode|null $nextSibling */\n            $nextSibling = $domNode->parentNode->parentNode->nextSibling;\n            if ($nextSibling) {\n                $domNode->parentNode->parentNode->insertBefore($textNode, $domNode->parentNode->parentNode->nextSibling);\n            } else {\n                $domNode->parentNode->parentNode->appendChild($textNode);\n            }\n        } else {\n            // Append to current node\n            $domNode->nodeValue = rtrim($domNode->nodeValue) . $ellipsis;\n        }\n    }\n\n    /**\n     * @param string $text\n     * @param int $length\n     * @param string $ending\n     * @param bool $exact\n     * @param bool $considerHtml\n     * @return string\n     */\n    public function truncate(\n        $text,\n        $length = 100,\n        $ending = '...',\n        $exact = false,\n        $considerHtml = true\n    ) {\n        if ($considerHtml) {\n            // if the plain text is shorter than the maximum length, return the whole text\n            if (strlen(preg_replace('/<.*?>/', '', $text)) <= $length) {\n                return $text;\n            }\n\n            // splits all html-tags to scanable lines\n            preg_match_all('/(<.+?>)?([^<>]*)/s', $text, $lines, PREG_SET_ORDER);\n            $total_length = strlen($ending);\n            $truncate = '';\n            $open_tags = [];\n\n            foreach ($lines as $line_matchings) {\n                // if there is any html-tag in this line, handle it and add it (uncounted) to the output\n                if (!empty($line_matchings[1])) {\n                    // if it's an \"empty element\" with or without xhtml-conform closing slash\n                    if (preg_match('/^<(\\s*.+?\\/\\s*|\\s*(img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param)(\\s.+?)?)>$/is', $line_matchings[1])) {\n                        // do nothing\n                        // if tag is a closing tag\n                    } elseif (preg_match('/^<\\s*\\/([^\\s]+?)\\s*>$/s', $line_matchings[1], $tag_matchings)) {\n                        // delete tag from $open_tags list\n                        $pos = array_search($tag_matchings[1], $open_tags);\n                        if ($pos !== false) {\n                            unset($open_tags[$pos]);\n                        }\n                        // if tag is an opening tag\n                    } elseif (preg_match('/^<\\s*([^\\s>!]+).*?>$/s', $line_matchings[1], $tag_matchings)) {\n                        // add tag to the beginning of $open_tags list\n                        array_unshift($open_tags, strtolower($tag_matchings[1]));\n                    }\n                    // add html-tag to $truncate'd text\n                    $truncate .= $line_matchings[1];\n                }\n                // calculate the length of the plain text part of the line; handle entities as one character\n                $content_length = strlen(preg_replace('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', ' ', $line_matchings[2]));\n                if ($total_length+$content_length> $length) {\n                    // the number of characters which are left\n                    $left = $length - $total_length;\n                    $entities_length = 0;\n                    // search for html entities\n                    if (preg_match_all('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|[0-9a-f]{1,6};/i', $line_matchings[2], $entities, PREG_OFFSET_CAPTURE)) {\n                        // calculate the real length of all entities in the legal range\n                        foreach ($entities[0] as $entity) {\n                            if ($entity[1]+1-$entities_length <= $left) {\n                                $left--;\n                                $entities_length += strlen($entity[0]);\n                            } else {\n                                // no more characters left\n                                break;\n                            }\n                        }\n                    }\n                    $truncate .= substr($line_matchings[2], 0, $left+$entities_length);\n                    // maximum lenght is reached, so get off the loop\n                    break;\n                } else {\n                    $truncate .= $line_matchings[2];\n                    $total_length += $content_length;\n                }\n                // if the maximum length is reached, get off the loop\n                if ($total_length>= $length) {\n                    break;\n                }\n            }\n        } else {\n            if (strlen($text) <= $length) {\n                return $text;\n            }\n\n            $truncate = substr($text, 0, $length - strlen($ending));\n        }\n        // if the words shouldn't be cut in the middle...\n        if (!$exact) {\n            // ...search the last occurance of a space...\n            $spacepos = strrpos($truncate, ' ');\n            if (false !== $spacepos) {\n                // ...and cut the text in this position\n                $truncate = substr($truncate, 0, $spacepos);\n            }\n        }\n        // add the defined ending to the text\n        $truncate .= $ending;\n        if (isset($open_tags)) {\n            // close all unclosed html-tags\n            foreach ($open_tags as $tag) {\n                $truncate .= '</' . $tag . '>';\n            }\n        }\n\n        return $truncate;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Helpers/YamlLinter.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Helpers\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Helpers;\n\nuse Exception;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse RecursiveDirectoryIterator;\nuse RecursiveIteratorIterator;\nuse RegexIterator;\nuse RocketTheme\\Toolbox\\File\\MarkdownFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse Symfony\\Component\\Yaml\\Yaml;\n\n/**\n * Class YamlLinter\n * @package Grav\\Common\\Helpers\n */\nclass YamlLinter\n{\n    /**\n     * @param string|null $folder\n     * @return array\n     */\n    public static function lint(string $folder = null)\n    {\n        if (null !== $folder) {\n            $folder = $folder ?: GRAV_ROOT;\n\n            return static::recurseFolder($folder);\n        }\n\n        return array_merge(static::lintConfig(), static::lintPages(), static::lintBlueprints());\n    }\n\n    /**\n     * @return array\n     */\n    public static function lintPages()\n    {\n        return static::recurseFolder('page://');\n    }\n\n    /**\n     * @return array\n     */\n    public static function lintConfig()\n    {\n        return static::recurseFolder('config://');\n    }\n\n    /**\n     * @return array\n     */\n    public static function lintBlueprints()\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n\n        $current_theme = Grav::instance()['config']->get('system.pages.theme');\n        $theme_path = 'themes://' . $current_theme . '/blueprints';\n\n        $locator->addPath('blueprints', '', [$theme_path]);\n        return static::recurseFolder('blueprints://');\n    }\n\n    /**\n     * @param string $path\n     * @param string $extensions\n     * @return array\n     */\n    public static function recurseFolder($path, $extensions = '(md|yaml)')\n    {\n        $lint_errors = [];\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $flags = RecursiveDirectoryIterator::SKIP_DOTS | RecursiveDirectoryIterator::FOLLOW_SYMLINKS;\n        if ($locator->isStream($path)) {\n            $directory = $locator->getRecursiveIterator($path, $flags);\n        } else {\n            $directory = new RecursiveDirectoryIterator($path, $flags);\n        }\n        $recursive = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);\n        $iterator = new RegexIterator($recursive, '/^.+\\.'.$extensions.'$/ui');\n\n        /** @var RecursiveDirectoryIterator $file */\n        foreach ($iterator as $filepath => $file) {\n            try {\n                Yaml::parse(static::extractYaml($filepath));\n            } catch (Exception $e) {\n                $lint_errors[str_replace(GRAV_ROOT, '', $filepath)] = $e->getMessage();\n            }\n        }\n\n        return $lint_errors;\n    }\n\n    /**\n     * @param string $path\n     * @return string\n     */\n    protected static function extractYaml($path)\n    {\n        $extension = Utils::pathinfo($path, PATHINFO_EXTENSION);\n        if ($extension === 'md') {\n            $file = MarkdownFile::instance($path);\n            $contents = $file->frontmatter();\n            $file->free();\n        } else {\n            $contents = file_get_contents($path);\n        }\n        return $contents;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Inflector.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse DateInterval;\nuse DateTime;\nuse Grav\\Common\\Language\\Language;\nuse function in_array;\nuse function is_array;\nuse function strlen;\n\n/**\n* This file was originally part of the Akelos Framework\n*/\nclass Inflector\n{\n    /** @var bool */\n    protected static $initialized = false;\n    /** @var array|null */\n    protected static $plural;\n    /** @var array|null */\n    protected static $singular;\n    /** @var array|null */\n    protected static $uncountable;\n    /** @var array|null */\n    protected static $irregular;\n    /** @var array|null */\n    protected static $ordinals;\n\n    /**\n     * @return void\n     */\n    public static function init()\n    {\n        if (!static::$initialized) {\n            static::$initialized = true;\n            /** @var Language $language */\n            $language = Grav::instance()['language'];\n            if (!$language->isDebug()) {\n                static::$plural = $language->translate('GRAV.INFLECTOR_PLURALS', null, true);\n                static::$singular = $language->translate('GRAV.INFLECTOR_SINGULAR', null, true);\n                static::$uncountable = $language->translate('GRAV.INFLECTOR_UNCOUNTABLE', null, true);\n                static::$irregular = $language->translate('GRAV.INFLECTOR_IRREGULAR', null, true);\n                static::$ordinals = $language->translate('GRAV.INFLECTOR_ORDINALS', null, true);\n            }\n        }\n    }\n\n    /**\n     * Pluralizes English nouns.\n     *\n     * @param string $word  English noun to pluralize\n     * @param int    $count The count\n     * @return string|false Plural noun\n     */\n    public static function pluralize($word, $count = 2)\n    {\n        static::init();\n\n        if ((int)$count === 1) {\n            return $word;\n        }\n\n        $lowercased_word = strtolower($word);\n\n        if (is_array(static::$uncountable)) {\n            foreach (static::$uncountable as $_uncountable) {\n                if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) {\n                    return $word;\n                }\n            }\n        }\n\n        if (is_array(static::$irregular)) {\n            foreach (static::$irregular as $_plural => $_singular) {\n                if (preg_match('/(' . $_plural . ')$/i', $word, $arr)) {\n                    return preg_replace('/(' . $_plural . ')$/i', substr($arr[0], 0, 1) . substr($_singular, 1), $word);\n                }\n            }\n        }\n\n        if (is_array(static::$plural)) {\n            foreach (static::$plural as $rule => $replacement) {\n                if (preg_match($rule, $word)) {\n                    return preg_replace($rule, $replacement, $word);\n                }\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Singularizes English nouns.\n     *\n     * @param    string $word English noun to singularize\n     * @param    int    $count\n     *\n     * @return string Singular noun.\n     */\n    public static function singularize($word, $count = 1)\n    {\n        static::init();\n\n        if ((int)$count !== 1) {\n            return $word;\n        }\n\n        $lowercased_word = strtolower($word);\n\n        if (is_array(static::$uncountable)) {\n            foreach (static::$uncountable as $_uncountable) {\n                if (substr($lowercased_word, -1 * strlen($_uncountable)) === $_uncountable) {\n                    return $word;\n                }\n            }\n        }\n\n        if (is_array(static::$irregular)) {\n            foreach (static::$irregular as $_plural => $_singular) {\n                if (preg_match('/(' . $_singular . ')$/i', $word, $arr)) {\n                    return preg_replace('/(' . $_singular . ')$/i', substr($arr[0], 0, 1) . substr($_plural, 1), $word);\n                }\n            }\n        }\n\n        if (is_array(static::$singular)) {\n            foreach (static::$singular as $rule => $replacement) {\n                if (preg_match($rule, $word)) {\n                    return preg_replace($rule, $replacement, $word);\n                }\n            }\n        }\n\n        return $word;\n    }\n\n    /**\n     * Converts an underscored or CamelCase word into a English\n     * sentence.\n     *\n     * The titleize public function converts text like \"WelcomePage\",\n     * \"welcome_page\" or  \"welcome page\" to this \"Welcome\n     * Page\".\n     * If second parameter is set to 'first' it will only\n     * capitalize the first character of the title.\n     *\n     * @param    string $word      Word to format as tile\n     * @param    string $uppercase If set to 'first' it will only uppercase the\n     *                             first character. Otherwise it will uppercase all\n     *                             the words in the title.\n     *\n     * @return string Text formatted as title\n     */\n    public static function titleize($word, $uppercase = '')\n    {\n        $humanize_underscorize = static::humanize(static::underscorize($word));\n\n        if ($uppercase === 'first') {\n            $firstLetter = mb_strtoupper(mb_substr($humanize_underscorize, 0, 1, \"UTF-8\"), \"UTF-8\");\n            return $firstLetter . mb_substr($humanize_underscorize, 1, mb_strlen($humanize_underscorize, \"UTF-8\"), \"UTF-8\");\n        } else {\n            return mb_convert_case($humanize_underscorize, MB_CASE_TITLE, 'UTF-8');\n        }\n\n    }\n\n    /**\n     * Returns given word as CamelCased\n     *\n     * Converts a word like \"send_email\" to \"SendEmail\". It\n     * will remove non alphanumeric character from the word, so\n     * \"who's online\" will be converted to \"WhoSOnline\"\n     *\n     * @see variablize\n     *\n     * @param  string $word Word to convert to camel case\n     * @return string UpperCamelCasedWord\n     */\n    public static function camelize($word)\n    {\n        return str_replace(' ', '', ucwords(preg_replace('/[^\\p{L}^0-9]+/', ' ', $word)));\n    }\n\n    /**\n     * Converts a word \"into_it_s_underscored_version\"\n     *\n     * Convert any \"CamelCased\" or \"ordinary Word\" into an\n     * \"underscored_word\".\n     *\n     * This can be really useful for creating friendly URLs.\n     *\n     * @param  string $word Word to underscore\n     * @return string Underscored word\n     */\n    public static function underscorize($word)\n    {\n        $regex1 = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\\1_\\2', $word);\n        $regex2 = preg_replace('/([a-zd])([A-Z])/', '\\1_\\2', $regex1);\n        $regex3 = preg_replace('/[^\\p{L}^0-9]+/u', '_', $regex2);\n\n        return strtolower($regex3);\n    }\n\n    /**\n     * Converts a word \"into-it-s-hyphenated-version\"\n     *\n     * Convert any \"CamelCased\" or \"ordinary Word\" into an\n     * \"hyphenated-word\".\n     *\n     * This can be really useful for creating friendly URLs.\n     *\n     * @param  string $word Word to hyphenate\n     * @return string hyphenized word\n     */\n    public static function hyphenize($word)\n    {\n        $regex1 = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\\1-\\2', $word);\n        $regex2 = preg_replace('/([a-z])([A-Z])/', '\\1-\\2', $regex1);\n        $regex3 = preg_replace('/([0-9])([A-Z])/', '\\1-\\2', $regex2);\n        $regex4 = preg_replace('/[^\\p{L}^0-9]+/', '-', $regex3);\n\n        $regex4 = trim($regex4, '-');\n\n        return strtolower($regex4);\n    }\n\n    /**\n     * Returns a human-readable string from $word\n     *\n     * Returns a human-readable string from $word, by replacing\n     * underscores with a space, and by upper-casing the initial\n     * character by default.\n     *\n     * If you need to uppercase all the words you just have to\n     * pass 'all' as a second parameter.\n     *\n     * @param    string $word      String to \"humanize\"\n     * @param    string $uppercase If set to 'all' it will uppercase all the words\n     *                             instead of just the first one.\n     *\n     * @return string Human-readable word\n     */\n    public static function humanize($word, $uppercase = '')\n    {\n        $uppercase = $uppercase === 'all' ? 'ucwords' : 'ucfirst';\n\n        return $uppercase(str_replace('_', ' ', preg_replace('/_id$/', '', $word)));\n    }\n\n    /**\n     * Same as camelize but first char is underscored\n     *\n     * Converts a word like \"send_email\" to \"sendEmail\". It\n     * will remove non alphanumeric character from the word, so\n     * \"who's online\" will be converted to \"whoSOnline\"\n     *\n     * @see camelize\n     *\n     * @param  string $word Word to lowerCamelCase\n     * @return string Returns a lowerCamelCasedWord\n     */\n    public static function variablize($word)\n    {\n        $word = static::camelize($word);\n\n        return strtolower($word[0]) . substr($word, 1);\n    }\n\n    /**\n     * Converts a class name to its table name according to rails\n     * naming conventions.\n     *\n     * Converts \"Person\" to \"people\"\n     *\n     * @see classify\n     *\n     * @param  string $class_name Class name for getting related table_name.\n     * @return string plural_table_name\n     */\n    public static function tableize($class_name)\n    {\n        return static::pluralize(static::underscorize($class_name));\n    }\n\n    /**\n     * Converts a table name to its class name according to rails\n     * naming conventions.\n     *\n     * Converts \"people\" to \"Person\"\n     *\n     * @see tableize\n     *\n     * @param  string $table_name Table name for getting related ClassName.\n     * @return string SingularClassName\n     */\n    public static function classify($table_name)\n    {\n        return static::camelize(static::singularize($table_name));\n    }\n\n    /**\n     * Converts number to its ordinal English form.\n     *\n     * This method converts 13 to 13th, 2 to 2nd ...\n     *\n     * @param  int $number Number to get its ordinal value\n     * @return string Ordinal representation of given string.\n     */\n    public static function ordinalize($number)\n    {\n        static::init();\n\n        if (!is_array(static::$ordinals)) {\n            return (string)$number;\n        }\n\n        if (in_array($number % 100, range(11, 13), true)) {\n            return $number . static::$ordinals['default'];\n        }\n\n        switch ($number % 10) {\n            case 1:\n                return $number . static::$ordinals['first'];\n            case 2:\n                return $number . static::$ordinals['second'];\n            case 3:\n                return $number . static::$ordinals['third'];\n            default:\n                return $number . static::$ordinals['default'];\n        }\n    }\n\n    /**\n     * Converts a number of days to a number of months\n     *\n     * @param int $days\n     * @return int\n     */\n    public static function monthize($days)\n    {\n        $now = new DateTime();\n        $end = new DateTime();\n\n        $duration = new DateInterval(\"P{$days}D\");\n\n        $diff = $end->add($duration)->diff($now);\n\n        // handle years\n        if ($diff->y > 0) {\n            $diff->m += 12 * $diff->y;\n        }\n\n        return $diff->m;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Iterator.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse RocketTheme\\Toolbox\\ArrayTraits\\ArrayAccessWithGetters;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Iterator as ArrayIterator;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Constructor;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Countable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Export;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Serializable;\nuse function array_slice;\nuse function count;\nuse function is_callable;\nuse function is_object;\n\n/**\n * Class Iterator\n * @package Grav\\Common\n */\nclass Iterator implements \\ArrayAccess, \\Iterator, \\Countable, \\Serializable\n{\n    use Constructor, ArrayAccessWithGetters, ArrayIterator, Countable, Serializable, Export;\n\n    /** @var array */\n    protected $items = [];\n\n    /**\n     * Convert function calls for the existing keys into their values.\n     *\n     * @param  string $key\n     * @param  mixed  $args\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function __call($key, $args)\n    {\n        return $this->items[$key] ?? null;\n    }\n\n    /**\n     * Clone the iterator.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __clone()\n    {\n        foreach ($this as $key => $value) {\n            if (is_object($value)) {\n                $this->{$key} = clone $this->{$key};\n            }\n        }\n    }\n\n    /**\n     * Convents iterator to a comma separated list.\n     *\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return implode(',', $this->items);\n    }\n\n    /**\n     * Remove item from the list.\n     *\n     * @param string $key\n     * @return void\n     */\n    public function remove($key)\n    {\n        $this->offsetUnset($key);\n    }\n\n    /**\n     * Return previous item.\n     *\n     * @return mixed\n     */\n    public function prev()\n    {\n        return prev($this->items);\n    }\n\n    /**\n     * Return nth item.\n     *\n     * @param int $key\n     * @return mixed|bool\n     */\n    public function nth($key)\n    {\n        $items = array_keys($this->items);\n\n        return isset($items[$key]) ? $this->offsetGet($items[$key]) : false;\n    }\n\n    /**\n     * Get the first item\n     *\n     * @return mixed\n     */\n    public function first()\n    {\n        $items = array_keys($this->items);\n\n        return $this->offsetGet(array_shift($items));\n    }\n\n    /**\n     * Get the last item\n     *\n     * @return mixed\n     */\n    public function last()\n    {\n        $items = array_keys($this->items);\n\n        return $this->offsetGet(array_pop($items));\n    }\n\n    /**\n     * Reverse the Iterator\n     *\n     * @return $this\n     */\n    public function reverse()\n    {\n        $this->items = array_reverse($this->items);\n\n        return $this;\n    }\n\n    /**\n     * @param mixed $needle Searched value.\n     *\n     * @return string|int|false  Key if found, otherwise false.\n     */\n    public function indexOf($needle)\n    {\n        foreach (array_values($this->items) as $key => $value) {\n            if ($value === $needle) {\n                return $key;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Shuffle items.\n     *\n     * @return $this\n     */\n    public function shuffle()\n    {\n        $keys = array_keys($this->items);\n        shuffle($keys);\n\n        $new = [];\n        foreach ($keys as $key) {\n            $new[$key] = $this->items[$key];\n        }\n\n        $this->items = $new;\n\n        return $this;\n    }\n\n    /**\n     * Slice the list.\n     *\n     * @param int $offset\n     * @param int|null $length\n     * @return $this\n     */\n    public function slice($offset, $length = null)\n    {\n        $this->items = array_slice($this->items, $offset, $length);\n\n        return $this;\n    }\n\n    /**\n     * Pick one or more random entries.\n     *\n     * @param int $num Specifies how many entries should be picked.\n     * @return $this\n     */\n    public function random($num = 1)\n    {\n        $count = count($this->items);\n        if ($num > $count) {\n            $num = $count;\n        }\n\n        $this->items = array_intersect_key($this->items, array_flip((array)array_rand($this->items, $num)));\n\n        return $this;\n    }\n\n    /**\n     * Append new elements to the list.\n     *\n     * @param array|Iterator $items Items to be appended. Existing keys will be overridden with the new values.\n     * @return $this\n     */\n    public function append($items)\n    {\n        if ($items instanceof static) {\n            $items = $items->toArray();\n        }\n        $this->items = array_merge($this->items, (array)$items);\n\n        return $this;\n    }\n\n    /**\n     * Filter elements from the list\n     *\n     * @param  callable|null $callback A function the receives ($value, $key) and must return a boolean to indicate\n     *                                 filter status\n     *\n     * @return $this\n     */\n    public function filter(callable $callback = null)\n    {\n        foreach ($this->items as $key => $value) {\n            if ((!$callback && !(bool)$value) || ($callback && !$callback($value, $key))) {\n                unset($this->items[$key]);\n            }\n        }\n\n        return $this;\n    }\n\n\n    /**\n     * Sorts elements from the list and returns a copy of the list in the proper order\n     *\n     * @param callable|null $callback\n     * @param bool          $desc\n     * @return $this|array\n     *\n     */\n    public function sort(callable $callback = null, $desc = false)\n    {\n        if (!$callback || !is_callable($callback)) {\n            return $this;\n        }\n\n        $items = $this->items;\n        uasort($items, $callback);\n\n        return !$desc ? $items : array_reverse($items, true);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Language/Language.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Language\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Language;\n\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Config\\Config;\nuse Negotiation\\AcceptLanguage;\nuse Negotiation\\LanguageNegotiator;\nuse function array_key_exists;\nuse function count;\nuse function in_array;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class Language\n * @package Grav\\Common\\Language\n */\nclass Language\n{\n    /** @var Grav */\n    protected $grav;\n    /** @var Config */\n    protected $config;\n    /** @var bool */\n    protected $enabled = true;\n    /** @var array */\n    protected $languages = [];\n    /** @var array */\n    protected $fallback_languages = [];\n    /** @var array */\n    protected $fallback_extensions = [];\n    /** @var array */\n    protected $page_extensions = [];\n    /** @var string|false */\n    protected $default;\n    /** @var string|false */\n    protected $active;\n    /** @var array */\n    protected $http_accept_language;\n    /** @var bool */\n    protected $lang_in_url = false;\n\n    /**\n     * Constructor\n     *\n     * @param Grav $grav\n     */\n    public function __construct(Grav $grav)\n    {\n        $this->grav = $grav;\n        $this->config = $grav['config'];\n\n        $languages = $this->config->get('system.languages.supported', []);\n        foreach ($languages as &$language) {\n            $language = (string)$language;\n        }\n        unset($language);\n\n        $this->languages = $languages;\n\n        $this->init();\n    }\n\n    /**\n     * Initialize the default and enabled languages\n     *\n     * @return void\n     */\n    public function init()\n    {\n        $default = $this->config->get('system.languages.default_lang');\n        if (null !== $default) {\n            $default = (string)$default;\n        }\n\n        // Note that reset returns false on empty languages.\n        $this->default = $default ?? reset($this->languages);\n\n        $this->resetFallbackPageExtensions();\n\n        if (empty($this->languages)) {\n            // If no languages are set, turn of multi-language support.\n            $this->enabled = false;\n        } elseif ($default && !in_array($default, $this->languages, true)) {\n            // If default language isn't in the language list, we need to add it.\n            array_unshift($this->languages, $default);\n        }\n    }\n\n    /**\n     * Ensure that languages are enabled\n     *\n     * @return bool\n     */\n    public function enabled()\n    {\n        return $this->enabled;\n    }\n\n    /**\n     * Returns true if language debugging is turned on.\n     *\n     * @return bool\n     */\n    public function isDebug(): bool\n    {\n        return !$this->config->get('system.languages.translations', true);\n    }\n\n    /**\n     * Gets the array of supported languages\n     *\n     * @return array\n     */\n    public function getLanguages()\n    {\n        return $this->languages;\n    }\n\n    /**\n     * Sets the current supported languages manually\n     *\n     * @param array $langs\n     * @return void\n     */\n    public function setLanguages($langs)\n    {\n        $this->languages = $langs;\n\n        $this->init();\n    }\n\n    /**\n     * Gets a pipe-separated string of available languages\n     *\n     * @param string|null $delimiter Delimiter to be quoted.\n     * @return string\n     */\n    public function getAvailable($delimiter = null)\n    {\n        $languagesArray = $this->languages; //Make local copy\n\n        $languagesArray = array_map(static function ($value) use ($delimiter) {\n            return preg_quote($value, $delimiter);\n        }, $languagesArray);\n\n        sort($languagesArray);\n\n        return implode('|', array_reverse($languagesArray));\n    }\n\n    /**\n     * Gets language, active if set, else default\n     *\n     * @return string|false\n     */\n    public function getLanguage()\n    {\n        return $this->active ?: $this->default;\n    }\n\n    /**\n     * Gets current default language\n     *\n     * @return string|false\n     */\n    public function getDefault()\n    {\n        return $this->default;\n    }\n\n    /**\n     * Sets default language manually\n     *\n     * @param string $lang\n     * @return string|bool\n     */\n    public function setDefault($lang)\n    {\n        $lang = (string)$lang;\n        if ($this->validate($lang)) {\n            $this->default = $lang;\n\n            return $lang;\n        }\n\n        return false;\n    }\n\n    /**\n     * Gets current active language\n     *\n     * @return string|false\n     */\n    public function getActive()\n    {\n        return $this->active;\n    }\n\n    /**\n     * Sets active language manually\n     *\n     * @param string|false $lang\n     * @return string|false\n     */\n    public function setActive($lang)\n    {\n        $lang = (string)$lang;\n        if ($this->validate($lang)) {\n            /** @var Debugger $debugger */\n            $debugger = $this->grav['debugger'];\n            $debugger->addMessage('Active language set to ' . $lang, 'debug');\n\n            $this->active = $lang;\n\n            return $lang;\n        }\n\n        return false;\n    }\n\n    /**\n     * Sets the active language based on the first part of the URL\n     *\n     * @param string $uri\n     * @return string\n     */\n    public function setActiveFromUri($uri)\n    {\n        $regex = '/(^\\/(' . $this->getAvailable() . '))(?:\\/|\\?|$)/i';\n\n        // if languages set\n        if ($this->enabled()) {\n            // Check for explicit language override via ?lang= query parameter.\n            // This allows switching to any language (including the default language\n            // when include_default_lang is false and the URL has no language prefix).\n            $requestedLang = $_GET['lang'] ?? null;\n            if ($requestedLang) {\n                $requestedLang = strtolower($requestedLang);\n                if (in_array($requestedLang, $this->languages, true)) {\n                    $this->setActive($requestedLang);\n\n                    // Store in session.\n                    if (isset($this->grav['session']) && $this->grav['session']->isStarted()\n                        && $this->config->get('system.languages.session_store_active', true)\n                    ) {\n                        $this->grav['session']->active_language = $this->active;\n                    }\n\n                    return $uri;\n                }\n            }\n\n            // Try setting language from prefix of URL (/en/blah/blah).\n            if (preg_match($regex, $uri, $matches)) {\n                $this->lang_in_url = true;\n                $this->setActive($matches[2]);\n                $uri = preg_replace(\"/\\\\\" . $matches[1] . '/', '', $uri, 1);\n\n                // Store in session if language is different.\n                if (isset($this->grav['session']) && $this->grav['session']->isStarted()\n                    && $this->config->get('system.languages.session_store_active', true)\n                    && $this->grav['session']->active_language != $this->active\n                ) {\n                    $this->grav['session']->active_language = $this->active;\n                }\n            } else {\n                // Try getting language from the session, else no active.\n                if (isset($this->grav['session']) && $this->grav['session']->isStarted() &&\n                    $this->config->get('system.languages.session_store_active', true)) {\n                    $this->setActive($this->grav['session']->active_language ?: null);\n                }\n                // if still null, try from http_accept_language header\n                if ($this->active === null &&\n                    $this->config->get('system.languages.http_accept_language') &&\n                    $accept = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? false) {\n                    $negotiator = new LanguageNegotiator();\n                    $best_language = $negotiator->getBest($accept, $this->languages);\n\n                    if ($best_language instanceof AcceptLanguage) {\n                        $this->setActive($best_language->getType());\n                    } else {\n                        $this->setActive($this->getDefault());\n                    }\n                }\n            }\n        }\n\n        return $uri;\n    }\n\n    /**\n     * Get a URL prefix based on configuration\n     *\n     * @param string|null $lang\n     * @return string\n     */\n    public function getLanguageURLPrefix($lang = null)\n    {\n        if (!$this->enabled()) {\n            return '';\n        }\n\n        // if active lang is not passed in, use current active\n        if (!$lang) {\n            $lang = $this->getLanguage();\n        }\n\n        return $this->isIncludeDefaultLanguage($lang) ? '/' . $lang : '';\n    }\n\n    /**\n     * Test to see if language is default and language should be included in the URL\n     *\n     * @param string|null $lang\n     * @return bool\n     */\n    public function isIncludeDefaultLanguage($lang = null)\n    {\n        if (!$this->enabled()) {\n            return false;\n        }\n\n        // if active lang is not passed in, use current active\n        if (!$lang) {\n            $lang = $this->getLanguage();\n        }\n\n        return !($this->default === $lang && $this->config->get('system.languages.include_default_lang') === false);\n    }\n\n    /**\n     * Simple getter to tell if a language was found in the URL\n     *\n     * @return bool\n     */\n    public function isLanguageInUrl()\n    {\n        return (bool) $this->lang_in_url;\n    }\n\n    /**\n     * Get full list of used language page extensions: [''=>'.md', 'en'=>'.en.md', ...]\n     *\n     * @param string|null $fileExtension\n     * @return array\n     */\n    public function getPageExtensions($fileExtension = null)\n    {\n        $fileExtension = $fileExtension ?: CONTENT_EXT;\n\n        if (!isset($this->fallback_extensions[$fileExtension])) {\n            $extensions[''] = $fileExtension;\n            foreach ($this->languages as $code) {\n                $extensions[$code] = \".{$code}{$fileExtension}\";\n            }\n\n            $this->fallback_extensions[$fileExtension] = $extensions;\n        }\n\n        return $this->fallback_extensions[$fileExtension];\n    }\n\n    /**\n     * Gets an array of valid extensions with active first, then fallback extensions\n     *\n     * @param string|null $fileExtension\n     * @param string|null $languageCode\n     * @param bool $assoc  Return values in ['en' => '.en.md', ...] format.\n     * @return array Key is the language code, value is the file extension to be used.\n     */\n    public function getFallbackPageExtensions(string $fileExtension = null, string $languageCode = null, bool $assoc = false)\n    {\n        $fileExtension = $fileExtension ?: CONTENT_EXT;\n        $key = $fileExtension . '-' . ($languageCode ?? 'default') . '-' . (int)$assoc;\n\n        if (!isset($this->fallback_extensions[$key])) {\n            $all = $this->getPageExtensions($fileExtension);\n            $list = [];\n            $fallback = $this->getFallbackLanguages($languageCode, true);\n            foreach ($fallback as $code) {\n                $ext = $all[$code] ?? null;\n                if (null !== $ext) {\n                    $list[$code] = $ext;\n                }\n            }\n            if (!$assoc) {\n                $list = array_values($list);\n            }\n\n            $this->fallback_extensions[$key] = $list;\n        }\n\n        return $this->fallback_extensions[$key];\n    }\n\n    /**\n     * Resets the fallback_languages value.\n     *\n     * Useful to re-initialize the pages and change site language at runtime, example:\n     *\n     * ```\n     * $this->grav['language']->setActive('it');\n     * $this->grav['language']->resetFallbackPageExtensions();\n     * $this->grav['pages']->init();\n     * ```\n     *\n     * @return void\n     */\n    public function resetFallbackPageExtensions()\n    {\n        $this->fallback_languages = [];\n        $this->fallback_extensions = [];\n        $this->page_extensions = [];\n    }\n\n    /**\n     * Gets an array of languages with active first, then fallback languages.\n     *\n     *\n     * @param string|null  $languageCode\n     * @param bool $includeDefault  If true, list contains '', which can be used for default\n     * @return array\n     */\n    public function getFallbackLanguages(string $languageCode = null, bool $includeDefault = false)\n    {\n        // Handle default.\n        if ($languageCode === '' || !$this->enabled()) {\n            return [''];\n        }\n\n        $default = $this->getDefault() ?? 'en';\n        $active = $languageCode ?? $this->getActive() ?? $default;\n        $key = $active . '-' . (int)$includeDefault;\n\n        if (!isset($this->fallback_languages[$key])) {\n            $fallback = $this->config->get('system.languages.content_fallback.' . $active);\n            $fallback_languages = [];\n\n            if (null === $fallback && $this->config->get('system.languages.pages_fallback_only', false)) {\n                user_error('Configuration option `system.languages.pages_fallback_only` is deprecated since Grav 1.7, use `system.languages.content_fallback` instead', E_USER_DEPRECATED);\n\n                // Special fallback list returns itself and all the previous items in reverse order:\n                // active: 'v2', languages: ['v1', 'v2', 'v3', 'v4'] => ['v2', 'v1', '']\n                if ($includeDefault) {\n                    $fallback_languages[''] = '';\n                }\n                foreach ($this->languages as $code) {\n                    $fallback_languages[$code] = $code;\n                    if ($code === $active) {\n                        break;\n                    }\n                }\n                $fallback_languages = array_reverse($fallback_languages);\n            } else {\n                if (null === $fallback) {\n                    $fallback = [$default];\n                } elseif (!is_array($fallback)) {\n                    $fallback = is_string($fallback) && $fallback !== '' ? explode(',', $fallback) : [];\n                }\n                array_unshift($fallback, $active);\n                $fallback = array_unique($fallback);\n\n                foreach ($fallback as $code) {\n                    // Default fallback list has active language followed by default language and extensionless file:\n                    // active: 'fi', default: 'en', languages: ['sv', 'en', 'de', 'fi'] => ['fi', 'en', '']\n                    $fallback_languages[$code] = $code;\n                    if ($includeDefault && $code === $default) {\n                        $fallback_languages[''] = '';\n                    }\n                }\n            }\n\n            $fallback_languages = array_values($fallback_languages);\n\n            $this->fallback_languages[$key] = $fallback_languages;\n        }\n\n        return $this->fallback_languages[$key];\n    }\n\n    /**\n     * Ensures the language is valid and supported\n     *\n     * @param string $lang\n     * @return bool\n     */\n    public function validate($lang)\n    {\n        return in_array($lang, $this->languages, true);\n    }\n\n    /**\n     * Translate a key and possibly arguments into a string using current lang and fallbacks\n     *\n     * @param string|array $args      The first argument is the lookup key value\n     *                         Other arguments can be passed and replaced in the translation with sprintf syntax\n     * @param array|null $languages\n     * @param bool  $array_support\n     * @param bool  $html_out\n     * @return string|string[]\n     */\n    public function translate($args, array $languages = null, $array_support = false, $html_out = false)\n    {\n        if (is_array($args)) {\n            $lookup = array_shift($args);\n        } else {\n            $lookup = $args;\n            $args = [];\n        }\n\n        if (!$this->isDebug()) {\n            if ($lookup && $this->enabled() && empty($languages)) {\n                $languages = $this->getTranslatedLanguages();\n            }\n\n            $languages = $languages ?: ['en'];\n\n            foreach ((array)$languages as $lang) {\n                $translation = $this->getTranslation($lang, $lookup, $array_support);\n\n                if ($translation) {\n                    if (is_string($translation) && count($args) >= 1) {\n                        return vsprintf($translation, $args);\n                    }\n\n                    return $translation;\n                }\n            }\n        } elseif ($array_support) {\n            return [$lookup];\n        }\n\n        if ($html_out) {\n            return '<span class=\"untranslated\">' . $lookup . '</span>';\n        }\n\n        return $lookup;\n    }\n\n    /**\n     * Translate Array\n     *\n     * @param string $key\n     * @param string $index\n     * @param array|null $languages\n     * @param bool $html_out\n     * @return string\n     */\n    public function translateArray($key, $index, $languages = null, $html_out = false)\n    {\n        if ($this->isDebug()) {\n            return $key . '[' . $index . ']';\n        }\n\n        if ($key && empty($languages) && $this->enabled()) {\n            $languages = $this->getTranslatedLanguages();\n        }\n\n        $languages = $languages ?: ['en'];\n\n        foreach ((array)$languages as $lang) {\n            $translation_array = (array)Grav::instance()['languages']->get($lang . '.' . $key, null);\n            if ($translation_array && array_key_exists($index, $translation_array)) {\n                return $translation_array[$index];\n            }\n        }\n\n        if ($html_out) {\n            return '<span class=\"untranslated\">' . $key . '[' . $index . ']</span>';\n        }\n\n        return $key . '[' . $index . ']';\n    }\n\n    /**\n     * Lookup the translation text for a given lang and key\n     *\n     * @param string $lang lang code\n     * @param string $key  key to lookup with\n     * @param bool $array_support\n     * @return string|string[]\n     */\n    public function getTranslation($lang, $key, $array_support = false)\n    {\n        if ($this->isDebug()) {\n            return $key;\n        }\n\n        $translation = Grav::instance()['languages']->get($lang . '.' . $key, null);\n        if (!$array_support && is_array($translation)) {\n            return (string)array_shift($translation);\n        }\n\n        return $translation;\n    }\n\n    /**\n     * Get the browser accepted languages\n     *\n     * @param array $accept_langs\n     * @return array\n     * @deprecated 1.6 No longer used - using content negotiation.\n     */\n    public function getBrowserLanguages($accept_langs = [])\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, no longer used', E_USER_DEPRECATED);\n\n        if (empty($this->http_accept_language)) {\n            if (empty($accept_langs) && isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {\n                $accept_langs = $_SERVER['HTTP_ACCEPT_LANGUAGE'];\n            } else {\n                return $accept_langs;\n            }\n\n            $langs = [];\n\n            foreach (explode(',', $accept_langs) as $k => $pref) {\n                // split $pref again by ';q='\n                // and decorate the language entries by inverted position\n                if (false !== ($i = strpos($pref, ';q='))) {\n                    $langs[substr($pref, 0, $i)] = [(float)substr($pref, $i + 3), -$k];\n                } else {\n                    $langs[$pref] = [1, -$k];\n                }\n            }\n            arsort($langs);\n\n            // no need to undecorate, because we're only interested in the keys\n            $this->http_accept_language = array_keys($langs);\n        }\n        return $this->http_accept_language;\n    }\n\n    /**\n     * Accessible wrapper to LanguageCodes\n     *\n     * @param string $code\n     * @param string $type\n     * @return string|false\n     */\n    public function getLanguageCode($code, $type = 'name')\n    {\n        return LanguageCodes::get($code, $type);\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function __debugInfo()\n    {\n        $vars = get_object_vars($this);\n        unset($vars['grav'], $vars['config']);\n\n        return $vars;\n    }\n\n    /**\n     * @return array\n     */\n    protected function getTranslatedLanguages(): array\n    {\n        if ($this->config->get('system.languages.translations_fallback', true)) {\n            $languages = $this->getFallbackLanguages();\n        } else {\n            $languages = [$this->getLanguage()];\n        }\n\n        $languages[] = 'en';\n\n        return array_values(array_unique($languages));\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Language/LanguageCodes.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Language\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Language;\n\n/**\n * Class LanguageCodes\n * @package Grav\\Common\\Language\n */\nclass LanguageCodes\n{\n    /** @var array */\n    protected static $codes = [\n        'af'         => [ 'name' => 'Afrikaans',                 'nativeName' => 'Afrikaans' ],\n        'ak'         => [ 'name' => 'Akan',                      'nativeName' => 'Akan' ], // unverified native name\n        'ast'        => [ 'name' => 'Asturian',                  'nativeName' => 'Asturianu' ],\n        'ar'         => [ 'name' => 'Arabic',                    'nativeName' => 'عربي', 'orientation' => 'rtl'],\n        'as'         => [ 'name' => 'Assamese',                  'nativeName' => 'অসমীয়া' ],\n        'be'         => [ 'name' => 'Belarusian',                'nativeName' => 'Беларуская' ],\n        'bg'         => [ 'name' => 'Bulgarian',                 'nativeName' => 'Български' ],\n        'bn'         => [ 'name' => 'Bengali',                   'nativeName' => 'বাংলা' ],\n        'bn-BD'      => [ 'name' => 'Bengali (Bangladesh)',      'nativeName' => 'বাংলা (বাংলাদেশ)' ],\n        'bn-IN'      => [ 'name' => 'Bengali (India)',           'nativeName' => 'বাংলা (ভারত)' ],\n        'br'         => [ 'name' => 'Breton',                    'nativeName' => 'Brezhoneg' ],\n        'bs'         => [ 'name' => 'Bosnian',                   'nativeName' => 'Bosanski' ],\n        'ca'         => [ 'name' => 'Catalan',                   'nativeName' => 'Català' ],\n        'ca-valencia'=> [ 'name' => 'Catalan (Valencian)',       'nativeName' => 'Català (valencià)' ], // not iso-639-1. a=l10n-drivers\n        'cs'         => [ 'name' => 'Czech',                     'nativeName' => 'Čeština' ],\n        'cy'         => [ 'name' => 'Welsh',                     'nativeName' => 'Cymraeg' ],\n        'da'         => [ 'name' => 'Danish',                    'nativeName' => 'Dansk' ],\n        'de'         => [ 'name' => 'German',                    'nativeName' => 'Deutsch' ],\n        'de-AT'      => [ 'name' => 'German (Austria)',          'nativeName' => 'Deutsch (Österreich)' ],\n        'de-CH'      => [ 'name' => 'German (Switzerland)',      'nativeName' => 'Deutsch (Schweiz)' ],\n        'de-DE'      => [ 'name' => 'German (Germany)',          'nativeName' => 'Deutsch (Deutschland)' ],\n        'dsb'        => [ 'name' => 'Lower Sorbian',             'nativeName' => 'Dolnoserbšćina' ], // iso-639-2\n        'el'         => [ 'name' => 'Greek',                     'nativeName' => 'Ελληνικά' ],\n        'en'         => [ 'name' => 'English',                   'nativeName' => 'English' ],\n        'en-AU'      => [ 'name' => 'English (Australian)',      'nativeName' => 'English (Australian)' ],\n        'en-CA'      => [ 'name' => 'English (Canadian)',        'nativeName' => 'English (Canadian)' ],\n        'en-GB'      => [ 'name' => 'English (British)',         'nativeName' => 'English (British)' ],\n        'en-NZ'      => [ 'name' => 'English (New Zealand)',     'nativeName' => 'English (New Zealand)' ],\n        'en-US'      => [ 'name' => 'English (US)',              'nativeName' => 'English (US)' ],\n        'en-ZA'      => [ 'name' => 'English (South African)',   'nativeName' => 'English (South African)' ],\n        'eo'         => [ 'name' => 'Esperanto',                 'nativeName' => 'Esperanto' ],\n        'es'         => [ 'name' => 'Spanish',                   'nativeName' => 'Español' ],\n        'es-AR'      => [ 'name' => 'Spanish (Argentina)',       'nativeName' => 'Español (de Argentina)' ],\n        'es-CL'      => [ 'name' => 'Spanish (Chile)',           'nativeName' => 'Español (de Chile)' ],\n        'es-ES'      => [ 'name' => 'Spanish (Spain)',           'nativeName' => 'Español (de España)' ],\n        'es-MX'      => [ 'name' => 'Spanish (Mexico)',          'nativeName' => 'Español (de México)' ],\n        'et'         => [ 'name' => 'Estonian',                  'nativeName' => 'Eesti keel' ],\n        'eu'         => [ 'name' => 'Basque',                    'nativeName' => 'Euskara' ],\n        'fa'         => [ 'name' => 'Persian',                   'nativeName' => 'فارسی' , 'orientation' => 'rtl' ],\n        'fi'         => [ 'name' => 'Finnish',                   'nativeName' => 'Suomi' ],\n        'fj-FJ'      => [ 'name' => 'Fijian',                    'nativeName' => 'Vosa vaka-Viti' ],\n        'fr'         => [ 'name' => 'French',                    'nativeName' => 'Français' ],\n        'fr-CA'      => [ 'name' => 'French (Canada)',           'nativeName' => 'Français (Canada)' ],\n        'fr-FR'      => [ 'name' => 'French (France)',           'nativeName' => 'Français (France)' ],\n        'fur'        => [ 'name' => 'Friulian',                  'nativeName' => 'Furlan' ],\n        'fur-IT'     => [ 'name' => 'Friulian',                  'nativeName' => 'Furlan' ],\n        'fy'         => [ 'name' => 'Frisian',                   'nativeName' => 'Frysk' ],\n        'fy-NL'      => [ 'name' => 'Frisian',                   'nativeName' => 'Frysk' ],\n        'ga'         => [ 'name' => 'Irish',                     'nativeName' => 'Gaeilge' ],\n        'ga-IE'      => [ 'name' => 'Irish (Ireland)',           'nativeName' => 'Gaeilge (Éire)' ],\n        'gd'         => [ 'name' => 'Gaelic (Scotland)',         'nativeName' => 'Gàidhlig' ],\n        'gl'         => [ 'name' => 'Galician',                  'nativeName' => 'Galego' ],\n        'gu'         => [ 'name' => 'Gujarati',                  'nativeName' => 'ગુજરાતી' ],\n        'gu-IN'      => [ 'name' => 'Gujarati',                  'nativeName' => 'ગુજરાતી' ],\n        'he'         => [ 'name' => 'Hebrew',                    'nativeName' => 'עברית', 'orientation' => 'rtl' ],\n        'hi'         => [ 'name' => 'Hindi',                     'nativeName' => 'हिन्दी' ],\n        'hi-IN'      => [ 'name' => 'Hindi (India)',             'nativeName' => 'हिन्दी (भारत)' ],\n        'hr'         => [ 'name' => 'Croatian',                  'nativeName' => 'Hrvatski' ],\n        'hsb'        => [ 'name' => 'Upper Sorbian',             'nativeName' => 'Hornjoserbsce' ],\n        'hu'         => [ 'name' => 'Hungarian',                 'nativeName' => 'Magyar' ],\n        'hy'         => [ 'name' => 'Armenian',                  'nativeName' => 'Հայերեն' ],\n        'hy-AM'      => [ 'name' => 'Armenian',                  'nativeName' => 'Հայերեն' ],\n        'id'         => [ 'name' => 'Indonesian',                'nativeName' => 'Bahasa Indonesia' ],\n        'is'         => [ 'name' => 'Icelandic',                 'nativeName' => 'íslenska' ],\n        'it'         => [ 'name' => 'Italian',                   'nativeName' => 'Italiano' ],\n        'ja'         => [ 'name' => 'Japanese',                  'nativeName' => '日本語' ],\n        'ja-JP'      => [ 'name' => 'Japanese',                  'nativeName' => '日本語' ], // not iso-639-1\n        'ka'         => [ 'name' => 'Georgian',                  'nativeName' => 'ქართული' ],\n        'kk'         => [ 'name' => 'Kazakh',                    'nativeName' => 'Қазақ' ],\n        'km'         => [ 'name' => 'Khmer',                     'nativeName' => 'Khmer' ],\n        'kn'         => [ 'name' => 'Kannada',                   'nativeName' => 'ಕನ್ನಡ' ],\n        'ko'         => [ 'name' => 'Korean',                    'nativeName' => '한국어' ],\n        'ku'         => [ 'name' => 'Kurdish',                   'nativeName' => 'Kurdî' ],\n        'la'         => [ 'name' => 'Latin',                     'nativeName' => 'Latina' ],\n        'lb'         => [ 'name' => 'Luxembourgish',             'nativeName' => 'Lëtzebuergesch' ],\n        'lg'         => [ 'name' => 'Luganda',                   'nativeName' => 'Luganda' ],\n        'lo'         => [ 'name' => 'Lao',                       'nativeName' => 'Lao' ],\n        'lt'         => [ 'name' => 'Lithuanian',                'nativeName' => 'Lietuvių' ],\n        'lv'         => [ 'name' => 'Latvian',                   'nativeName' => 'Latviešu' ],\n        'mai'        => [ 'name' => 'Maithili',                  'nativeName' => 'मैथिली মৈথিলী' ],\n        'mg'         => [ 'name' => 'Malagasy',                  'nativeName' => 'Malagasy' ],\n        'mi'         => [ 'name' => 'Maori (Aotearoa)',          'nativeName' => 'Māori (Aotearoa)' ],\n        'mk'         => [ 'name' => 'Macedonian',                'nativeName' => 'Македонски' ],\n        'ml'         => [ 'name' => 'Malayalam',                 'nativeName' => 'മലയാളം' ],\n        'mn'         => [ 'name' => 'Mongolian',                 'nativeName' => 'Монгол' ],\n        'mr'         => [ 'name' => 'Marathi',                   'nativeName' => 'मराठी' ],\n        'my'         => [ 'name' => 'Myanmar (Burmese)',         'nativeName' => 'ဗမာी' ],\n        'no'         => [ 'name' => 'Norwegian',                 'nativeName' => 'Norsk' ],\n        'nb'         => [ 'name' => 'Norwegian',                 'nativeName' => 'Norsk' ],\n        'nb-NO'      => [ 'name' => 'Norwegian (Bokmål)',        'nativeName' => 'Norsk bokmål' ],\n        'ne-NP'      => [ 'name' => 'Nepali',                    'nativeName' => 'नेपाली' ],\n        'nn-NO'      => [ 'name' => 'Norwegian (Nynorsk)',       'nativeName' => 'Norsk nynorsk' ],\n        'nl'         => [ 'name' => 'Dutch',                     'nativeName' => 'Nederlands' ],\n        'nr'         => [ 'name' => 'Ndebele, South',            'nativeName' => 'IsiNdebele' ],\n        'nso'        => [ 'name' => 'Northern Sotho',            'nativeName' => 'Sepedi' ],\n        'oc'         => [ 'name' => 'Occitan (Lengadocian)',     'nativeName' => 'Occitan (lengadocian)' ],\n        'or'         => [ 'name' => 'Oriya',                     'nativeName' => 'ଓଡ଼ିଆ' ],\n        'pa'         => [ 'name' => 'Punjabi',                   'nativeName' => 'ਪੰਜਾਬੀ' ],\n        'pa-IN'      => [ 'name' => 'Punjabi',                   'nativeName' => 'ਪੰਜਾਬੀ' ],\n        'pl'         => [ 'name' => 'Polish',                    'nativeName' => 'Polski' ],\n        'pt'         => [ 'name' => 'Portuguese',                'nativeName' => 'Português' ],\n        'pt-BR'      => [ 'name' => 'Portuguese (Brazilian)',    'nativeName' => 'Português (do Brasil)' ],\n        'pt-PT'      => [ 'name' => 'Portuguese (Portugal)',     'nativeName' => 'Português (Europeu)' ],\n        'ro'         => [ 'name' => 'Romanian',                  'nativeName' => 'Română' ],\n        'rm'         => [ 'name' => 'Romansh',                   'nativeName' => 'Rumantsch' ],\n        'ru'         => [ 'name' => 'Russian',                   'nativeName' => 'Русский' ],\n        'rw'         => [ 'name' => 'Kinyarwanda',               'nativeName' => 'Ikinyarwanda' ],\n        'si'         => [ 'name' => 'Sinhala',                   'nativeName' => 'සිංහල' ],\n        'sk'         => [ 'name' => 'Slovak',                    'nativeName' => 'Slovenčina' ],\n        'sl'         => [ 'name' => 'Slovenian',                 'nativeName' => 'Slovensko' ],\n        'son'        => [ 'name' => 'Songhai',                   'nativeName' => 'Soŋay' ],\n        'sq'         => [ 'name' => 'Albanian',                  'nativeName' => 'Shqip' ],\n        'sr'         => [ 'name' => 'Serbian',                   'nativeName' => 'Српски' ],\n        'sr-Latn'    => [ 'name' => 'Serbian',                   'nativeName' => 'Srpski' ], // follows RFC 4646\n        'ss'         => [ 'name' => 'Siswati',                   'nativeName' => 'siSwati' ],\n        'st'         => [ 'name' => 'Southern Sotho',            'nativeName' => 'Sesotho' ],\n        'sv'         => [ 'name' => 'Swedish',                   'nativeName' => 'Svenska' ],\n        'sv-SE'      => [ 'name' => 'Swedish',                   'nativeName' => 'Svenska' ],\n        'sw'         => [ 'name' => 'Swahili',                   'nativeName' => 'Swahili' ],\n        'ta'         => [ 'name' => 'Tamil',                     'nativeName' => 'தமிழ்' ],\n        'ta-IN'      => [ 'name' => 'Tamil (India)',             'nativeName' => 'தமிழ் (இந்தியா)' ],\n        'ta-LK'      => [ 'name' => 'Tamil (Sri Lanka)',         'nativeName' => 'தமிழ் (இலங்கை)' ],\n        'te'         => [ 'name' => 'Telugu',                    'nativeName' => 'తెలుగు' ],\n        'th'         => [ 'name' => 'Thai',                      'nativeName' => 'ไทย' ],\n        'tlh'        => [ 'name' => 'Klingon',                   'nativeName' => 'Klingon' ],\n        'tn'         => [ 'name' => 'Tswana',                    'nativeName' => 'Setswana' ],\n        'tr'         => [ 'name' => 'Turkish',                   'nativeName' => 'Türkçe' ],\n        'ts'         => [ 'name' => 'Tsonga',                    'nativeName' => 'Xitsonga' ],\n        'tt'         => [ 'name' => 'Tatar',                     'nativeName' => 'Tatarça' ],\n        'tt-RU'      => [ 'name' => 'Tatar',                     'nativeName' => 'Tatarça' ],\n        'uk'         => [ 'name' => 'Ukrainian',                 'nativeName' => 'Українська' ],\n        'ur'         => [ 'name' => 'Urdu',                      'nativeName' => 'اُردو', 'orientation' => 'rtl'  ],\n        've'         => [ 'name' => 'Venda',                     'nativeName' => 'Tshivenḓa' ],\n        'vi'         => [ 'name' => 'Vietnamese',                'nativeName' => 'Tiếng Việt' ],\n        'wo'         => [ 'name' => 'Wolof',                     'nativeName' => 'Wolof' ],\n        'xh'         => [ 'name' => 'Xhosa',                     'nativeName' => 'isiXhosa' ],\n        'yi'         => [ 'name' => 'Yiddish',                   'nativeName' => 'ייִדיש', 'orientation' => 'rtl'  ],\n        'ydd'        => [ 'name' => 'Yiddish',                   'nativeName' => 'ייִדיש', 'orientation' => 'rtl'  ],\n        'zh'         => [ 'name' => 'Chinese (Simplified)',      'nativeName' => '中文 (简体)' ],\n        'zh-CN'      => [ 'name' => 'Chinese (Simplified)',      'nativeName' => '中文 (简体)' ],\n        'zh-TW'      => [ 'name' => 'Chinese (Traditional)',     'nativeName' => '正體中文 (繁體)' ],\n        'zu'         => [ 'name' => 'Zulu',                      'nativeName' => 'isiZulu' ]\n    ];\n\n    /**\n     * @param string $code\n     * @return string|false\n     */\n    public static function getName($code)\n    {\n        return static::get($code, 'name');\n    }\n\n    /**\n     * @param string $code\n     * @return string|false\n     */\n    public static function getNativeName($code)\n    {\n        if (isset(static::$codes[$code])) {\n            return static::get($code, 'nativeName');\n        }\n\n        if (preg_match('/[a-zA-Z]{2}-[a-zA-Z]{2}/', $code)) {\n            return static::get(substr($code, 0, 2), 'nativeName') . ' (' . substr($code, -2) . ')';\n        }\n\n        return $code;\n    }\n\n    /**\n     * @param string $code\n     * @return string\n     */\n    public static function getOrientation($code)\n    {\n        return static::$codes[$code]['orientation'] ?? 'ltr';\n    }\n\n    /**\n     * @param string $code\n     * @return bool\n     */\n    public static function isRtl($code)\n    {\n        return static::getOrientation($code) === 'rtl';\n    }\n\n    /**\n     * @param array $keys\n     * @return array\n     */\n    public static function getNames(array $keys)\n    {\n        $results = [];\n        foreach ($keys as $key) {\n            if (isset(static::$codes[$key])) {\n                $results[$key] = static::$codes[$key];\n            }\n        }\n        return $results;\n    }\n\n    /**\n     * @param string $code\n     * @param string $type\n     * @return string|false\n     */\n    public static function get($code, $type)\n    {\n        return static::$codes[$code][$type] ?? false;\n    }\n\n    /**\n     * @param bool $native\n     * @return array\n     */\n    public static function getList($native = true)\n    {\n        $list = [];\n        foreach (static::$codes as $key => $names) {\n            $list[$key] = $native ? $names['nativeName'] : $names['name'];\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Markdown/Parsedown.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Markdown\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Markdown;\n\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Markdown\\Excerpts;\n\n/**\n * Class Parsedown\n * @package Grav\\Common\\Markdown\n */\nclass Parsedown extends \\Parsedown\n{\n\n    use ParsedownGravTrait;\n\n    /**\n     * Parsedown constructor.\n     *\n     * @param Excerpts|PageInterface|null $excerpts\n     * @param array|null $defaults\n     */\n    public function __construct($excerpts = null, $defaults = null)\n    {\n        if (!$excerpts || $excerpts instanceof PageInterface || null !== $defaults) {\n            // Deprecated in Grav 1.6.10\n            if ($defaults) {\n                $defaults = ['markdown' => $defaults];\n            }\n            $excerpts = new Excerpts($excerpts, $defaults);\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\\'markdown\\' => $defaults])) instead.', E_USER_DEPRECATED);\n        }\n\n        $this->init($excerpts, $defaults);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Markdown/ParsedownExtra.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Markdown\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Markdown;\n\nuse Exception;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Markdown\\Excerpts;\n\n/**\n * Class ParsedownExtra\n * @package Grav\\Common\\Markdown\n */\nclass ParsedownExtra extends \\ParsedownExtra\n{\n    use ParsedownGravTrait;\n\n    /**\n     * ParsedownExtra constructor.\n     *\n     * @param Excerpts|PageInterface|null $excerpts\n     * @param array|null $defaults\n     * @throws Exception\n     */\n    public function __construct($excerpts = null, $defaults = null)\n    {\n        if (!$excerpts || $excerpts instanceof PageInterface || null !== $defaults) {\n            // Deprecated in Grav 1.6.10\n            if ($defaults) {\n                $defaults = ['markdown' => $defaults];\n            }\n            $excerpts = new Excerpts($excerpts, $defaults);\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\\'markdown\\' => $defaults])) instead.', E_USER_DEPRECATED);\n        }\n\n        parent::__construct();\n\n        $this->init($excerpts, $defaults);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Markdown/ParsedownGravTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Markdown\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Markdown;\n\nuse Grav\\Common\\Page\\Markdown\\Excerpts;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse function call_user_func_array;\nuse function in_array;\nuse function strlen;\n\n/**\n * Trait ParsedownGravTrait\n * @package Grav\\Common\\Markdown\n */\ntrait ParsedownGravTrait\n{\n    /** @var array */\n    public $completable_blocks = [];\n    /** @var array */\n    public $continuable_blocks = [];\n    public $plugins = [];\n\n    /** @var Excerpts */\n    protected $excerpts;\n    /** @var array */\n    protected $special_chars;\n    /** @var string */\n    protected $twig_link_regex = '/\\!*\\[(?:.*)\\]\\((\\{([\\{%#])\\s*(.*?)\\s*(?:\\2|\\})\\})\\)/';\n\n    /**\n     * Initialization function to setup key variables needed by the MarkdownGravLinkTrait\n     *\n     * @param PageInterface|Excerpts|null $excerpts\n     * @param array|null $defaults\n     * @return void\n     */\n    protected function init($excerpts = null, $defaults = null)\n    {\n        if (!$excerpts || $excerpts instanceof PageInterface) {\n            // Deprecated in Grav 1.6.10\n            if ($defaults) {\n                $defaults = ['markdown' => $defaults];\n            }\n            $this->excerpts = new Excerpts($excerpts, $defaults);\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use ->init(new ' . Excerpts::class . '($page, [\\'markdown\\' => $defaults])) instead.', E_USER_DEPRECATED);\n        } else {\n            $this->excerpts = $excerpts;\n        }\n\n        $this->BlockTypes['{'][] = 'TwigTag';\n        $this->special_chars = ['>' => 'gt', '<' => 'lt', '\"' => 'quot'];\n\n        $defaults = $this->excerpts->getConfig();\n\n        if (isset($defaults['markdown']['auto_line_breaks'])) {\n            $this->setBreaksEnabled($defaults['markdown']['auto_line_breaks']);\n        }\n        if (isset($defaults['markdown']['auto_url_links'])) {\n            $this->setUrlsLinked($defaults['markdown']['auto_url_links']);\n        }\n        if (isset($defaults['markdown']['escape_markup'])) {\n                $this->setMarkupEscaped($defaults['markdown']['escape_markup']);\n        }\n        if (isset($defaults['markdown']['special_chars'])) {\n            $this->setSpecialChars($defaults['markdown']['special_chars']);\n        }\n\n        $this->excerpts->fireInitializedEvent($this);\n    }\n\n    /**\n     * @return Excerpts\n     */\n    public function getExcerpts()\n    {\n        return $this->excerpts;\n    }\n\n    /**\n     * Be able to define a new Block type or override an existing one\n     *\n     * @param string $type\n     * @param string $tag\n     * @param bool $continuable\n     * @param bool $completable\n     * @param int|null $index\n     * @return void\n     */\n    public function addBlockType($type, $tag, $continuable = false, $completable = false, $index = null)\n    {\n        $block = &$this->unmarkedBlockTypes;\n        if ($type) {\n            if (!isset($this->BlockTypes[$type])) {\n                $this->BlockTypes[$type] = [];\n            }\n            $block = &$this->BlockTypes[$type];\n        }\n\n        if (null === $index) {\n            $block[] = $tag;\n        } else {\n            array_splice($block, $index, 0, [$tag]);\n        }\n\n        if ($continuable) {\n            $this->continuable_blocks[] = $tag;\n        }\n        if ($completable) {\n            $this->completable_blocks[] = $tag;\n        }\n    }\n\n    /**\n     * Be able to define a new Inline type or override an existing one\n     *\n     * @param string $type\n     * @param string $tag\n     * @param int|null $index\n     * @return void\n     */\n    public function addInlineType($type, $tag, $index = null)\n    {\n        if (null === $index || !isset($this->InlineTypes[$type])) {\n            $this->InlineTypes[$type] [] = $tag;\n        } else {\n            array_splice($this->InlineTypes[$type], $index, 0, [$tag]);\n        }\n\n        if (strpos($this->inlineMarkerList, $type) === false) {\n            $this->inlineMarkerList .= $type;\n        }\n    }\n\n    /**\n     * Overrides the default behavior to allow for plugin-provided blocks to be continuable\n     *\n     * @param string $Type\n     * @return bool\n     */\n    protected function isBlockContinuable($Type)\n    {\n        $continuable = in_array($Type, $this->continuable_blocks, true)\n            || method_exists($this, 'block' . $Type . 'Continue');\n\n        return $continuable;\n    }\n\n    /**\n     *  Overrides the default behavior to allow for plugin-provided blocks to be completable\n     *\n     * @param string $Type\n     * @return bool\n     */\n    protected function isBlockCompletable($Type)\n    {\n        $completable = in_array($Type, $this->completable_blocks, true)\n            || method_exists($this, 'block' . $Type . 'Complete');\n\n        return $completable;\n    }\n\n\n    /**\n     * Make the element function publicly accessible, Medium uses this to render from Twig\n     *\n     * @param  array $Element\n     * @return string markup\n     */\n    public function elementToHtml(array $Element)\n    {\n        return $this->element($Element);\n    }\n\n    /**\n     * Setter for special chars\n     *\n     * @param array $special_chars\n     * @return $this\n     */\n    public function setSpecialChars($special_chars)\n    {\n        $this->special_chars = $special_chars;\n\n        return $this;\n    }\n\n    /**\n     * Ensure Twig tags are treated as block level items with no <p></p> tags\n     *\n     * @param array $line\n     * @return array|null\n     */\n    protected function blockTwigTag($line)\n    {\n        if (preg_match('/(?:{{|{%|{#)(.*)(?:}}|%}|#})/', $line['body'], $matches)) {\n            return ['markup' => $line['body']];\n        }\n\n        return null;\n    }\n\n    /**\n     * @param array $excerpt\n     * @return array|null\n     */\n    protected function inlineSpecialCharacter($excerpt)\n    {\n        if ($excerpt['text'][0] === '&' && !preg_match('/^&#?\\w+;/', $excerpt['text'])) {\n            return [\n                'markup' => '&amp;',\n                'extent' => 1,\n            ];\n        }\n\n        if (isset($this->special_chars[$excerpt['text'][0]])) {\n            return [\n                'markup' => '&' . $this->special_chars[$excerpt['text'][0]] . ';',\n                'extent' => 1,\n            ];\n        }\n\n        return null;\n    }\n\n    /**\n     * @param array $excerpt\n     * @return array\n     */\n    protected function inlineImage($excerpt)\n    {\n        if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) {\n            $excerpt['text'] = str_replace($matches[1], '/', $excerpt['text']);\n            $excerpt = parent::inlineImage($excerpt);\n            $excerpt['element']['attributes']['src'] = $matches[1];\n            $excerpt['extent'] = $excerpt['extent'] + strlen($matches[1]) - 1;\n\n            return $excerpt;\n        }\n\n        $excerpt['type'] = 'image';\n        $excerpt = parent::inlineImage($excerpt);\n\n        // if this is an image process it\n        if (isset($excerpt['element']['attributes']['src'])) {\n            $excerpt = $this->excerpts->processImageExcerpt($excerpt);\n        }\n\n        return $excerpt;\n    }\n\n    /**\n     * @param array $excerpt\n     * @return array\n     */\n    protected function inlineLink($excerpt)\n    {\n        $type = $excerpt['type'] ?? 'link';\n\n        // do some trickery to get around Parsedown requirement for valid URL if its Twig in there\n        if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) {\n            $excerpt['text'] = str_replace($matches[1], '/', $excerpt['text']);\n            $excerpt = parent::inlineLink($excerpt);\n            $excerpt['element']['attributes']['href'] = $matches[1];\n            $excerpt['extent'] = $excerpt['extent'] + strlen($matches[1]) - 1;\n\n            return $excerpt;\n        }\n\n        $excerpt = parent::inlineLink($excerpt);\n\n        // if this is a link\n        if (isset($excerpt['element']['attributes']['href'])) {\n            $excerpt = $this->excerpts->processLinkExcerpt($excerpt, $type);\n        }\n\n        return $excerpt;\n    }\n\n    /**\n     * For extending this class via plugins\n     *\n     * @param string $method\n     * @param array $args\n     * @return mixed|null\n     */\n    #[\\ReturnTypeWillChange]\n    public function __call($method, $args)\n    {\n\n        if (isset($this->plugins[$method]) === true) {\n            $func = $this->plugins[$method];\n\n            return call_user_func_array($func, $args);\n        } elseif (isset($this->{$method}) === true) {\n            $func = $this->{$method};\n\n            return call_user_func_array($func, $args);\n        }\n\n        return null;\n    }\n\n    public function __set($name, $value)\n    {\n        if (is_callable($value)) {\n            $this->plugins[$name] = $value;\n        }\n\n    }\n\n\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/AudioMediaInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\n/**\n * Class implements audio media interface.\n */\ninterface AudioMediaInterface extends MediaObjectInterface, MediaPlayerInterface\n{\n    /**\n     * Allows to set the controlsList behaviour\n     * Separate multiple values with a hyphen\n     *\n     * @param string $controlsList\n     * @return $this\n     */\n    public function controlsList($controlsList);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/ImageManipulateInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\n/**\n * Class implements image manipulation interface.\n */\ninterface ImageManipulateInterface\n{\n    /**\n     * Allows the ability to override the image's pretty name stored in cache\n     *\n     * @param string $name\n     */\n    public function setImagePrettyName($name);\n\n    /**\n     * @return string\n     */\n    public function getImagePrettyName();\n\n    /**\n     * Simply processes with no extra methods.  Useful for triggering events.\n     *\n     * @return $this\n     */\n    public function cache();\n\n    /**\n     * Generate alternative image widths, using either an array of integers, or\n     * a min width, a max width, and a step parameter to fill out the necessary\n     * widths. Existing image alternatives won't be overwritten.\n     *\n     * @param int|int[] $min_width\n     * @param int $max_width\n     * @param int $step\n     * @return $this\n     */\n    public function derivatives($min_width, $max_width = 2500, $step = 200);\n\n    /**\n     * Clear out the alternatives.\n     */\n    public function clearAlternatives();\n\n    /**\n     * Sets or gets the quality of the image\n     *\n     * @param int|null $quality 0-100 quality\n     * @return int|$this\n     */\n    public function quality($quality = null);\n\n    /**\n     * Sets image output format.\n     *\n     * @param string $format\n     * @return $this\n     */\n    public function format($format);\n\n    /**\n     * Set or get sizes parameter for srcset media action\n     *\n     * @param string|null $sizes\n     * @return string\n     */\n    public function sizes($sizes = null);\n\n    /**\n     * Allows to set the width attribute from Markdown or Twig\n     * Examples: ![Example](myimg.png?width=200&height=400)\n     *           ![Example](myimg.png?resize=100,200&width=100&height=200)\n     *           ![Example](myimg.png?width=auto&height=auto)\n     *           ![Example](myimg.png?width&height)\n     *           {{ page.media['myimg.png'].width().height().html }}\n     *           {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}\n     *\n     * @param string|int $value A value or 'auto' or empty to use the width of the image\n     * @return $this\n     */\n    public function width($value = 'auto');\n\n    /**\n     * Allows to set the height attribute from Markdown or Twig\n     * Examples: ![Example](myimg.png?width=200&height=400)\n     *           ![Example](myimg.png?resize=100,200&width=100&height=200)\n     *           ![Example](myimg.png?width=auto&height=auto)\n     *           ![Example](myimg.png?width&height)\n     *           {{ page.media['myimg.png'].width().height().html }}\n     *           {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}\n     *\n     * @param string|int $value A value or 'auto' or empty to use the height of the image\n     * @return $this\n     */\n    public function height($value = 'auto');\n\n    /* *\n     * Filter image by using user defined filter parameters.\n     *\n     * @param string $filter Filter to be used.\n     * @return $this\n     * FIXME: Conflicts against Data class\n     */\n    //public function filter($filter = 'image.filters.default');\n\n    /**\n     * Return the image higher quality version\n     *\n     * @return ImageMediaInterface the alternative version with higher quality\n     */\n    public function higherQualityAlternative();\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/ImageMediaInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\n/**\n * Class implements image media interface.\n */\ninterface ImageMediaInterface extends MediaObjectInterface\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/MediaCollectionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Page\\Medium\\ImageFile;\nuse Grav\\Common\\Page\\Medium\\Medium;\n\n/**\n * Class implements media collection interface.\n */\ninterface MediaCollectionInterface extends \\Grav\\Framework\\Media\\Interfaces\\MediaCollectionInterface\n{\n    /**\n     * Return media path.\n     *\n     * @return string|null\n     */\n    public function getPath();\n\n    /**\n     * @param string|null $path\n     * @return void\n     */\n    public function setPath(?string $path);\n\n    /**\n     * Get medium by filename.\n     *\n     * @param string $filename\n     * @return Medium|null\n     */\n    public function get($filename);\n\n    /**\n     * Get a list of all media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function all();\n\n    /**\n     * Get a list of all image media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function images();\n\n    /**\n     * Get a list of all video media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function videos();\n\n    /**\n     * Get a list of all audio media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function audios();\n\n    /**\n     * Get a list of all file media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function files();\n\n    /**\n     * Set file modification timestamps (query params) for all the media files.\n     *\n     * @param string|int|null $timestamp\n     * @return $this\n     */\n    public function setTimestamps($timestamp = null);\n\n    /**\n     * @param string $name\n     * @param MediaObjectInterface $file\n     * @return void\n     */\n    public function add($name, $file);\n\n    /**\n     * Create Medium from a file.\n     *\n     * @param  string $file\n     * @param  array  $params\n     * @return Medium|null\n     */\n    public function createFromFile($file, array $params = []);\n\n    /**\n     * Create Medium from array of parameters\n     *\n     * @param  array          $items\n     * @param  Blueprint|null $blueprint\n     * @return Medium|null\n     */\n    public function createFromArray(array $items = [], Blueprint $blueprint = null);\n\n    /**\n     * @param MediaObjectInterface $mediaObject\n     * @return ImageFile\n     */\n    public function getImageFileObject(MediaObjectInterface $mediaObject): ImageFile;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/MediaFileInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\n/**\n * Class implements media file interface.\n */\ninterface MediaFileInterface extends MediaObjectInterface\n{\n    /**\n     * Check if this medium exists or not\n     *\n     * @return bool\n     */\n    public function exists();\n\n    /**\n     * Get file modification time for the medium.\n     *\n     * @return int|null\n     */\n    public function modified();\n\n    /**\n     * Get size of the medium.\n     *\n     * @return int\n     */\n    public function size();\n\n    /**\n     * Return the path to file.\n     *\n     * @param bool $reset\n     * @return string path to file\n     */\n    public function path($reset = true);\n\n    /**\n     * Return the relative path to file\n     *\n     * @param bool $reset\n     * @return mixed\n     */\n    public function relativePath($reset = true);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/MediaInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\n/**\n * Class implements media interface.\n */\ninterface MediaInterface extends \\Grav\\Framework\\Media\\Interfaces\\MediaInterface\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/MediaLinkInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\n/**\n * Class implements media file interface.\n */\ninterface MediaLinkInterface\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/MediaObjectInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\nuse ArrayAccess;\nuse Grav\\Common\\Data\\Data;\n\n/**\n * Class implements media object interface.\n *\n * @property string $type\n * @property string $filename\n * @property string $filepath\n */\ninterface MediaObjectInterface extends \\Grav\\Framework\\Media\\Interfaces\\MediaObjectInterface, ArrayAccess\n{\n    /**\n     * Create a copy of this media object\n     *\n     * @return static\n     */\n    public function copy();\n\n    /**\n     * Return just metadata from the Medium object\n     *\n     * @return Data\n     */\n    public function meta();\n\n    /**\n     * Set querystring to file modification timestamp (or value provided as a parameter).\n     *\n     * @param string|int|null $timestamp\n     * @return $this\n     */\n    public function setTimestamp($timestamp = null);\n\n    /**\n     * Returns an array containing just the metadata\n     *\n     * @return array\n     */\n    public function metadata();\n\n    /**\n     * Add meta file for the medium.\n     *\n     * @param string $filepath\n     */\n    public function addMetaFile($filepath);\n\n    /**\n     * Add alternative Medium to this Medium.\n     *\n     * @param int|float $ratio\n     * @param MediaObjectInterface $alternative\n     */\n    public function addAlternative($ratio, MediaObjectInterface $alternative);\n\n    /**\n     * Get list of image alternatives. Includes the current media image as well.\n     *\n     * @param bool $withDerived If true, include generated images as well. If false, only return existing files.\n     * @return array\n     */\n    public function getAlternatives(bool $withDerived = true): array;\n\n    /**\n     * Return string representation of the object (html).\n     *\n     * @return string\n     */\n    public function __toString();\n\n    /**\n     * Get/set querystring for the file's url\n     *\n     * @param  string|null  $querystring\n     * @param  bool $withQuestionmark\n     * @return string\n     */\n    public function querystring($querystring = null, $withQuestionmark = true);\n\n    /**\n     * Get the URL with full querystring\n     *\n     * @param string $url\n     * @return string\n     */\n    public function urlQuerystring($url);\n\n    /**\n     * Get/set hash for the file's url\n     *\n     * @param  string|null $hash\n     * @param  bool $withHash\n     * @return string\n     */\n    public function urlHash($hash = null, $withHash = true);\n\n    /**\n     * Get an element (is array) that can be rendered by the Parsedown engine\n     *\n     * @param  string|null  $title\n     * @param  string|null  $alt\n     * @param  string|null  $class\n     * @param  string|null  $id\n     * @param  bool $reset\n     * @return array\n     */\n    public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true);\n\n    /**\n     * Reset medium.\n     *\n     * @return $this\n     */\n    public function reset();\n\n    /**\n     * Add custom attribute to medium.\n     *\n     * @param string $attribute\n     * @param string $value\n     * @return $this\n     */\n    public function attribute($attribute = null, $value = '');\n\n    /**\n     * Switch display mode.\n     *\n     * @param string $mode\n     * @return MediaObjectInterface|null\n     */\n    public function display($mode = 'source');\n\n    /**\n     * Helper method to determine if this media item has a thumbnail or not\n     *\n     * @param string $type;\n     * @return bool\n     */\n    public function thumbnailExists($type = 'page');\n\n    /**\n     * Switch thumbnail.\n     *\n     * @param string $type\n     * @return $this\n     */\n    public function thumbnail($type = 'auto');\n\n    /**\n     * Turn the current Medium into a Link\n     *\n     * @param  bool $reset\n     * @param  array  $attributes\n     * @return MediaLinkInterface\n     */\n    public function link($reset = true, array $attributes = []);\n\n    /**\n     * Turn the current Medium into a Link with lightbox enabled\n     *\n     * @param  int  $width\n     * @param  int  $height\n     * @param  bool $reset\n     * @return MediaLinkInterface\n     */\n    public function lightbox($width = null, $height = null, $reset = true);\n\n    /**\n     * Add a class to the element from Markdown or Twig\n     * Example: ![Example](myimg.png?classes=float-left) or ![Example](myimg.png?classes=myclass1,myclass2)\n     *\n     * @return $this\n     */\n    public function classes();\n\n    /**\n     * Add an id to the element from Markdown or Twig\n     * Example: ![Example](myimg.png?id=primary-img)\n     *\n     * @param string $id\n     * @return $this\n     */\n    public function id($id);\n\n    /**\n     * Allows to add an inline style attribute from Markdown or Twig\n     * Example: ![Example](myimg.png?style=float:left)\n     *\n     * @param string $style\n     * @return $this\n     */\n    public function style($style);\n\n    /**\n     * Allow any action to be called on this medium from twig or markdown\n     *\n     * @param string $method\n     * @param mixed $args\n     * @return $this\n     */\n    #[\\ReturnTypeWillChange]\n    public function __call($method, $args);\n\n    /**\n     * Set value by using dot notation for nested arrays/objects.\n     *\n     * @example $data->set('this.is.my.nested.variable', $value);\n     *\n     * @param string $name Dot separated path to the requested value.\n     * @param mixed $value New value.\n     * @param string|null $separator Separator, defaults to '.'\n     * @return $this\n     */\n    public function set($name, $value, $separator = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/MediaPlayerInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\n/**\n * Class implements media player interface.\n */\ninterface MediaPlayerInterface extends MediaObjectInterface\n{\n    /**\n     * Allows to set or remove the HTML5 default controls\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function controls($status = true);\n\n    /**\n     * Allows to set the loop attribute\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function loop($status = false);\n\n    /**\n     * Allows to set the autoplay attribute\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function autoplay($status = false);\n\n    /**\n     * Allows to set the muted attribute\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function muted($status = false);\n\n    /**\n     * Allows to set the preload behaviour\n     *\n     * @param string|null $preload\n     * @return $this\n     */\n    public function preload($preload = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/MediaUploadInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse RuntimeException;\n\n/**\n * Implements media upload and delete functionality.\n */\ninterface MediaUploadInterface\n{\n    /**\n     * Checks that uploaded file meets the requirements. Returns new filename.\n     *\n     * @example\n     *   $filename = null;  // Override filename if needed (ignored if randomizing filenames).\n     *   $settings = ['destination' => 'user://pages/media']; // Settings from the form field.\n     *   $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);\n     *   $media->copyUploadedFile($uploadedFile, $filename);\n\n     * @param UploadedFileInterface $uploadedFile\n     * @param string|null $filename\n     * @param array|null $settings\n     * @return string\n     * @throws RuntimeException\n     */\n    public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string;\n\n    /**\n     * Copy uploaded file to the media collection.\n     *\n     * WARNING: Always check uploaded file before copying it!\n     *\n     * @example\n     *   $filename = null;  // Override filename if needed (ignored if randomizing filenames).\n     *   $settings = ['destination' => 'user://pages/media']; // Settings from the form field.\n     *   $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);\n     *   $media->copyUploadedFile($uploadedFile, $filename);\n     *\n     * @param UploadedFileInterface $uploadedFile\n     * @param string $filename\n     * @param array|null $settings\n     * @return void\n     * @throws RuntimeException\n     */\n    public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void;\n\n    /**\n     * Delete real file from the media collection.\n     *\n     * @param string $filename\n     * @param array|null $settings\n     * @return void\n     */\n    public function deleteFile(string $filename, array $settings = null): void;\n\n    /**\n     * Rename file inside the media collection.\n     *\n     * @param string $from\n     * @param string $to\n     * @param array|null $settings\n     */\n    public function renameFile(string $from, string $to, array $settings = null): void;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Interfaces/VideoMediaInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Interfaces;\n\n/**\n * Class implements video media interface.\n */\ninterface VideoMediaInterface extends MediaObjectInterface, MediaPlayerInterface\n{\n    /**\n     * Allows to set the video's poster image\n     *\n     * @param string $urlImage\n     * @return $this\n     */\n    public function poster($urlImage);\n\n    /**\n     * Allows to set the playsinline attribute\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function playsinline($status = false);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/AudioMediaTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\n/**\n * Trait AudioMediaTrait\n * @package Grav\\Common\\Media\\Traits\n */\ntrait AudioMediaTrait\n{\n    use StaticResizeTrait;\n    use MediaPlayerTrait;\n\n    /**\n     * Allows to set the controlsList behaviour\n     * Separate multiple values with a hyphen\n     *\n     * @param string $controlsList\n     * @return $this\n     */\n    public function controlsList($controlsList)\n    {\n        $controlsList = str_replace('-', ' ', $controlsList);\n        $this->attributes['controlsList'] = $controlsList;\n\n        return $this;\n    }\n\n    /**\n     * Parsedown element for source display mode\n     *\n     * @param  array $attributes\n     * @param  bool $reset\n     * @return array\n     */\n    protected function sourceParsedownElement(array $attributes, $reset = true)\n    {\n        $location = $this->url($reset);\n\n        return [\n            'name' => 'audio',\n            'rawHtml' => '<source src=\"' . $location . '\">Your browser does not support the audio tag.',\n            'attributes' => $attributes\n        ];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/ImageDecodingTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n * @author     Pedro Moreno https://github.com/pmoreno-rodriguez\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse Grav\\Common\\Grav;\n\n/**\n * Trait ImageDecodingTrait\n * @package Grav\\Common\\Media\\Traits\n */\n\ntrait ImageDecodingTrait\n{\n    /**\n     * Allows to set the decoding attribute from Markdown or Twig\n     *\n     * @param string|null $value\n     * @return $this\n     */\n    public function decoding($value = null)\n    {\n        if (null === $value) {\n            $value = Grav::instance()['config']->get('system.images.defaults.decoding', 'auto');\n        }\n\n        // Validate the provided value (similar to loading)\n        if ($value !== null && $value !== 'auto') {\n            $this->attributes['decoding'] = $value;\n        }\n\n        return $this;\n    }\n\n}"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/ImageFetchPriorityTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n * @author     Pedro Moreno https://github.com/pmoreno-rodriguez\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse Grav\\Common\\Grav;\n\n/**\n * Trait ImageFetchPriorityTrait\n * @package Grav\\Common\\Media\\Traits\n */\n\ntrait ImageFetchPriorityTrait\n{\n    /**\n     * Allows to set the fetchpriority attribute from Markdown or Twig\n     *\n     * @param string|null $value\n     * @return $this\n     */\n    public function fetchpriority($value = null)\n    {\n        if (null === $value) {\n            $value = Grav::instance()['config']->get('system.images.defaults.fetchpriority', 'auto');\n        }\n\n        // Validate the provided value (similar to loading and decoding attributes)\n        if ($value !== null && $value !== 'auto') {\n            $this->attributes['fetchpriority'] = $value;\n        }\n\n        return $this;\n    }\n\n}"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/ImageLoadingTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse Grav\\Common\\Grav;\n\n/**\n * Trait ImageLoadingTrait\n * @package Grav\\Common\\Media\\Traits\n */\ntrait ImageLoadingTrait\n{\n    /**\n     * Allows to set the loading attribute from Markdown or Twig\n     *\n     * @param string|null $value\n     * @return $this\n     */\n    public function loading($value = null)\n    {\n        if (null === $value) {\n            $value = Grav::instance()['config']->get('system.images.defaults.loading', 'auto');\n        }\n        if ($value && $value !== 'auto') {\n            $this->attributes['loading'] = $value;\n        }\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/ImageMediaTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Media\\Interfaces\\ImageMediaInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaCollectionInterface;\nuse Grav\\Common\\Page\\Medium\\ImageFile;\nuse Grav\\Common\\Page\\Medium\\ImageMedium;\nuse Grav\\Common\\Page\\Medium\\MediumFactory;\nuse function array_key_exists;\nuse function extension_loaded;\nuse function func_num_args;\nuse function function_exists;\n\n/**\n * Trait ImageMediaTrait\n * @package Grav\\Common\\Media\\Traits\n */\ntrait ImageMediaTrait\n{\n    /** @var ImageFile|null */\n    protected $image;\n\n    /** @var string */\n    protected $format = 'guess';\n\n    /** @var int */\n    protected $quality;\n\n    /** @var int */\n    protected $default_quality;\n\n    /** @var bool */\n    protected $debug_watermarked = false;\n\n    /** @var bool  */\n    protected $auto_sizes;\n\n    /** @var bool */\n    protected $aspect_ratio;\n\n    /** @var integer */\n    protected $retina_scale;\n\n    /** @var bool */\n    protected $watermark;\n\n    /** @var array */\n    public static $magic_actions = [\n        'resize', 'forceResize', 'cropResize', 'crop', 'zoomCrop',\n        'negate', 'brightness', 'contrast', 'grayscale', 'emboss',\n        'smooth', 'sharp', 'edge', 'colorize', 'sepia', 'enableProgressive',\n        'rotate', 'flip', 'fixOrientation', 'gaussianBlur', 'format', 'create', 'fill', 'merge'\n    ];\n\n    /** @var array */\n    public static $magic_resize_actions = [\n        'resize' => [0, 1],\n        'forceResize' => [0, 1],\n        'cropResize' => [0, 1],\n        'crop' => [0, 1, 2, 3],\n        'zoomCrop' => [0, 1]\n    ];\n\n    /** @var string */\n    protected $sizes = '100vw';\n\n\n    /**\n     * Allows the ability to override the image's pretty name stored in cache\n     *\n     * @param string $name\n     */\n    public function setImagePrettyName($name)\n    {\n        $this->set('prettyname', $name);\n        if ($this->image) {\n            $this->image->setPrettyName($name);\n        }\n    }\n\n    /**\n     * @return string\n     */\n    public function getImagePrettyName()\n    {\n        if ($this->get('prettyname')) {\n            return $this->get('prettyname');\n        }\n\n        $basename = $this->get('basename');\n        if (preg_match('/[a-z0-9]{40}-(.*)/', $basename, $matches)) {\n            $basename = $matches[1];\n        }\n        return $basename;\n    }\n\n    /**\n     * Simply processes with no extra methods.  Useful for triggering events.\n     *\n     * @return $this\n     */\n    public function cache()\n    {\n        if (!$this->image) {\n            $this->image();\n        }\n\n        return $this;\n    }\n\n    /**\n     * Generate alternative image widths, using either an array of integers, or\n     * a min width, a max width, and a step parameter to fill out the necessary\n     * widths. Existing image alternatives won't be overwritten.\n     *\n     * @param  int|int[] $min_width\n     * @param  int       $max_width\n     * @param  int       $step\n     * @return $this\n     */\n    public function derivatives($min_width, $max_width = 2500, $step = 200)\n    {\n        if (!empty($this->alternatives)) {\n            $max = max(array_keys($this->alternatives));\n            $base = $this->alternatives[$max];\n        } else {\n            $base = $this;\n        }\n\n        $widths = [];\n\n        if (func_num_args() === 1) {\n            foreach ((array) func_get_arg(0) as $width) {\n                if ($width < $base->get('width')) {\n                    $widths[] = $width;\n                }\n            }\n        } else {\n            $max_width = min($max_width, $base->get('width'));\n\n            for ($width = $min_width; $width < $max_width; $width += $step) {\n                $widths[] = $width;\n            }\n        }\n\n        foreach ($widths as $width) {\n            // Only generate image alternatives that don't already exist\n            if (array_key_exists((int) $width, $this->alternatives)) {\n                continue;\n            }\n\n            $derivative = MediumFactory::fromFile($base->get('filepath'));\n\n            // It's possible that MediumFactory::fromFile returns null if the\n            // original image file no longer exists and this class instance was\n            // retrieved from the page cache\n            if (null !== $derivative) {\n                $index = 2;\n                $alt_widths = array_keys($this->alternatives);\n                sort($alt_widths);\n\n                foreach ($alt_widths as $i => $key) {\n                    if ($width > $key) {\n                        $index += max($i, 1);\n                    }\n                }\n\n                $basename = preg_replace('/(@\\d+x)?$/', \"@{$width}w\", $base->get('basename'), 1);\n                $derivative->setImagePrettyName($basename);\n\n                $ratio = $base->get('width') / $width;\n                $height = $derivative->get('height') / $ratio;\n\n                $derivative->resize($width, $height);\n                $derivative->set('width', $width);\n                $derivative->set('height', $height);\n\n                $this->addAlternative($ratio, $derivative);\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * Clear out the alternatives.\n     */\n    public function clearAlternatives()\n    {\n        $this->alternatives = [];\n    }\n\n    /**\n     * Sets or gets the quality of the image\n     *\n     * @param  int|null $quality 0-100 quality\n     * @return int|$this\n     */\n    public function quality($quality = null)\n    {\n        if ($quality) {\n            if (!$this->image) {\n                $this->image();\n            }\n\n            $this->quality = $quality;\n\n            return $this;\n        }\n\n        return $this->quality;\n    }\n\n    /**\n     * Sets image output format.\n     *\n     * @param string $format\n     * @return $this\n     */\n    public function format($format)\n    {\n        if (!$this->image) {\n            $this->image();\n        }\n\n        $this->format = $format;\n\n        return $this;\n    }\n\n    /**\n     * Set or get sizes parameter for srcset media action\n     *\n     * @param  string|null $sizes\n     * @return string\n     */\n    public function sizes($sizes = null)\n    {\n        if ($sizes) {\n            $this->sizes = $sizes;\n\n            return $this;\n        }\n\n        return empty($this->sizes) ? '100vw' : $this->sizes;\n    }\n\n    /**\n     * Allows to set the width attribute from Markdown or Twig\n     * Examples: ![Example](myimg.png?width=200&height=400)\n     *           ![Example](myimg.png?resize=100,200&width=100&height=200)\n     *           ![Example](myimg.png?width=auto&height=auto)\n     *           ![Example](myimg.png?width&height)\n     *           {{ page.media['myimg.png'].width().height().html }}\n     *           {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}\n     *\n     * @param string|int $value A value or 'auto' or empty to use the width of the image\n     * @return $this\n     */\n    public function width($value = 'auto')\n    {\n        if (!$value || $value === 'auto') {\n            $this->attributes['width'] = $this->get('width');\n        } else {\n            $this->attributes['width'] = $value;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Allows to set the height attribute from Markdown or Twig\n     * Examples: ![Example](myimg.png?width=200&height=400)\n     *           ![Example](myimg.png?resize=100,200&width=100&height=200)\n     *           ![Example](myimg.png?width=auto&height=auto)\n     *           ![Example](myimg.png?width&height)\n     *           {{ page.media['myimg.png'].width().height().html }}\n     *           {{ page.media['myimg.png'].resize(100,200).width(100).height(200).html }}\n     *\n     * @param string|int $value A value or 'auto' or empty to use the height of the image\n     * @return $this\n     */\n    public function height($value = 'auto')\n    {\n        if (!$value || $value === 'auto') {\n            $this->attributes['height'] = $this->get('height');\n        } else {\n            $this->attributes['height'] = $value;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Filter image by using user defined filter parameters.\n     *\n     * @param string $filter Filter to be used.\n     * @return $this\n     */\n    public function filter($filter = 'image.filters.default')\n    {\n        $filters = (array) $this->get($filter, []);\n        foreach ($filters as $params) {\n            $params = (array) $params;\n            $method = array_shift($params);\n            $this->__call($method, $params);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Return the image higher quality version\n     *\n     * @return ImageMediaInterface|$this the alternative version with higher quality\n     */\n    public function higherQualityAlternative()\n    {\n        if ($this->alternatives) {\n            /** @var ImageMedium $max */\n            $max = reset($this->alternatives);\n            /** @var ImageMedium $alternative */\n            foreach ($this->alternatives as $alternative) {\n                if ($alternative->quality() > $max->quality()) {\n                    $max = $alternative;\n                }\n            }\n\n            return $max;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Gets medium image, resets image manipulation operations.\n     *\n     * @return $this\n     */\n    protected function image()\n    {\n        $locator = Grav::instance()['locator'];\n\n        // Use existing cache folder or if it doesn't exist, create it.\n        $cacheDir = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true);\n\n        // Make sure we free previous image.\n        unset($this->image);\n\n        /** @var MediaCollectionInterface $media */\n        $media = $this->get('media');\n        if ($media && method_exists($media, 'getImageFileObject')) {\n            $this->image = $media->getImageFileObject($this);\n        } else {\n            $this->image = ImageFile::open($this->get('filepath'));\n        }\n\n        $this->image\n            ->setCacheDir($cacheDir)\n            ->setActualCacheDir($cacheDir)\n            ->setPrettyName($this->getImagePrettyName());\n\n        // Fix orientation if enabled\n        $config = Grav::instance()['config'];\n        if ($config->get('system.images.auto_fix_orientation', false) &&\n            extension_loaded('exif') && function_exists('exif_read_data')) {\n            $this->image->fixOrientation();\n        }\n\n        // Set CLS configuration\n        $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false);\n        $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false);\n        $this->retina_scale = $config->get('system.images.cls.retina_scale', 1);\n\n        $this->watermark = $config->get('system.images.watermark.watermark_all', false);\n\n        return $this;\n    }\n\n    /**\n     * Save the image with cache.\n     *\n     * @return string\n     */\n    protected function saveImage()\n    {\n        if (!$this->image) {\n            return parent::path(false);\n        }\n\n        $this->filter();\n\n        if (isset($this->result)) {\n            return $this->result;\n        }\n\n        if ($this->format === 'guess') {\n            $extension = strtolower($this->get('extension'));\n            $this->format($extension);\n        }\n\n        if (!$this->debug_watermarked && $this->get('debug')) {\n            $ratio = $this->get('ratio');\n            if (!$ratio) {\n                $ratio = 1;\n            }\n\n            $locator = Grav::instance()['locator'];\n            $overlay = $locator->findResource(\"system://assets/responsive-overlays/{$ratio}x.png\") ?: $locator->findResource('system://assets/responsive-overlays/unknown.png');\n            $this->image->merge(ImageFile::open($overlay));\n        }\n\n        if ($this->watermark) {\n            $this->watermark();\n        }\n\n        return $this->image->cacheFile($this->format, $this->quality, false, [$this->get('width'), $this->get('height'), $this->get('modified')]);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/MediaFileTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse Grav\\Common\\Grav;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n/**\n * Trait MediaFileTrait\n * @package Grav\\Common\\Media\\Traits\n */\ntrait MediaFileTrait\n{\n    /**\n     * Check if this medium exists or not\n     *\n     * @return bool\n     */\n    public function exists()\n    {\n        $path = $this->path(false);\n\n        return file_exists($path);\n    }\n\n    /**\n     * Get file modification time for the medium.\n     *\n     * @return int|null\n     */\n    public function modified()\n    {\n        $path = $this->path(false);\n        if (!file_exists($path)) {\n            return null;\n        }\n\n        return filemtime($path) ?: null;\n    }\n\n    /**\n     * Get size of the medium.\n     *\n     * @return int\n     */\n    public function size()\n    {\n        $path = $this->path(false);\n        if (!file_exists($path)) {\n            return 0;\n        }\n\n        return filesize($path) ?: 0;\n    }\n\n    /**\n     * Return PATH to file.\n     *\n     * @param bool $reset\n     * @return string path to file\n     */\n    public function path($reset = true)\n    {\n        if ($reset) {\n            $this->reset();\n        }\n\n        return $this->get('url') ?? $this->get('filepath');\n    }\n\n    /**\n     * Return the relative path to file\n     *\n     * @param bool $reset\n     * @return string\n     */\n    public function relativePath($reset = true)\n    {\n        if ($reset) {\n            $this->reset();\n        }\n\n        $path = $this->path(false);\n        $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $path) ?: $path;\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n        if ($locator->isStream($output)) {\n            $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true));\n        }\n\n        return $output;\n    }\n\n    /**\n     * Return URL to file.\n     *\n     * @param bool $reset\n     * @return string\n     */\n    public function url($reset = true)\n    {\n        $url = $this->get('url');\n        if ($url) {\n            return $url;\n        }\n\n        $path = $this->relativePath($reset);\n\n        return trim($this->getGrav()['base_url'] . '/' . $this->urlQuerystring($path), '\\\\');\n    }\n\n    /**\n     * Get the URL with full querystring\n     *\n     * @param string $url\n     * @return string\n     */\n    abstract public function urlQuerystring($url);\n\n    /**\n     * Reset medium.\n     *\n     * @return $this\n     */\n    abstract public function reset();\n\n    /**\n     * @return Grav\n     */\n    abstract protected function getGrav(): Grav;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/MediaObjectTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Media\\Interfaces\\MediaFileInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaLinkInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaObjectInterface;\nuse Grav\\Common\\Page\\Medium\\ThumbnailImageMedium;\nuse Grav\\Common\\Utils;\nuse function count;\nuse function func_get_args;\nuse function in_array;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class Medium\n * @package Grav\\Common\\Page\\Medium\n *\n * @property string $mime\n */\ntrait MediaObjectTrait\n{\n    /** @var string */\n    protected $mode = 'source';\n\n    /** @var MediaObjectInterface|null */\n    protected $_thumbnail;\n\n    /** @var array */\n    protected $thumbnailTypes = ['page', 'default'];\n\n    /** @var string|null */\n    protected $thumbnailType;\n\n    /** @var MediaObjectInterface[] */\n    protected $alternatives = [];\n\n    /** @var array */\n    protected $attributes = [];\n\n    /** @var array */\n    protected $styleAttributes = [];\n\n    /** @var array */\n    protected $metadata = [];\n\n    /** @var array */\n    protected $medium_querystring = [];\n\n    /** @var string */\n    protected $timestamp;\n\n    /**\n     * Create a copy of this media object\n     *\n     * @return static\n     */\n    public function copy()\n    {\n        return clone $this;\n    }\n\n    /**\n     * Return just metadata from the Medium object\n     *\n     * @return Data\n     */\n    public function meta()\n    {\n        return new Data($this->getItems());\n    }\n\n    /**\n     * Set querystring to file modification timestamp (or value provided as a parameter).\n     *\n     * @param string|int|null $timestamp\n     * @return $this\n     */\n    public function setTimestamp($timestamp = null)\n    {\n        if (null !== $timestamp) {\n            $this->timestamp = (string)($timestamp);\n        } elseif ($this instanceof MediaFileInterface) {\n            $this->timestamp = (string)$this->modified();\n        } else {\n            $this->timestamp = '';\n        }\n\n        return $this;\n    }\n\n    /**\n     * Returns an array containing just the metadata\n     *\n     * @return array\n     */\n    public function metadata()\n    {\n        return $this->metadata;\n    }\n\n    /**\n     * Add meta file for the medium.\n     *\n     * @param string $filepath\n     */\n    abstract public function addMetaFile($filepath);\n\n    /**\n     * Add alternative Medium to this Medium.\n     *\n     * @param int|float $ratio\n     * @param MediaObjectInterface $alternative\n     */\n    public function addAlternative($ratio, MediaObjectInterface $alternative)\n    {\n        if (!is_numeric($ratio) || $ratio === 0) {\n            return;\n        }\n\n        $alternative->set('ratio', $ratio);\n        $width = $alternative->get('width', 0);\n\n        $this->alternatives[$width] = $alternative;\n    }\n\n    /**\n     * @param bool $withDerived\n     * @return array\n     */\n    public function getAlternatives(bool $withDerived = true): array\n    {\n        $alternatives = [];\n        foreach ($this->alternatives + [$this->get('width', 0) => $this] as $size => $alternative) {\n            if ($withDerived || $alternative->filename === Utils::basename($alternative->filepath)) {\n                $alternatives[$size] = $alternative;\n            }\n        }\n\n        ksort($alternatives, SORT_NUMERIC);\n\n        return $alternatives;\n    }\n\n    /**\n     * Return string representation of the object (html).\n     *\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    abstract public function __toString();\n\n    /**\n     * Get/set querystring for the file's url\n     *\n     * @param  string|null  $querystring\n     * @param  bool $withQuestionmark\n     * @return string\n     */\n    public function querystring($querystring = null, $withQuestionmark = true)\n    {\n        if (null !== $querystring) {\n            $this->medium_querystring[] = ltrim($querystring, '?&');\n            foreach ($this->alternatives as $alt) {\n                $alt->querystring($querystring, $withQuestionmark);\n            }\n        }\n\n        if (empty($this->medium_querystring)) {\n            return '';\n        }\n\n        // join the strings\n        $querystring = implode('&', $this->medium_querystring);\n        // explode all strings\n        $query_parts = explode('&', $querystring);\n        // Join them again now ensure the elements are unique\n        $querystring = implode('&', array_unique($query_parts));\n\n        return $withQuestionmark ? ('?' . $querystring) : $querystring;\n    }\n\n    /**\n     * Get the URL with full querystring\n     *\n     * @param string $url\n     * @return string\n     */\n    public function urlQuerystring($url)\n    {\n        $querystring = $this->querystring();\n        if (isset($this->timestamp) && !Utils::contains($querystring, $this->timestamp)) {\n            $querystring = empty($querystring) ? ('?' . $this->timestamp) : ($querystring . '&' . $this->timestamp);\n        }\n\n        return ltrim($url . $querystring . $this->urlHash(), '/');\n    }\n\n    /**\n     * Get/set hash for the file's url\n     *\n     * @param  string|null  $hash\n     * @param  bool $withHash\n     * @return string\n     */\n    public function urlHash($hash = null, $withHash = true)\n    {\n        if ($hash) {\n            $this->set('urlHash', ltrim($hash, '#'));\n        }\n\n        $hash = $this->get('urlHash', '');\n\n        return $withHash && !empty($hash) ? '#' . $hash : $hash;\n    }\n\n    /**\n     * Get an element (is array) that can be rendered by the Parsedown engine\n     *\n     * @param  string|null  $title\n     * @param  string|null  $alt\n     * @param  string|null  $class\n     * @param  string|null  $id\n     * @param  bool $reset\n     * @return array\n     */\n    public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true)\n    {\n        $attributes = $this->attributes;\n        $items = $this->getItems();\n\n        $style = '';\n        foreach ($this->styleAttributes as $key => $value) {\n            if (is_numeric($key)) { // Special case for inline style attributes, refer to style() method\n                $style .= $value;\n            } else {\n                $style .= $key . ': ' . $value . ';';\n            }\n        }\n        if ($style) {\n            $attributes['style'] = $style;\n        }\n\n        if (empty($attributes['title'])) {\n            if (!empty($title)) {\n                $attributes['title'] = $title;\n            } elseif (!empty($items['title'])) {\n                $attributes['title'] = $items['title'];\n            }\n        }\n\n        if (empty($attributes['alt'])) {\n            if (!empty($alt)) {\n                $attributes['alt'] = $alt;\n            } elseif (!empty($items['alt'])) {\n                $attributes['alt'] = $items['alt'];\n            } elseif (!empty($items['alt_text'])) {\n                $attributes['alt'] = $items['alt_text'];\n            } else {\n                $attributes['alt'] = '';\n            }\n        }\n\n        if (empty($attributes['class'])) {\n            if (!empty($class)) {\n                $attributes['class'] = $class;\n            } elseif (!empty($items['class'])) {\n                $attributes['class'] = $items['class'];\n            }\n        }\n\n        if (empty($attributes['id'])) {\n            if (!empty($id)) {\n                $attributes['id'] = $id;\n            } elseif (!empty($items['id'])) {\n                $attributes['id'] = $items['id'];\n            }\n        }\n\n        switch ($this->mode) {\n            case 'text':\n                $element = $this->textParsedownElement($attributes, false);\n                break;\n            case 'thumbnail':\n                $thumbnail = $this->getThumbnail();\n                $element = $thumbnail ? $thumbnail->sourceParsedownElement($attributes, false) : [];\n                break;\n            case 'source':\n                $element = $this->sourceParsedownElement($attributes, false);\n                break;\n            default:\n                $element = [];\n        }\n\n        if ($reset) {\n            $this->reset();\n        }\n\n        $this->display('source');\n\n        return $element;\n    }\n\n    /**\n     * Reset medium.\n     *\n     * @return $this\n     */\n    public function reset()\n    {\n        $this->attributes = [];\n\n        return $this;\n    }\n\n    /**\n     * Add custom attribute to medium.\n     *\n     * @param string $attribute\n     * @param string $value\n     * @return $this\n     */\n    public function attribute($attribute = null, $value = '')\n    {\n        if (!empty($attribute)) {\n            $this->attributes[$attribute] = $value;\n        }\n        return $this;\n    }\n\n    /**\n     * Switch display mode.\n     *\n     * @param string $mode\n     *\n     * @return MediaObjectInterface|null\n     */\n    public function display($mode = 'source')\n    {\n        if ($this->mode === $mode) {\n            return $this;\n        }\n\n        $this->mode = $mode;\n        if ($mode === 'thumbnail') {\n            $thumbnail = $this->getThumbnail();\n\n            return $thumbnail ? $thumbnail->reset() : null;\n        }\n\n        return $this->reset();\n    }\n\n    /**\n     * Helper method to determine if this media item has a thumbnail or not\n     *\n     * @param string $type;\n     * @return bool\n     */\n    public function thumbnailExists($type = 'page')\n    {\n        $thumbs = $this->get('thumbnails');\n\n        return isset($thumbs[$type]);\n    }\n\n    /**\n     * Switch thumbnail.\n     *\n     * @param string $type\n     * @return $this\n     */\n    public function thumbnail($type = 'auto')\n    {\n        if ($type !== 'auto' && !in_array($type, $this->thumbnailTypes, true)) {\n            return $this;\n        }\n\n        if ($this->thumbnailType !== $type) {\n            $this->_thumbnail = null;\n        }\n\n        $this->thumbnailType = $type;\n\n        return $this;\n    }\n\n    /**\n     * Return URL to file.\n     *\n     * @param bool $reset\n     * @return string\n     */\n    abstract public function url($reset = true);\n\n    /**\n     * Turn the current Medium into a Link\n     *\n     * @param  bool $reset\n     * @param  array  $attributes\n     * @return MediaLinkInterface\n     */\n    public function link($reset = true, array $attributes = [])\n    {\n        if ($this->mode !== 'source') {\n            $this->display('source');\n        }\n\n        foreach ($this->attributes as $key => $value) {\n            empty($attributes['data-' . $key]) && $attributes['data-' . $key] = $value;\n        }\n\n        empty($attributes['href']) && $attributes['href'] = $this->url();\n\n        return $this->createLink($attributes);\n    }\n\n    /**\n     * Turn the current Medium into a Link with lightbox enabled\n     *\n     * @param  int|null  $width\n     * @param  int|null  $height\n     * @param  bool $reset\n     * @return MediaLinkInterface\n     */\n    public function lightbox($width = null, $height = null, $reset = true)\n    {\n        $attributes = ['rel' => 'lightbox'];\n\n        if ($width && $height) {\n            $attributes['data-width'] = $width;\n            $attributes['data-height'] = $height;\n        }\n\n        return $this->link($reset, $attributes);\n    }\n\n    /**\n     * Add a class to the element from Markdown or Twig\n     * Example: ![Example](myimg.png?classes=float-left) or ![Example](myimg.png?classes=myclass1,myclass2)\n     *\n     * @return $this\n     */\n    public function classes()\n    {\n        $classes = func_get_args();\n        if (!empty($classes)) {\n            $this->attributes['class'] = implode(',', $classes);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Add an id to the element from Markdown or Twig\n     * Example: ![Example](myimg.png?id=primary-img)\n     *\n     * @param string $id\n     * @return $this\n     */\n    public function id($id)\n    {\n        if (is_string($id)) {\n            $this->attributes['id'] = trim($id);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Allows to add an inline style attribute from Markdown or Twig\n     * Example: ![Example](myimg.png?style=float:left)\n     *\n     * @param string $style\n     * @return $this\n     */\n    public function style($style)\n    {\n        $this->styleAttributes[] = rtrim($style, ';') . ';';\n\n        return $this;\n    }\n\n    /**\n     * Allow any action to be called on this medium from twig or markdown\n     *\n     * @param string $method\n     * @param array $args\n     * @return $this\n     */\n    #[\\ReturnTypeWillChange]\n    public function __call($method, $args)\n    {\n        $count = count($args);\n        if ($count > 1 || ($count === 1 && !empty($args[0]))) {\n            $method .= '=' . implode(',', array_map(static function ($a) {\n                if (is_array($a)) {\n                    $a = '[' . implode(',', $a) . ']';\n                }\n\n                return rawurlencode($a);\n            }, $args));\n        }\n\n        if (!empty($method)) {\n            $this->querystring($this->querystring(null, false) . '&' . $method);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Parsedown element for source display mode\n     *\n     * @param  array $attributes\n     * @param  bool $reset\n     * @return array\n     */\n    protected function sourceParsedownElement(array $attributes, $reset = true)\n    {\n        return $this->textParsedownElement($attributes, $reset);\n    }\n\n    /**\n     * Parsedown element for text display mode\n     *\n     * @param  array $attributes\n     * @param  bool $reset\n     * @return array\n     */\n    protected function textParsedownElement(array $attributes, $reset = true)\n    {\n        if ($reset) {\n            $this->reset();\n        }\n\n        $text = $attributes['title'] ?? '';\n        if ($text === '') {\n            $text = $attributes['alt'] ?? '';\n            if ($text === '') {\n                $text = $this->get('filename');\n            }\n        }\n\n        return [\n            'name' => 'p',\n            'attributes' => $attributes,\n            'text' => $text\n        ];\n    }\n\n    /**\n     * Get the thumbnail Medium object\n     *\n     * @return ThumbnailImageMedium|null\n     */\n    protected function getThumbnail()\n    {\n        if (null === $this->_thumbnail) {\n            $types = $this->thumbnailTypes;\n\n            if ($this->thumbnailType !== 'auto') {\n                array_unshift($types, $this->thumbnailType);\n            }\n\n            foreach ($types as $type) {\n                $thumb = $this->get(\"thumbnails.{$type}\", false);\n                if ($thumb) {\n                    $image = $thumb instanceof ThumbnailImageMedium ? $thumb : $this->createThumbnail($thumb);\n                    if($image) {\n                        $image->parent = $this;\n                        $this->_thumbnail = $image;\n                    }\n                    break;\n                }\n            }\n        }\n\n        return $this->_thumbnail;\n    }\n\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @example $value = $this->get('this.is.my.nested.variable');\n     *\n     * @param string $name Dot separated path to the requested value.\n     * @param mixed $default Default value (or null).\n     * @param string|null $separator Separator, defaults to '.'\n     * @return mixed Value.\n     */\n    abstract public function get($name, $default = null, $separator = null);\n\n        /**\n     * Set value by using dot notation for nested arrays/objects.\n     *\n     * @example $data->set('this.is.my.nested.variable', $value);\n     *\n     * @param string $name Dot separated path to the requested value.\n     * @param mixed $value New value.\n     * @param string|null $separator Separator, defaults to '.'\n     * @return $this\n     */\n    abstract public function set($name, $value, $separator = null);\n\n    /**\n     * @param string $thumb\n     */\n    abstract protected function createThumbnail($thumb);\n\n    /**\n     * @param array $attributes\n     * @return MediaLinkInterface\n     */\n    abstract protected function createLink(array $attributes);\n\n    /**\n     * @return array\n     */\n    abstract protected function getItems(): array;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/MediaPlayerTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse function in_array;\n\n/**\n * Class implements audio object interface.\n */\ntrait MediaPlayerTrait\n{\n    /**\n     * Allows to set or remove the HTML5 default controls\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function controls($status = true)\n    {\n        if ($status) {\n            $this->attributes['controls'] = 'controls';\n        } else {\n            unset($this->attributes['controls']);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Allows to set the loop attribute\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function loop($status = false)\n    {\n        if ($status) {\n            $this->attributes['loop'] = 'loop';\n        } else {\n            unset($this->attributes['loop']);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Allows to set the autoplay attribute\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function autoplay($status = false)\n    {\n        if ($status) {\n            $this->attributes['autoplay'] = 'autoplay';\n        } else {\n            unset($this->attributes['autoplay']);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Allows to set the muted attribute\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function muted($status = false)\n    {\n        if ($status) {\n            $this->attributes['muted'] = 'muted';\n        } else {\n            unset($this->attributes['muted']);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Allows to set the preload behaviour\n     *\n     * @param string|null $preload\n     * @return $this\n     */\n    public function preload($preload = null)\n    {\n        $validPreloadAttrs = ['auto', 'metadata', 'none'];\n\n        if (null === $preload) {\n            unset($this->attributes['preload']);\n        } elseif (in_array($preload, $validPreloadAttrs, true)) {\n            $this->attributes['preload'] = $preload;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Reset player.\n     */\n    public function resetPlayer()\n    {\n        $this->attributes['controls'] = 'controls';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/MediaTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse Grav\\Common\\Cache;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Media\\Interfaces\\MediaCollectionInterface;\nuse Grav\\Common\\Page\\Media;\nuse Psr\\SimpleCache\\CacheInterface;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function strlen;\n\n/**\n * Trait MediaTrait\n * @package Grav\\Common\\Media\\Traits\n */\ntrait MediaTrait\n{\n    /** @var MediaCollectionInterface|null */\n    protected $media;\n    /** @var bool */\n    protected $_loadMedia = true;\n\n    /**\n     * Get filesystem path to the associated media.\n     *\n     * @return string|null\n     */\n    abstract public function getMediaFolder();\n\n    /**\n     * Get display order for the associated media.\n     *\n     * @return array Empty array means default ordering.\n     */\n    public function getMediaOrder()\n    {\n        return [];\n    }\n\n    /**\n     * Get URI ot the associated media. Method will return null if path isn't URI.\n     *\n     * @return string|null\n     */\n    public function getMediaUri()\n    {\n        $folder = $this->getMediaFolder();\n        if (!$folder) {\n            return null;\n        }\n\n        if (strpos($folder, '://')) {\n            return $folder;\n        }\n\n       /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $user = $locator->findResource('user://');\n        if (strpos($folder, $user) === 0) {\n            return 'user://' . substr($folder, strlen($user)+1);\n        }\n\n        return null;\n    }\n\n    /**\n     * Gets the associated media collection.\n     *\n     * @return MediaCollectionInterface|Media  Representation of associated media.\n     */\n    public function getMedia()\n    {\n        $media = $this->media;\n        if (null === $media) {\n            $cache = $this->getMediaCache();\n            $cacheKey = md5('media' . $this->getCacheKey());\n\n            // Use cached media if possible.\n            $media = $cache->get($cacheKey);\n            if (!$media instanceof MediaCollectionInterface) {\n                $media = new Media($this->getMediaFolder(), $this->getMediaOrder(), $this->_loadMedia);\n                $cache->set($cacheKey, $media);\n            }\n\n            $this->media = $media;\n        }\n\n        return $media;\n    }\n\n    /**\n     * Sets the associated media collection.\n     *\n     * @param  MediaCollectionInterface|Media  $media Representation of associated media.\n     * @return $this\n     */\n    protected function setMedia(MediaCollectionInterface $media)\n    {\n        $cache = $this->getMediaCache();\n        $cacheKey = md5('media' . $this->getCacheKey());\n        $cache->set($cacheKey, $media);\n\n        $this->media = $media;\n\n        return $this;\n    }\n\n    /**\n     * @return void\n     */\n    protected function freeMedia()\n    {\n        $this->media = null;\n    }\n\n    /**\n     * Clear media cache.\n     *\n     * @return void\n     */\n    protected function clearMediaCache()\n    {\n        $cache = $this->getMediaCache();\n        $cacheKey = md5('media' . $this->getCacheKey());\n        $cache->delete($cacheKey);\n\n        $this->freeMedia();\n    }\n\n    /**\n     * @return CacheInterface\n     */\n    protected function getMediaCache()\n    {\n        /** @var Cache $cache */\n        $cache = Grav::instance()['cache'];\n\n        return $cache->getSimpleCache();\n    }\n\n    /**\n     * @return string\n     */\n    abstract protected function getCacheKey(): string;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/MediaUploadTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse Exception;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Medium\\Medium;\nuse Grav\\Common\\Page\\Medium\\MediumFactory;\nuse Grav\\Common\\Security;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Grav\\Framework\\Form\\FormFlashFile;\nuse Grav\\Framework\\Mime\\MimeTypes;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function dirname;\nuse function in_array;\n\n/**\n * Implements media upload and delete functionality.\n */\ntrait MediaUploadTrait\n{\n    /** @var array */\n    private $_upload_defaults = [\n        'self'              => true,        // Whether path is in the media collection path itself.\n        'avoid_overwriting' => false,       // Do not override existing files (adds datetime postfix if conflict).\n        'random_name'       => false,       // True if name needs to be randomized.\n        'accept'            => ['image/*'], // Accepted mime types or file extensions.\n        'limit'             => 10,          // Maximum number of files.\n        'filesize'          => null,        // Maximum filesize in MB.\n        'destination'       => null         // Destination path, if empty, exception is thrown.\n    ];\n\n    /**\n     * Create Medium from an uploaded file.\n     *\n     * @param  UploadedFileInterface $uploadedFile\n     * @param  array  $params\n     * @return Medium|null\n     */\n    public function createFromUploadedFile(UploadedFileInterface $uploadedFile, array $params = [])\n    {\n        return MediumFactory::fromUploadedFile($uploadedFile, $params);\n    }\n\n    /**\n     * Checks that uploaded file meets the requirements. Returns new filename.\n     *\n     * @example\n     *   $filename = null;  // Override filename if needed (ignored if randomizing filenames).\n     *   $settings = ['destination' => 'user://pages/media']; // Settings from the form field.\n     *   $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);\n     *   $media->copyUploadedFile($uploadedFile, $filename);\n     *\n     * @param UploadedFileInterface $uploadedFile\n     * @param string|null $filename\n     * @param array|null $settings\n     * @return string\n     * @throws RuntimeException\n     */\n    public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string\n    {\n        // Check if there is an upload error.\n        switch ($uploadedFile->getError()) {\n            case UPLOAD_ERR_OK:\n                break;\n            case UPLOAD_ERR_INI_SIZE:\n            case UPLOAD_ERR_FORM_SIZE:\n                throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_FILESIZE_LIMIT'), 400);\n            case UPLOAD_ERR_PARTIAL:\n            case UPLOAD_ERR_NO_FILE:\n                if (!$uploadedFile instanceof FormFlashFile) {\n                    throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILES_SENT'), 400);\n                }\n                break;\n            case UPLOAD_ERR_NO_TMP_DIR:\n                throw new RuntimeException($this->translate('PLUGIN_ADMIN.UPLOAD_ERR_NO_TMP_DIR'), 400);\n            case UPLOAD_ERR_CANT_WRITE:\n            case UPLOAD_ERR_EXTENSION:\n            default:\n                throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 400);\n        }\n\n        $metadata = [\n            'filename' => $uploadedFile->getClientFilename(),\n            'mime' => $uploadedFile->getClientMediaType(),\n            'size' => $uploadedFile->getSize(),\n        ];\n\n        if ($uploadedFile instanceof FormFlashFile) {\n            $uploadedFile->checkXss();\n        }\n\n        return $this->checkFileMetadata($metadata, $filename, $settings);\n    }\n\n    /**\n     * Checks that file metadata meets the requirements. Returns new filename.\n     *\n     * @param array $metadata\n     * @param array|null $settings\n     * @return string\n     * @throws RuntimeException\n     */\n    public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string\n    {\n        // Add the defaults to the settings.\n        $settings = $this->getUploadSettings($settings);\n\n        // Destination is always needed (but it can be set in defaults).\n        $self = $settings['self'] ?? false;\n        if (!isset($settings['destination']) && $self === false) {\n            throw new RuntimeException($this->translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED'), 400);\n        }\n\n        if (null === $filename) {\n            // If no filename is given, use the filename from the uploaded file (path is not allowed).\n            $folder = '';\n            $filename = $metadata['filename'] ?? '';\n        } else {\n            // If caller sets the filename, we will accept any custom path.\n            $folder = dirname($filename);\n            if ($folder === '.') {\n                $folder = '';\n            }\n            $filename = Utils::basename($filename);\n        }\n        $extension = Utils::pathinfo($filename, PATHINFO_EXTENSION);\n\n        // Decide which filename to use.\n        if ($settings['random_name']) {\n            // Generate random filename if asked for.\n            $filename = mb_strtolower(Utils::generateRandomString(15) . '.' . $extension);\n        }\n\n        // Handle conflicting filename if needed.\n        if ($settings['avoid_overwriting']) {\n            $destination = $settings['destination'];\n            if ($destination && $this->fileExists($filename, $destination)) {\n                $filename = date('YmdHis') . '-' . $filename;\n            }\n        }\n        $filepath = $folder . $filename;\n\n        // Check if the filename is allowed.\n        if (!Utils::checkFilename($filepath)) {\n            throw new RuntimeException(\n                sprintf($this->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD'), $filepath, $this->translate('PLUGIN_ADMIN.BAD_FILENAME'))\n            );\n        }\n\n        // Check if the file extension is allowed.\n        $extension = mb_strtolower($extension);\n        if (!$extension || !$this->getConfig()->get(\"media.types.{$extension}\")) {\n            // Not a supported type.\n            throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400);\n        }\n\n        // Calculate maximum file size (from MB).\n        $filesize = $settings['filesize'];\n        if ($filesize) {\n            $max_filesize = $filesize * 1048576;\n            if ($metadata['size'] > $max_filesize) {\n                // TODO: use own language string\n                throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400);\n            }\n        } elseif (null === $filesize) {\n            // Check size against the Grav upload limit.\n            $grav_limit = Utils::getUploadLimit();\n            if ($grav_limit > 0 && $metadata['size'] > $grav_limit) {\n                throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400);\n            }\n        }\n\n        $grav = Grav::instance();\n        /** @var MimeTypes $mimeChecker */\n        $mimeChecker = $grav['mime'];\n\n        // Handle Accepted file types. Accept can only be mime types (image/png | image/*) or file extensions (.pdf | .jpg)\n        // Do not trust mime type sent by the browser.\n        $mime = $metadata['mime'] ?? $mimeChecker->getMimeType($extension);\n        $validExtensions = $mimeChecker->getExtensions($mime);\n        if (!in_array($extension, $validExtensions, true)) {\n            throw new RuntimeException('The mime type does not match to file extension', 400);\n        }\n\n        $accepted = false;\n        $errors = [];\n        foreach ((array)$settings['accept'] as $type) {\n            // Force acceptance of any file when star notation\n            if ($type === '*') {\n                $accepted = true;\n                break;\n            }\n\n            $isMime = strstr($type, '/');\n            $find = str_replace(['.', '*', '+'], ['\\.', '.*', '\\+'], $type);\n\n            if ($isMime) {\n                $match = preg_match('#' . $find . '$#', $mime);\n                if (!$match) {\n                    // TODO: translate\n                    $errors[] = 'The MIME type \"' . $mime . '\" for the file \"' . $filepath . '\" is not an accepted.';\n                } else {\n                    $accepted = true;\n                    break;\n                }\n            } else {\n                $match = preg_match('#' . $find . '$#', $filename);\n                if (!$match) {\n                    // TODO: translate\n                    $errors[] = 'The File Extension for the file \"' . $filepath . '\" is not an accepted.';\n                } else {\n                    $accepted = true;\n                    break;\n                }\n            }\n        }\n        if (!$accepted) {\n            throw new RuntimeException(implode('<br />', $errors), 400);\n        }\n\n        return $filepath;\n    }\n\n    /**\n     * Copy uploaded file to the media collection.\n     *\n     * WARNING: Always check uploaded file before copying it!\n     *\n     * @example\n     *   $settings = ['destination' => 'user://pages/media']; // Settings from the form field.\n     *   $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);\n     *   $media->copyUploadedFile($uploadedFile, $filename, $settings);\n     *\n     * @param UploadedFileInterface $uploadedFile\n     * @param string $filename\n     * @param array|null $settings\n     * @return void\n     * @throws RuntimeException\n     */\n    public function copyUploadedFile(UploadedFileInterface $uploadedFile, string $filename, array $settings = null): void\n    {\n        // Add the defaults to the settings.\n        $settings = $this->getUploadSettings($settings);\n\n        $path = $settings['destination'] ?? $this->getPath();\n        if (!$path || !$filename) {\n            throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400);\n        }\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        try {\n            // Clear locator cache to make sure we have up to date information from the filesystem.\n            $locator->clearCache();\n            $this->clearCache();\n\n            $filesystem = Filesystem::getInstance(false);\n\n            // Calculate path without the retina scaling factor.\n            $basename = $filesystem->basename($filename);\n            $pathname = $filesystem->pathname($filename);\n\n            // Get name for the uploaded file.\n            [$base, $ext,,] = $this->getFileParts($basename);\n            $name = \"{$pathname}{$base}.{$ext}\";\n\n            // Upload file.\n            if ($uploadedFile instanceof FormFlashFile) {\n                // FormFlashFile needs some additional logic.\n                if ($uploadedFile->getError() === \\UPLOAD_ERR_OK) {\n                    // Move uploaded file.\n                    $this->doMoveUploadedFile($uploadedFile, $filename, $path);\n                } elseif (strpos($filename, 'original/') === 0 && !$this->fileExists($filename, $path) && $this->fileExists($basename, $path)) {\n                    // Original image support: override original image if it's the same as the uploaded image.\n                    $this->doCopy($basename, $filename, $path);\n                }\n\n                // FormFlashFile may also contain metadata.\n                $metadata = $uploadedFile->getMetaData();\n                if ($metadata) {\n                    // TODO: This overrides metadata if used with multiple retina image sizes.\n                    $this->doSaveMetadata(['upload' => $metadata], $name, $path);\n                }\n            } else {\n                // Not a FormFlashFile.\n                $this->doMoveUploadedFile($uploadedFile, $filename, $path);\n            }\n\n            // Post-processing: Special content sanitization for SVG.\n            $mime = Utils::getMimeByFilename($filename);\n            if (Utils::contains($mime, 'svg', false)) {\n                $this->doSanitizeSvg($filename, $path);\n            }\n\n            // Add the new file into the media.\n            // TODO: This overrides existing media sizes if used with multiple retina image sizes.\n            $this->doAddUploadedMedium($name, $filename, $path);\n\n        } catch (Exception $e) {\n            throw new RuntimeException($this->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE') . $e->getMessage(), 400);\n        } finally {\n            // Finally clear media cache.\n            $locator->clearCache();\n            $this->clearCache();\n        }\n    }\n\n    /**\n     * Delete real file from the media collection.\n     *\n     * @param string $filename\n     * @param array|null $settings\n     * @return void\n     * @throws RuntimeException\n     */\n    public function deleteFile(string $filename, array $settings = null): void\n    {\n        // Add the defaults to the settings.\n        $settings = $this->getUploadSettings($settings);\n        $filesystem = Filesystem::getInstance(false);\n\n        // First check for allowed filename.\n        $basename = $filesystem->basename($filename);\n        if (!Utils::checkFilename($basename)) {\n            throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . \": {$this->translate('PLUGIN_ADMIN.BAD_FILENAME')}: \" . $filename, 400);\n        }\n\n        $path = $settings['destination'] ?? $this->getPath();\n        if (!$path) {\n            return;\n        }\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n        $locator->clearCache();\n\n        $pathname = $filesystem->pathname($filename);\n\n        // Get base name of the file.\n        [$base, $ext,,] = $this->getFileParts($basename);\n        $name = \"{$pathname}{$base}.{$ext}\";\n\n        // Remove file and all all the associated metadata.\n        $this->doRemove($name, $path);\n\n        // Finally clear media cache.\n        $locator->clearCache();\n        $this->clearCache();\n    }\n\n    /**\n     * Rename file inside the media collection.\n     *\n     * @param string $from\n     * @param string $to\n     * @param array|null $settings\n     */\n    public function renameFile(string $from, string $to, array $settings = null): void\n    {\n        // Add the defaults to the settings.\n        $settings = $this->getUploadSettings($settings);\n        $filesystem = Filesystem::getInstance(false);\n\n        $path = $settings['destination'] ?? $this->getPath();\n        if (!$path) {\n            // TODO: translate error message\n            throw new RuntimeException('Failed to rename file: Bad destination', 400);\n        }\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n        $locator->clearCache();\n\n        // Get base name of the file.\n        $pathname = $filesystem->pathname($from);\n\n        // Remove @2x, @3x and .meta.yaml\n        [$base, $ext,,] = $this->getFileParts($filesystem->basename($from));\n        $from = \"{$pathname}{$base}.{$ext}\";\n\n        [$base, $ext,,] = $this->getFileParts($filesystem->basename($to));\n        $to = \"{$pathname}{$base}.{$ext}\";\n\n        $this->doRename($from, $to, $path);\n\n        // Finally clear media cache.\n        $locator->clearCache();\n        $this->clearCache();\n    }\n\n    /**\n     * Internal logic to move uploaded file.\n     *\n     * @param UploadedFileInterface $uploadedFile\n     * @param string $filename\n     * @param string $path\n     */\n    protected function doMoveUploadedFile(UploadedFileInterface $uploadedFile, string $filename, string $path): void\n    {\n        $filepath = sprintf('%s/%s', $path, $filename);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        // Do not use streams internally.\n        if ($locator->isStream($filepath)) {\n            $filepath = (string)$locator->findResource($filepath, true, true);\n        }\n\n        Folder::create(dirname($filepath));\n\n        $uploadedFile->moveTo($filepath);\n    }\n\n    /**\n     * Get upload settings.\n     *\n     * @param array|null $settings Form field specific settings (override).\n     * @return array\n     */\n    public function getUploadSettings(?array $settings = null): array\n    {\n        return null !== $settings ? $settings + $this->_upload_defaults : $this->_upload_defaults;\n    }\n\n    /**\n     * Internal logic to copy file.\n     *\n     * @param string $src\n     * @param string $dst\n     * @param string $path\n     */\n    protected function doCopy(string $src, string $dst, string $path): void\n    {\n        $src = sprintf('%s/%s', $path, $src);\n        $dst = sprintf('%s/%s', $path, $dst);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        // Do not use streams internally.\n        if ($locator->isStream($dst)) {\n            $dst = (string)$locator->findResource($dst, true, true);\n        }\n\n        Folder::create(dirname($dst));\n\n        copy($src, $dst);\n    }\n\n    /**\n     * Internal logic to rename file.\n     *\n     * @param string $from\n     * @param string $to\n     * @param string $path\n     */\n    protected function doRename(string $from, string $to, string $path): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        $fromPath = $path . '/' . $from;\n        if ($locator->isStream($fromPath)) {\n            $fromPath = $locator->findResource($fromPath, true, true);\n        }\n\n        if (!is_file($fromPath)) {\n            return;\n        }\n\n        $mediaPath = dirname($fromPath);\n        $toPath = $mediaPath . '/' . $to;\n        if ($locator->isStream($toPath)) {\n            $toPath = $locator->findResource($toPath, true, true);\n        }\n\n        if (is_file($toPath)) {\n            // TODO: translate error message\n            throw new RuntimeException(sprintf('File could not be renamed: %s already exists (%s)', $to, $mediaPath), 500);\n        }\n\n        $result = rename($fromPath, $toPath);\n        if (!$result) {\n            // TODO: translate error message\n            throw new RuntimeException(sprintf('File could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500);\n        }\n\n        // TODO: Add missing logic to handle retina files.\n        if (is_file($fromPath . '.meta.yaml')) {\n            $result = rename($fromPath . '.meta.yaml', $toPath . '.meta.yaml');\n            if (!$result) {\n                // TODO: translate error message\n                throw new RuntimeException(sprintf('Meta could not be renamed: %s -> %s (%s)', $from, $to, $mediaPath), 500);\n            }\n        }\n    }\n\n    /**\n     * Internal logic to remove file.\n     *\n     * @param string $filename\n     * @param string $path\n     */\n    protected function doRemove(string $filename, string $path): void\n    {\n        $filesystem = Filesystem::getInstance(false);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        // If path doesn't exist, there's nothing to do.\n        $pathname = $filesystem->pathname($filename);\n        if (!$this->fileExists($pathname, $path)) {\n            return;\n        }\n\n        $folder = $locator->isStream($path) ? (string)$locator->findResource($path, true, true) : $path;\n\n        // Remove requested media file.\n        if ($this->fileExists($filename, $path)) {\n            $result = unlink(\"{$folder}/{$filename}\");\n            if (!$result) {\n                throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);\n            }\n        }\n\n        // Remove associated metadata.\n        $this->doRemoveMetadata($filename, $path);\n\n        // Remove associated 2x, 3x and their .meta.yaml files.\n        $targetPath = rtrim(sprintf('%s/%s', $folder, $pathname), '/');\n        $dir = scandir($targetPath, SCANDIR_SORT_NONE);\n        if (false === $dir) {\n            throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);\n        }\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        $basename = $filesystem->basename($filename);\n        $fileParts = (array)$filesystem->pathinfo($filename);\n\n        foreach ($dir as $file) {\n            $preg_name = preg_quote($fileParts['filename'], '`');\n            $preg_ext = preg_quote($fileParts['extension'] ?? '.', '`');\n            $preg_filename = preg_quote($basename, '`');\n\n            if (preg_match(\"`({$preg_name}@\\d+x\\.{$preg_ext}(?:\\.meta\\.yaml)?$|{$preg_filename}\\.meta\\.yaml)$`\", $file)) {\n                $testPath = $targetPath . '/' . $file;\n                if ($locator->isStream($testPath)) {\n                    $testPath = (string)$locator->findResource($testPath, true, true);\n                    $locator->clearCache($testPath);\n                }\n\n                if (is_file($testPath)) {\n                    $result = unlink($testPath);\n                    if (!$result) {\n                        throw new RuntimeException($this->translate('PLUGIN_ADMIN.FILE_COULD_NOT_BE_DELETED') . ': ' . $filename, 500);\n                    }\n                }\n            }\n        }\n\n        $this->hide($filename);\n    }\n\n    /**\n     * @param array $metadata\n     * @param string $filename\n     * @param string $path\n     */\n    protected function doSaveMetadata(array $metadata, string $filename, string $path): void\n    {\n        $filepath = sprintf('%s/%s', $path, $filename);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        // Do not use streams internally.\n        if ($locator->isStream($filepath)) {\n            $filepath = (string)$locator->findResource($filepath, true, true);\n        }\n\n        $file = YamlFile::instance($filepath . '.meta.yaml');\n        $file->save($metadata);\n    }\n\n    /**\n     * @param string $filename\n     * @param string $path\n     */\n    protected function doRemoveMetadata(string $filename, string $path): void\n    {\n        $filepath = sprintf('%s/%s', $path, $filename);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        // Do not use streams internally.\n        if ($locator->isStream($filepath)) {\n            $filepath = (string)$locator->findResource($filepath, true);\n            if (!$filepath) {\n                return;\n            }\n        }\n\n        $file = YamlFile::instance($filepath . '.meta.yaml');\n        if ($file->exists()) {\n            $file->delete();\n        }\n    }\n\n    /**\n     * @param string $filename\n     * @param string $path\n     */\n    protected function doSanitizeSvg(string $filename, string $path): void\n    {\n        $filepath = sprintf('%s/%s', $path, $filename);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n\n        // Do not use streams internally.\n        if ($locator->isStream($filepath)) {\n            $filepath = (string)$locator->findResource($filepath, true, true);\n        }\n\n        Security::sanitizeSVG($filepath);\n    }\n\n    /**\n     * @param string $name\n     * @param string $filename\n     * @param string $path\n     */\n    protected function doAddUploadedMedium(string $name, string $filename, string $path): void\n    {\n        $filepath = sprintf('%s/%s', $path, $filename);\n        $medium = $this->createFromFile($filepath);\n        $realpath = $path . '/' . $name;\n        $this->add($realpath, $medium);\n    }\n\n    /**\n     * @param string $string\n     * @return string\n     */\n    protected function translate(string $string): string\n    {\n        return $this->getLanguage()->translate($string);\n    }\n\n    abstract protected function getPath(): ?string;\n\n    abstract protected function getGrav(): Grav;\n\n    abstract protected function getConfig(): Config;\n\n    abstract protected function getLanguage(): Language;\n\n    abstract protected function clearCache(): void;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/StaticResizeTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\n/**\n * Trait StaticResizeTrait\n * @package Grav\\Common\\Media\\Traits\n */\ntrait StaticResizeTrait\n{\n    /**\n     * Resize media by setting attributes\n     *\n     * @param  int|null $width\n     * @param  int|null $height\n     * @return $this\n     */\n    public function resize($width = null, $height = null)\n    {\n        if ($width) {\n            $this->styleAttributes['width'] = $width . 'px';\n        } else {\n            unset($this->styleAttributes['width']);\n        }\n        if ($height) {\n            $this->styleAttributes['height'] = $height . 'px';\n        } else {\n            unset($this->styleAttributes['height']);\n        }\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/ThumbnailMediaTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\nuse BadMethodCallException;\nuse Grav\\Common\\Media\\Interfaces\\MediaLinkInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaObjectInterface;\nuse function get_class;\nuse function is_callable;\n\n/**\n * Trait ThumbnailMediaTrait\n * @package Grav\\Common\\Media\\Traits\n */\ntrait ThumbnailMediaTrait\n{\n    /** @var MediaObjectInterface|null */\n    public $parent;\n\n    /** @var bool */\n    public $linked = false;\n\n    /**\n     * Return srcset string for this Medium and its alternatives.\n     *\n     * @param bool $reset\n     * @return string\n     */\n    public function srcset($reset = true)\n    {\n        return '';\n    }\n\n    /**\n     * Get an element (is array) that can be rendered by the Parsedown engine\n     *\n     * @param string|null $title\n     * @param string|null $alt\n     * @param string|null $class\n     * @param string|null $id\n     * @param bool $reset\n     * @return array\n     */\n    public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true)\n    {\n        return $this->bubble('parsedownElement', [$title, $alt, $class, $id, $reset]);\n    }\n\n    /**\n     * Return HTML markup from the medium.\n     *\n     * @param string|null $title\n     * @param string|null $alt\n     * @param string|null $class\n     * @param string|null $id\n     * @param bool $reset\n     * @return string\n     */\n    public function html($title = null, $alt = null, $class = null, $id = null, $reset = true)\n    {\n        return $this->bubble('html', [$title, $alt, $class, $id, $reset]);\n    }\n\n    /**\n     * Switch display mode.\n     *\n     * @param string $mode\n     *\n     * @return MediaLinkInterface|MediaObjectInterface|null\n     */\n    public function display($mode = 'source')\n    {\n        return $this->bubble('display', [$mode], false);\n    }\n\n    /**\n     * Switch thumbnail.\n     *\n     * @param string $type\n     *\n     * @return MediaLinkInterface|MediaObjectInterface\n     */\n    public function thumbnail($type = 'auto')\n    {\n        $this->bubble('thumbnail', [$type], false);\n\n        return $this->bubble('getThumbnail', [], false);\n    }\n\n    /**\n     * Turn the current Medium into a Link\n     *\n     * @param  bool $reset\n     * @param  array  $attributes\n     * @return MediaLinkInterface\n     */\n    public function link($reset = true, array $attributes = [])\n    {\n        return $this->bubble('link', [$reset, $attributes], false);\n    }\n\n    /**\n     * Turn the current Medium into a Link with lightbox enabled\n     *\n     * @param  int|null  $width\n     * @param  int|null  $height\n     * @param  bool $reset\n     * @return MediaLinkInterface\n     */\n    public function lightbox($width = null, $height = null, $reset = true)\n    {\n        return $this->bubble('lightbox', [$width, $height, $reset], false);\n    }\n\n    /**\n     * Bubble a function call up to either the superclass function or the parent Medium instance\n     *\n     * @param  string  $method\n     * @param  array  $arguments\n     * @param  bool $testLinked\n     * @return mixed\n     */\n    protected function bubble($method, array $arguments = [], $testLinked = true)\n    {\n        if (!$testLinked || $this->linked) {\n            $parent = $this->parent;\n            if (null === $parent) {\n                return $this;\n            }\n\n            $closure = [$parent, $method];\n\n            if (!is_callable($closure)) {\n                throw new BadMethodCallException(get_class($parent) . '::' . $method . '() not found.');\n            }\n\n            return $closure(...$arguments);\n        }\n\n        return parent::{$method}(...$arguments);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Media/Traits/VideoMediaTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Media\\Traits;\n\n/**\n * Trait VideoMediaTrait\n * @package Grav\\Common\\Media\\Traits\n */\ntrait VideoMediaTrait\n{\n    use StaticResizeTrait;\n    use MediaPlayerTrait;\n\n    /**\n     * Allows to set the video's poster image\n     *\n     * @param string $urlImage\n     * @return $this\n     */\n    public function poster($urlImage)\n    {\n        $this->attributes['poster'] = $urlImage;\n\n        return $this;\n    }\n\n    /**\n     * Allows to set the playsinline attribute\n     *\n     * @param bool $status\n     * @return $this\n     */\n    public function playsinline($status = false)\n    {\n        if ($status) {\n            $this->attributes['playsinline'] = 'playsinline';\n        } else {\n            unset($this->attributes['playsinline']);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Parsedown element for source display mode\n     *\n     * @param  array $attributes\n     * @param  bool $reset\n     * @return array\n     */\n    protected function sourceParsedownElement(array $attributes, $reset = true)\n    {\n        $location = $this->url($reset);\n\n        return [\n            'name' => 'video',\n            'rawHtml' => '<source src=\"' . $location . '\">Your browser does not support the video tag.',\n            'attributes' => $attributes\n        ];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Collection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page;\n\nuse Exception;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Iterator;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Utils;\nuse InvalidArgumentException;\nuse function array_key_exists;\nuse function array_keys;\nuse function array_search;\nuse function count;\nuse function in_array;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class Collection\n * @package Grav\\Common\\Page\n * @implements PageCollectionInterface<string,Page>\n */\nclass Collection extends Iterator implements PageCollectionInterface\n{\n    /** @var Pages */\n    protected $pages;\n    /** @var array */\n    protected $params;\n\n    /**\n     * Collection constructor.\n     *\n     * @param array      $items\n     * @param array      $params\n     * @param Pages|null $pages\n     */\n    public function __construct($items = [], array $params = [], Pages $pages = null)\n    {\n        parent::__construct($items);\n\n        $this->params = $params;\n        $this->pages = $pages ?: Grav::instance()->offsetGet('pages');\n    }\n\n    /**\n     * Get the collection params\n     *\n     * @return array\n     */\n    public function params()\n    {\n        return $this->params;\n    }\n\n    /**\n     * Set parameters to the Collection\n     *\n     * @param array $params\n     * @return $this\n     */\n    public function setParams(array $params)\n    {\n        $this->params = array_merge($this->params, $params);\n\n        return $this;\n    }\n\n    /**\n     * Add a single page to a collection\n     *\n     * @param PageInterface $page\n     * @return $this\n     */\n    public function addPage(PageInterface $page)\n    {\n        $this->items[$page->path()] = ['slug' => $page->slug()];\n\n        return $this;\n    }\n\n    /**\n     * Add a page with path and slug\n     *\n     * @param string $path\n     * @param string $slug\n     * @return $this\n     */\n    public function add($path, $slug)\n    {\n        $this->items[$path] = ['slug' => $slug];\n\n        return $this;\n    }\n\n    /**\n     *\n     * Create a copy of this collection\n     *\n     * @return static\n     */\n    public function copy()\n    {\n        return new static($this->items, $this->params, $this->pages);\n    }\n\n    /**\n     *\n     * Merge another collection with the current collection\n     *\n     * @param PageCollectionInterface $collection\n     * @return $this\n     */\n    public function merge(PageCollectionInterface $collection)\n    {\n        foreach ($collection as $page) {\n            $this->addPage($page);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Intersect another collection with the current collection\n     *\n     * @param PageCollectionInterface $collection\n     * @return $this\n     */\n    public function intersect(PageCollectionInterface $collection)\n    {\n        $array1 = $this->items;\n        $array2 = $collection->toArray();\n\n        $this->items = array_uintersect($array1, $array2, function ($val1, $val2) {\n            return strcmp($val1['slug'], $val2['slug']);\n        });\n\n        return $this;\n    }\n\n    /**\n     * Set current page.\n     */\n    public function setCurrent(string $path): void\n    {\n        reset($this->items);\n\n        while (($key = key($this->items)) !== null && $key !== $path) {\n            next($this->items);\n        }\n    }\n\n    /**\n     * Returns current page.\n     *\n     * @return PageInterface\n     */\n    #[\\ReturnTypeWillChange]\n    public function current()\n    {\n        $current = parent::key();\n\n        return $this->pages->get($current);\n    }\n\n    /**\n     * Returns current slug.\n     *\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function key()\n    {\n        $current = parent::current();\n\n        return $current['slug'];\n    }\n\n    /**\n     * Returns the value at specified offset.\n     *\n     * @param string $offset\n     * @return PageInterface|null\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        return $this->pages->get($offset) ?: null;\n    }\n\n    /**\n     * Split collection into array of smaller collections.\n     *\n     * @param int $size\n     * @return Collection[]\n     */\n    public function batch($size)\n    {\n        $chunks = array_chunk($this->items, $size, true);\n\n        $list = [];\n        foreach ($chunks as $chunk) {\n            $list[] = new static($chunk, $this->params, $this->pages);\n        }\n\n        return $list;\n    }\n\n    /**\n     * Remove item from the list.\n     *\n     * @param PageInterface|string|null $key\n     * @return $this\n     * @throws InvalidArgumentException\n     */\n    public function remove($key = null)\n    {\n        if ($key instanceof PageInterface) {\n            $key = $key->path();\n        } elseif (null === $key) {\n            $key = (string)key($this->items);\n        }\n        if (!is_string($key)) {\n            throw new InvalidArgumentException('Invalid argument $key.');\n        }\n\n        parent::remove($key);\n\n        return $this;\n    }\n\n    /**\n     * Reorder collection.\n     *\n     * @param string $by\n     * @param string $dir\n     * @param array|null  $manual\n     * @param string|null $sort_flags\n     * @return $this\n     */\n    public function order($by, $dir = 'asc', $manual = null, $sort_flags = null)\n    {\n        $this->items = $this->pages->sortCollection($this, $by, $dir, $manual, $sort_flags);\n\n        return $this;\n    }\n\n    /**\n     * Check to see if this item is the first in the collection.\n     *\n     * @param  string $path\n     * @return bool True if item is first.\n     */\n    public function isFirst($path): bool\n    {\n        return $this->items && $path === array_keys($this->items)[0];\n    }\n\n    /**\n     * Check to see if this item is the last in the collection.\n     *\n     * @param  string $path\n     * @return bool True if item is last.\n     */\n    public function isLast($path): bool\n    {\n        return $this->items && $path === array_keys($this->items)[count($this->items) - 1];\n    }\n\n    /**\n     * Gets the previous sibling based on current position.\n     *\n     * @param  string $path\n     *\n     * @return PageInterface  The previous item.\n     */\n    public function prevSibling($path)\n    {\n        return $this->adjacentSibling($path, -1);\n    }\n\n    /**\n     * Gets the next sibling based on current position.\n     *\n     * @param  string $path\n     *\n     * @return PageInterface The next item.\n     */\n    public function nextSibling($path)\n    {\n        return $this->adjacentSibling($path, 1);\n    }\n\n    /**\n     * Returns the adjacent sibling based on a direction.\n     *\n     * @param  string  $path\n     * @param  int $direction either -1 or +1\n     * @return PageInterface|Collection    The sibling item.\n     */\n    public function adjacentSibling($path, $direction = 1)\n    {\n        $values = array_keys($this->items);\n        $keys = array_flip($values);\n\n        if (array_key_exists($path, $keys)) {\n            $index = $keys[$path] - $direction;\n\n            return isset($values[$index]) ? $this->offsetGet($values[$index]) : $this;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Returns the item in the current position.\n     *\n     * @param  string $path the path the item\n     * @return int|null The index of the current page, null if not found.\n     */\n    public function currentPosition($path): ?int\n    {\n        $pos = array_search($path, array_keys($this->items), true);\n\n        return $pos !== false ? $pos : null;\n    }\n\n    /**\n     * Returns the items between a set of date ranges of either the page date field (default) or\n     * an arbitrary datetime page field where start date and end date are optional\n     * Dates must be passed in as text that strtotime() can process\n     * http://php.net/manual/en/function.strtotime.php\n     *\n     * @param string|null $startDate\n     * @param string|null $endDate\n     * @param string|null $field\n     * @return $this\n     * @throws Exception\n     */\n    public function dateRange($startDate = null, $endDate = null, $field = null)\n    {\n        $start = $startDate ? Utils::date2timestamp($startDate) : null;\n        $end = $endDate ? Utils::date2timestamp($endDate) : null;\n\n        $date_range = [];\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if (!$page) {\n                continue;\n            }\n\n            $date = $field ? strtotime($page->value($field)) : $page->date();\n\n            if ((!$start || $date >= $start) && (!$end || $date <= $end)) {\n                $date_range[$path] = $slug;\n            }\n        }\n\n        $this->items = $date_range;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only visible pages\n     *\n     * @return Collection The collection with only visible pages\n     */\n    public function visible()\n    {\n        $visible = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && $page->visible()) {\n                $visible[$path] = $slug;\n            }\n        }\n        $this->items = $visible;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only non-visible pages\n     *\n     * @return Collection The collection with only non-visible pages\n     */\n    public function nonVisible()\n    {\n        $visible = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && !$page->visible()) {\n                $visible[$path] = $slug;\n            }\n        }\n        $this->items = $visible;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only pages\n     *\n     * @return Collection The collection with only pages\n     */\n    public function pages()\n    {\n        $modular = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && !$page->isModule()) {\n                $modular[$path] = $slug;\n            }\n        }\n        $this->items = $modular;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only modules\n     *\n     * @return Collection The collection with only modules\n     */\n    public function modules()\n    {\n        $modular = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && $page->isModule()) {\n                $modular[$path] = $slug;\n            }\n        }\n        $this->items = $modular;\n\n        return $this;\n    }\n\n    /**\n     * Alias of pages()\n     *\n     * @return Collection The collection with only non-module pages\n     */\n    public function nonModular()\n    {\n        $this->pages();\n\n        return $this;\n    }\n\n    /**\n     * Alias of modules()\n     *\n     * @return Collection The collection with only modules\n     */\n    public function modular()\n    {\n        $this->modules();\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only translated pages\n     *\n     * @return Collection The collection with only published pages\n     * @internal\n     */\n    public function translated()\n    {\n        $published = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && $page->translated()) {\n                $published[$path] = $slug;\n            }\n        }\n        $this->items = $published;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only untranslated pages\n     *\n     * @return Collection The collection with only non-published pages\n     * @internal\n     */\n    public function nonTranslated()\n    {\n        $published = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && !$page->translated()) {\n                $published[$path] = $slug;\n            }\n        }\n        $this->items = $published;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only published pages\n     *\n     * @return Collection The collection with only published pages\n     */\n    public function published()\n    {\n        $published = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && $page->published()) {\n                $published[$path] = $slug;\n            }\n        }\n        $this->items = $published;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only non-published pages\n     *\n     * @return Collection The collection with only non-published pages\n     */\n    public function nonPublished()\n    {\n        $published = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && !$page->published()) {\n                $published[$path] = $slug;\n            }\n        }\n        $this->items = $published;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only routable pages\n     *\n     * @return Collection The collection with only routable pages\n     */\n    public function routable()\n    {\n        $routable = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n\n            if ($page !== null && $page->routable()) {\n                $routable[$path] = $slug;\n            }\n        }\n\n        $this->items = $routable;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only non-routable pages\n     *\n     * @return Collection The collection with only non-routable pages\n     */\n    public function nonRoutable()\n    {\n        $routable = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && !$page->routable()) {\n                $routable[$path] = $slug;\n            }\n        }\n        $this->items = $routable;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only pages of the specified type\n     *\n     * @param string $type\n     * @return Collection The collection\n     */\n    public function ofType($type)\n    {\n        $items = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && $page->template() === $type) {\n                $items[$path] = $slug;\n            }\n        }\n\n        $this->items = $items;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only pages of one of the specified types\n     *\n     * @param string[] $types\n     * @return Collection The collection\n     */\n    public function ofOneOfTheseTypes($types)\n    {\n        $items = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n            if ($page !== null && in_array($page->template(), $types, true)) {\n                $items[$path] = $slug;\n            }\n        }\n\n        $this->items = $items;\n\n        return $this;\n    }\n\n    /**\n     * Creates new collection with only pages of one of the specified access levels\n     *\n     * @param array $accessLevels\n     * @return Collection The collection\n     */\n    public function ofOneOfTheseAccessLevels($accessLevels)\n    {\n        $items = [];\n\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n\n            if ($page !== null && isset($page->header()->access)) {\n                if (is_array($page->header()->access)) {\n                    //Multiple values for access\n                    $valid = false;\n\n                    foreach ($page->header()->access as $index => $accessLevel) {\n                        if (is_array($accessLevel)) {\n                            foreach ($accessLevel as $innerIndex => $innerAccessLevel) {\n                                if (in_array($innerAccessLevel, $accessLevels, false)) {\n                                    $valid = true;\n                                }\n                            }\n                        } else {\n                            if (in_array($index, $accessLevels, false)) {\n                                $valid = true;\n                            }\n                        }\n                    }\n                    if ($valid) {\n                        $items[$path] = $slug;\n                    }\n                } else {\n                    //Single value for access\n                    if (in_array($page->header()->access, $accessLevels, false)) {\n                        $items[$path] = $slug;\n                    }\n                }\n            }\n        }\n\n        $this->items = $items;\n\n        return $this;\n    }\n\n    /**\n     * Get the extended version of this Collection with each page keyed by route\n     *\n     * @return array\n     * @throws Exception\n     */\n    public function toExtendedArray()\n    {\n        $items  = [];\n        foreach ($this->items as $path => $slug) {\n            $page = $this->pages->get($path);\n\n            if ($page !== null) {\n                $items[$page->route()] = $page->toArray();\n            }\n        }\n        return $items;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Header.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page;\n\nuse ArrayAccess;\nuse JsonSerializable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Constructor;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Export;\nuse RocketTheme\\Toolbox\\ArrayTraits\\ExportInterface;\nuse RocketTheme\\Toolbox\\ArrayTraits\\NestedArrayAccessWithGetters;\n\n/**\n * Class Header\n * @package Grav\\Common\\Page\n */\nclass Header implements ArrayAccess, ExportInterface, JsonSerializable\n{\n    use NestedArrayAccessWithGetters, Constructor, Export;\n\n    /** @var array */\n    protected $items;\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->toArray();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Interfaces/PageCollectionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Interfaces;\n\nuse ArrayAccess;\nuse Countable;\nuse Exception;\nuse InvalidArgumentException;\nuse Serializable;\nuse Traversable;\n\n/**\n * Interface PageCollectionInterface\n * @package Grav\\Common\\Page\\Interfaces\n *\n * @template TKey of array-key\n * @template T\n * @extends Traversable<TKey,T>\n * @extends ArrayAccess<TKey|null,T>\n */\ninterface PageCollectionInterface extends Traversable, ArrayAccess, Countable, Serializable\n{\n    /**\n     * Get the collection params\n     *\n     * @return array\n     */\n    public function params();\n\n    /**\n     * Set parameters to the Collection\n     *\n     * @param array $params\n     * @return $this\n     */\n    public function setParams(array $params);\n\n    /**\n     * Add a single page to a collection\n     *\n     * @param PageInterface $page\n     * @return $this\n     */\n    public function addPage(PageInterface $page);\n\n    /**\n     * Add a page with path and slug\n     *\n     * @param string $path\n     * @param string $slug\n     * @return $this\n     */\n    //public function add($path, $slug);\n\n    /**\n     *\n     * Create a copy of this collection\n     *\n     * @return static\n     */\n    public function copy();\n\n    /**\n     *\n     * Merge another collection with the current collection\n     *\n     * @param PageCollectionInterface $collection\n     * @return PageCollectionInterface\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function merge(PageCollectionInterface $collection);\n\n    /**\n     * Intersect another collection with the current collection\n     *\n     * @param PageCollectionInterface $collection\n     * @return PageCollectionInterface\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function intersect(PageCollectionInterface $collection);\n\n    /**\n     * Split collection into array of smaller collections.\n     *\n     * @param int $size\n     * @return PageCollectionInterface[]\n     * @phpstan-return array<PageCollectionInterface<TKey,T>>\n     */\n    public function batch($size);\n\n    /**\n     * Remove item from the list.\n     *\n     * @param PageInterface|string|null $key\n     * @return PageCollectionInterface\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     * @throws InvalidArgumentException\n     */\n    //public function remove($key = null);\n\n    /**\n     * Reorder collection.\n     *\n     * @param string $by\n     * @param string $dir\n     * @param array|null  $manual\n     * @param string|null $sort_flags\n     * @return PageCollectionInterface\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function order($by, $dir = 'asc', $manual = null, $sort_flags = null);\n\n    /**\n     * Check to see if this item is the first in the collection.\n     *\n     * @param  string $path\n     * @return bool True if item is first.\n     */\n    public function isFirst($path): bool;\n\n    /**\n     * Check to see if this item is the last in the collection.\n     *\n     * @param  string $path\n     * @return bool True if item is last.\n     */\n    public function isLast($path): bool;\n\n    /**\n     * Gets the previous sibling based on current position.\n     *\n     * @param  string $path\n     * @return PageInterface  The previous item.\n     * @phpstan-return T\n     */\n    public function prevSibling($path);\n\n    /**\n     * Gets the next sibling based on current position.\n     *\n     * @param  string $path\n     * @return PageInterface The next item.\n     * @phpstan-return T\n     */\n    public function nextSibling($path);\n\n    /**\n     * Returns the adjacent sibling based on a direction.\n     *\n     * @param  string  $path\n     * @param  int $direction either -1 or +1\n     * @return PageInterface|PageCollectionInterface|false    The sibling item.\n     * @phpstan-return T|false\n     */\n    public function adjacentSibling($path, $direction = 1);\n\n    /**\n     * Returns the item in the current position.\n     *\n     * @param  string $path the path the item\n     * @return int|null The index of the current page, null if not found.\n     */\n    public function currentPosition($path): ?int;\n\n    /**\n     * Returns the items between a set of date ranges of either the page date field (default) or\n     * an arbitrary datetime page field where start date and end date are optional\n     * Dates must be passed in as text that strtotime() can process\n     * http://php.net/manual/en/function.strtotime.php\n     *\n     * @param string|null $startDate\n     * @param string|null $endDate\n     * @param string|null $field\n     * @return PageCollectionInterface\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     * @throws Exception\n     */\n    public function dateRange($startDate = null, $endDate = null, $field = null);\n\n    /**\n     * Creates new collection with only visible pages\n     *\n     * @return PageCollectionInterface The collection with only visible pages\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function visible();\n\n    /**\n     * Creates new collection with only non-visible pages\n     *\n     * @return PageCollectionInterface The collection with only non-visible pages\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function nonVisible();\n\n    /**\n     * Creates new collection with only pages\n     *\n     * @return PageCollectionInterface The collection with only pages\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function pages();\n\n    /**\n     * Creates new collection with only modules\n     *\n     * @return PageCollectionInterface The collection with only modules\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function modules();\n\n    /**\n     * Creates new collection with only modules\n     *\n     * @return PageCollectionInterface The collection with only modules\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     * @deprecated 1.7 Use $this->modules() instead\n     */\n    public function modular();\n\n    /**\n     * Creates new collection with only non-module pages\n     *\n     * @return PageCollectionInterface The collection with only non-module pages\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     * @deprecated 1.7 Use $this->pages() instead\n     */\n    public function nonModular();\n\n    /**\n     * Creates new collection with only published pages\n     *\n     * @return PageCollectionInterface The collection with only published pages\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function published();\n\n    /**\n     * Creates new collection with only non-published pages\n     *\n     * @return PageCollectionInterface The collection with only non-published pages\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function nonPublished();\n\n    /**\n     * Creates new collection with only routable pages\n     *\n     * @return PageCollectionInterface The collection with only routable pages\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function routable();\n\n    /**\n     * Creates new collection with only non-routable pages\n     *\n     * @return PageCollectionInterface The collection with only non-routable pages\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function nonRoutable();\n\n    /**\n     * Creates new collection with only pages of the specified type\n     *\n     * @param string $type\n     * @return PageCollectionInterface The collection\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function ofType($type);\n\n    /**\n     * Creates new collection with only pages of one of the specified types\n     *\n     * @param string[] $types\n     * @return PageCollectionInterface The collection\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function ofOneOfTheseTypes($types);\n\n    /**\n     * Creates new collection with only pages of one of the specified access levels\n     *\n     * @param array $accessLevels\n     * @return PageCollectionInterface The collection\n     * @phpstan-return PageCollectionInterface<TKey,T>\n     */\n    public function ofOneOfTheseAccessLevels($accessLevels);\n\n    /**\n     * Converts collection into an array.\n     *\n     * @return array\n     */\n    public function toArray();\n\n    /**\n     * Get the extended version of this Collection with each page keyed by route\n     *\n     * @return array\n     * @throws Exception\n     */\n    public function toExtendedArray();\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Interfaces/PageContentInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Interfaces;\n\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Media\\Interfaces\\MediaCollectionInterface;\nuse Grav\\Common\\Page\\Header;\n\n/**\n * Methods currently implemented in Flex Page emulation layer.\n */\ninterface PageContentInterface\n{\n    /**\n     * Gets and Sets the header based on the YAML configuration at the top of the .md file\n     *\n     * @param  object|array|null $var a YAML object representing the configuration for the file\n     * @return \\stdClass|Header      The current YAML configuration\n     */\n    public function header($var = null);\n\n    /**\n     * Get the summary.\n     *\n     * @param int|null $size Max summary size.\n     * @param bool $textOnly Only count text size.\n     * @return string\n     */\n    public function summary($size = null, $textOnly = false);\n\n    /**\n     * Sets the summary of the page\n     *\n     * @param string $summary Summary\n     */\n    public function setSummary($summary);\n\n    /**\n     * Gets and Sets the content based on content portion of the .md file\n     *\n     * @param  string|null $var Content\n     * @return string      Content\n     */\n    public function content($var = null);\n\n    /**\n     * Needed by the onPageContentProcessed event to get the raw page content\n     *\n     * @return string   the current page content\n     */\n    public function getRawContent();\n\n    /**\n     * Needed by the onPageContentProcessed event to set the raw page content\n     *\n     * @param string|null $content\n     */\n    public function setRawContent($content);\n\n    /**\n     * Gets and Sets the Page raw content\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function rawMarkdown($var = null);\n\n    /**\n     * Get value from a page variable (used mostly for creating edit forms).\n     *\n     * @param string $name Variable name.\n     * @param mixed|null $default\n     * @return mixed\n     */\n    public function value($name, $default = null);\n\n    /**\n     * Gets and sets the associated media as found in the page folder.\n     *\n     * @param  MediaCollectionInterface|null $var New media object.\n     * @return MediaCollectionInterface           Representation of associated media.\n     */\n    public function media($var = null);\n\n    /**\n     * Gets and sets the title for this Page.  If no title is set, it will use the slug() to get a name\n     *\n     * @param  string|null $var New title of the Page\n     * @return string           The title of the Page\n     */\n    public function title($var = null);\n\n    /**\n     * Gets and sets the menu name for this Page.  This is the text that can be used specifically for navigation.\n     * If no menu field is set, it will use the title()\n     *\n     * @param  string|null $var New menu field for the page\n     * @return string           The menu field for the page\n     */\n    public function menu($var = null);\n\n    /**\n     * Gets and Sets whether or not this Page is visible for navigation\n     *\n     * @param  bool|null $var   New value\n     * @return bool             True if the page is visible\n     */\n    public function visible($var = null);\n\n    /**\n     * Gets and Sets whether or not this Page is considered published\n     *\n     * @param  bool|null $var   New value\n     * @return bool             True if the page is published\n     */\n    public function published($var = null);\n\n    /**\n     * Gets and Sets the Page publish date\n     *\n     * @param  string|null $var String representation of the new date\n     * @return int              Unix timestamp representation of the date\n     */\n    public function publishDate($var = null);\n\n    /**\n     * Gets and Sets the Page unpublish date\n     *\n     * @param  string|null $var String representation of the new date\n     * @return int|null         Unix timestamp representation of the date\n     */\n    public function unpublishDate($var = null);\n\n    /**\n     * Gets and Sets the process setup for this Page. This is multi-dimensional array that consists of\n     * a simple array of arrays with the form array(\"markdown\"=>true) for example\n     *\n     * @param  array|null $var New array of name value pairs where the name is the process and value is true or false\n     * @return array            Array of name value pairs where the name is the process and value is true or false\n     */\n    public function process($var = null);\n\n    /**\n     * Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses\n     * the parent folder from the path\n     *\n     * @param  string|null $var New slug, e.g. 'my-blog'\n     * @return string           The slug\n     */\n    public function slug($var = null);\n\n    /**\n     * Get/set order number of this page.\n     *\n     * @param int|null $var      New order as a number\n     * @return string|bool       Order in a form of '02.' or false if not set\n     */\n    public function order($var = null);\n\n    /**\n     * Gets and sets the identifier for this Page object.\n     *\n     * @param  string|null $var New identifier\n     * @return string           The identifier\n     */\n    public function id($var = null);\n\n    /**\n     * Gets and sets the modified timestamp.\n     *\n     * @param  int|null $var New modified unix timestamp\n     * @return int           Modified unix timestamp\n     */\n    public function modified($var = null);\n\n    /**\n     * Gets and sets the option to show the last_modified header for the page.\n     *\n     * @param  bool|null $var New last_modified header value\n     * @return bool           Show last_modified header\n     */\n    public function lastModified($var = null);\n\n    /**\n     * Get/set the folder.\n     *\n     * @param string|null $var New folder\n     * @return string|null     The folder\n     */\n    public function folder($var = null);\n\n    /**\n     * Gets and sets the date for this Page object. This is typically passed in via the page headers\n     *\n     * @param  string|null $var New string representation of a date\n     * @return int              Unix timestamp representation of the date\n     */\n    public function date($var = null);\n\n    /**\n     * Gets and sets the date format for this Page object. This is typically passed in via the page headers\n     * using typical PHP date string structure - http://php.net/manual/en/function.date.php\n     *\n     * @param  string|null $var New string representation of a date format\n     * @return string           String representation of a date format\n     */\n    public function dateformat($var = null);\n\n    /**\n     * Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with.\n     *\n     * @param  array|null $var  New array of taxonomies\n     * @return array            An array of taxonomies\n     */\n    public function taxonomy($var = null);\n\n    /**\n     * Gets the configured state of the processing method.\n     *\n     * @param  string $process The process name, eg \"twig\" or \"markdown\"\n     * @return bool            Whether or not the processing method is enabled for this Page\n     */\n    public function shouldProcess($process);\n\n    /**\n     * Returns true if page is a module.\n     *\n     * @return bool\n     */\n    public function isModule(): bool;\n\n    /**\n     * Returns whether or not this Page object has a .md file associated with it or if its just a directory.\n     *\n     * @return bool True if its a page with a .md file associated\n     */\n    public function isPage();\n\n    /**\n     * Returns whether or not this Page object is a directory or a page.\n     *\n     * @return bool True if its a directory\n     */\n    public function isDir();\n\n    /**\n     * Returns whether the page exists in the filesystem.\n     *\n     * @return bool\n     */\n    public function exists();\n\n    /**\n     * Returns the blueprint from the page.\n     *\n     * @param string $name Name of the Blueprint form. Used by flex only.\n     * @return Blueprint Returns a Blueprint.\n     */\n    public function getBlueprint(string $name = '');\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Interfaces/PageFormInterface.php",
    "content": "<?php\nnamespace Grav\\Common\\Page\\Interfaces;\n\n/**\n * Interface PageFormInterface\n * @package Grav\\Common\\Page\\Interfaces\n */\ninterface PageFormInterface\n{\n    /**\n     * Return all the forms which are associated to this page.\n     *\n     * Forms are returned as [name => blueprint, ...], where blueprint follows the regular form blueprint format.\n     *\n     * @return array\n     */\n    //public function getForms(): array;\n\n    /**\n     * Add forms to this page.\n     *\n     * @param array $new\n     * @return $this\n     */\n    public function addForms(array $new/*, $override = true*/);\n\n    /**\n     * Alias of $this->getForms();\n     *\n     * @return array\n     */\n    public function forms();//: array;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Interfaces/PageInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Interfaces;\n\nuse Grav\\Common\\Media\\Interfaces\\MediaInterface;\n\n/**\n * Class implements page interface.\n */\ninterface PageInterface extends\n    PageContentInterface,\n    PageFormInterface,\n    PageRoutableInterface,\n    PageTranslateInterface,\n    MediaInterface,\n    PageLegacyInterface\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Interfaces/PageLegacyInterface.php",
    "content": "<?php\nnamespace Grav\\Common\\Page\\Interfaces;\n\nuse Exception;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Page\\Collection;\nuse InvalidArgumentException;\nuse RocketTheme\\Toolbox\\File\\MarkdownFile;\nuse SplFileInfo;\n\n/**\n * Interface PageLegacyInterface\n * @package Grav\\Common\\Page\\Interfaces\n */\ninterface PageLegacyInterface\n{\n    /**\n     * Initializes the page instance variables based on a file\n     *\n     * @param  SplFileInfo $file The file information for the .md file that the page represents\n     * @param  string|null $extension\n     * @return $this\n     */\n    public function init(SplFileInfo $file, $extension = null);\n\n    /**\n     * Gets and Sets the raw data\n     *\n     * @param  string|null $var Raw content string\n     * @return string      Raw content string\n     */\n    public function raw($var = null);\n\n    /**\n     * Gets and Sets the page frontmatter\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function frontmatter($var = null);\n\n    /**\n     * Modify a header value directly\n     *\n     * @param string $key\n     * @param mixed $value\n     */\n    public function modifyHeader($key, $value);\n\n    /**\n     * @return int\n     */\n    public function httpResponseCode();\n\n    /**\n     * @return array\n     */\n    public function httpHeaders();\n\n    /**\n     * Get the contentMeta array and initialize content first if it's not already\n     *\n     * @return mixed\n     */\n    public function contentMeta();\n\n    /**\n     * Add an entry to the page's contentMeta array\n     *\n     * @param string $name\n     * @param mixed $value\n     */\n    public function addContentMeta($name, $value);\n\n    /**\n     * Return the whole contentMeta array as it currently stands\n     *\n     * @param string|null $name\n     * @return mixed\n     */\n    public function getContentMeta($name = null);\n\n    /**\n     * Sets the whole content meta array in one shot\n     *\n     * @param array $content_meta\n     * @return array\n     */\n    public function setContentMeta($content_meta);\n\n    /**\n     * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page\n     */\n    public function cachePageContent();\n\n    /**\n     * Get file object to the page.\n     *\n     * @return MarkdownFile|null\n     */\n    public function file();\n\n    /**\n     * Save page if there's a file assigned to it.\n     *\n     * @param bool|mixed $reorder Internal use.\n     */\n    public function save($reorder = true);\n\n    /**\n     * Prepare move page to new location. Moves also everything that's under the current page.\n     *\n     * You need to call $this->save() in order to perform the move.\n     *\n     * @param PageInterface $parent New parent page.\n     * @return $this\n     */\n    public function move(PageInterface $parent);\n\n    /**\n     * Prepare a copy from the page. Copies also everything that's under the current page.\n     *\n     * Returns a new Page object for the copy.\n     * You need to call $this->save() in order to perform the move.\n     *\n     * @param PageInterface $parent New parent page.\n     * @return $this\n     */\n    public function copy(PageInterface $parent);\n\n    /**\n     * Get blueprints for the page.\n     *\n     * @return Blueprint\n     */\n    public function blueprints();\n\n    /**\n     * Get the blueprint name for this page.  Use the blueprint form field if set\n     *\n     * @return string\n     */\n    public function blueprintName();\n\n    /**\n     * Validate page header.\n     *\n     * @throws Exception\n     */\n    public function validate();\n\n    /**\n     * Filter page header from illegal contents.\n     */\n    public function filter();\n\n    /**\n     * Get unknown header variables.\n     *\n     * @return array\n     */\n    public function extra();\n\n    /**\n     * Convert page to an array.\n     *\n     * @return array\n     */\n    public function toArray();\n\n    /**\n     * Convert page to YAML encoded string.\n     *\n     * @return string\n     */\n    public function toYaml();\n\n    /**\n     * Convert page to JSON encoded string.\n     *\n     * @return string\n     */\n    public function toJson();\n\n    /**\n     * Returns normalized list of name => form pairs.\n     *\n     * @return array\n     */\n    public function forms();\n\n    /**\n     * @param array $new\n     */\n    public function addForms(array $new);\n\n    /**\n     * Gets and sets the name field.  If no name field is set, it will return 'default.md'.\n     *\n     * @param  string|null $var The name of this page.\n     * @return string      The name of this page.\n     */\n    public function name($var = null);\n\n    /**\n     * Returns child page type.\n     *\n     * @return string\n     */\n    public function childType();\n\n    /**\n     * Gets and sets the template field. This is used to find the correct Twig template file to render.\n     * If no field is set, it will return the name without the .md extension\n     *\n     * @param  string|null $var the template name\n     * @return string      the template name\n     */\n    public function template($var = null);\n\n    /**\n     * Allows a page to override the output render format, usually the extension provided\n     * in the URL. (e.g. `html`, `json`, `xml`, etc).\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function templateFormat($var = null);\n\n    /**\n     * Gets and sets the extension field.\n     *\n     * @param string|null $var\n     * @return string|null\n     */\n    public function extension($var = null);\n\n    /**\n     * Gets and sets the expires field. If not set will return the default\n     *\n     * @param  int|null $var The new expires value.\n     * @return int      The expires value\n     */\n    public function expires($var = null);\n\n    /**\n     * Gets and sets the cache-control property.  If not set it will return the default value (null)\n     * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options\n     *\n     * @param string|null $var\n     * @return string|null\n     */\n    public function cacheControl($var = null);\n\n    /**\n     * @param bool|null $var\n     * @return bool\n     */\n    public function ssl($var = null);\n\n    /**\n     * Returns the state of the debugger override etting for this page\n     *\n     * @return bool\n     */\n    public function debugger();\n\n    /**\n     * Function to merge page metadata tags and build an array of Metadata objects\n     * that can then be rendered in the page.\n     *\n     * @param  array|null $var an Array of metadata values to set\n     * @return array      an Array of metadata values for the page\n     */\n    public function metadata($var = null);\n\n    /**\n     * Gets and sets the option to show the etag header for the page.\n     *\n     * @param  bool|null $var show etag header\n     * @return bool      show etag header\n     */\n    public function eTag($var = null): bool;\n\n    /**\n     * Gets and sets the path to the .md file for this Page object.\n     *\n     * @param  string|null $var the file path\n     * @return string|null      the file path\n     */\n    public function filePath($var = null);\n\n    /**\n     * Gets the relative path to the .md file\n     *\n     * @return string The relative file path\n     */\n    public function filePathClean();\n\n    /**\n     * Gets and sets the order by which any sub-pages should be sorted.\n     *\n     * @param  string|null $var the order, either \"asc\" or \"desc\"\n     * @return string      the order, either \"asc\" or \"desc\"\n     * @deprecated 1.6\n     */\n    public function orderDir($var = null);\n\n    /**\n     * Gets and sets the order by which the sub-pages should be sorted.\n     *\n     * default - is the order based on the file system, ie 01.Home before 02.Advark\n     * title - is the order based on the title set in the pages\n     * date - is the order based on the date set in the pages\n     * folder - is the order based on the name of the folder with any numerics omitted\n     *\n     * @param  string|null $var supported options include \"default\", \"title\", \"date\", and \"folder\"\n     * @return string      supported options include \"default\", \"title\", \"date\", and \"folder\"\n     * @deprecated 1.6\n     */\n    public function orderBy($var = null);\n\n    /**\n     * Gets the manual order set in the header.\n     *\n     * @param  string|null $var supported options include \"default\", \"title\", \"date\", and \"folder\"\n     * @return array\n     * @deprecated 1.6\n     */\n    public function orderManual($var = null);\n\n    /**\n     * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the\n     * sub_pages header property is set for this page object.\n     *\n     * @param  int|null $var the maximum number of sub-pages\n     * @return int      the maximum number of sub-pages\n     * @deprecated 1.6\n     */\n    public function maxCount($var = null);\n\n    /**\n     * Gets and sets the modular var that helps identify this page is a modular child\n     *\n     * @param  bool|null $var true if modular_twig\n     * @return bool      true if modular_twig\n     * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead.\n     */\n    public function modular($var = null);\n\n    /**\n     * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need\n     * twig processing handled differently from a regular page.\n     *\n     * @param  bool|null $var true if modular_twig\n     * @return bool      true if modular_twig\n     */\n    public function modularTwig($var = null);\n\n    /**\n     * Returns children of this page.\n     *\n     * @return PageCollectionInterface|Collection\n     */\n    public function children();\n\n    /**\n     * Check to see if this item is the first in an array of sub-pages.\n     *\n     * @return bool True if item is first.\n     */\n    public function isFirst();\n\n    /**\n     * Check to see if this item is the last in an array of sub-pages.\n     *\n     * @return bool True if item is last\n     */\n    public function isLast();\n\n    /**\n     * Gets the previous sibling based on current position.\n     *\n     * @return PageInterface the previous Page item\n     */\n    public function prevSibling();\n\n    /**\n     * Gets the next sibling based on current position.\n     *\n     * @return PageInterface the next Page item\n     */\n    public function nextSibling();\n\n    /**\n     * Returns the adjacent sibling based on a direction.\n     *\n     * @param  int $direction either -1 or +1\n     * @return PageInterface|false             the sibling page\n     */\n    public function adjacentSibling($direction = 1);\n\n    /**\n     * Helper method to return an ancestor page.\n     *\n     * @param bool|null $lookup Name of the parent folder\n     * @return PageInterface page you were looking for if it exists\n     */\n    public function ancestor($lookup = null);\n\n    /**\n     * Helper method to return an ancestor page to inherit from. The current\n     * page object is returned.\n     *\n     * @param string $field Name of the parent folder\n     * @return PageInterface\n     */\n    public function inherited($field);\n\n    /**\n     * Helper method to return an ancestor field only to inherit from. The\n     * first occurrence of an ancestor field will be returned if at all.\n     *\n     * @param string $field Name of the parent folder\n     * @return array\n     */\n    public function inheritedField($field);\n\n    /**\n     * Helper method to return a page.\n     *\n     * @param string $url the url of the page\n     * @param bool $all\n     * @return PageInterface page you were looking for if it exists\n     */\n    public function find($url, $all = false);\n\n    /**\n     * Get a collection of pages in the current context.\n     *\n     * @param string|array $params\n     * @param bool $pagination\n     * @return Collection\n     * @throws InvalidArgumentException\n     */\n    public function collection($params = 'content', $pagination = true);\n\n    /**\n     * @param string|array $value\n     * @param bool $only_published\n     * @return PageCollectionInterface|Collection\n     */\n    public function evaluate($value, $only_published = true);\n\n    /**\n     * Returns whether or not the current folder exists\n     *\n     * @return bool\n     */\n    public function folderExists();\n\n    /**\n     * Gets the Page Unmodified (original) version of the page.\n     *\n     * @return PageInterface The original version of the page.\n     */\n    public function getOriginal();\n\n    /**\n     * Gets the action.\n     *\n     * @return string The Action string.\n     */\n    public function getAction();\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Interfaces/PageRoutableInterface.php",
    "content": "<?php\nnamespace Grav\\Common\\Page\\Interfaces;\n\n/**\n * Interface PageRoutableInterface\n * @package Grav\\Common\\Page\\Interfaces\n */\ninterface PageRoutableInterface\n{\n    /**\n     * Returns the page extension, got from the page `url_extension` config and falls back to the\n     * system config `system.pages.append_url_extension`.\n     *\n     * @return string      The extension of this page. For example `.html`\n     */\n    public function urlExtension();\n\n    /**\n     * Gets and Sets whether or not this Page is routable, ie you can reach it\n     * via a URL.\n     * The page must be *routable* and *published*\n     *\n     * @param  bool|null $var true if the page is routable\n     * @return bool      true if the page is routable\n     */\n    public function routable($var = null);\n\n    /**\n     * Gets the URL for a page - alias of url().\n     *\n     * @param bool|null $include_host\n     * @return string the permalink\n     */\n    public function link($include_host = false);\n\n    /**\n     * Gets the URL with host information, aka Permalink.\n     * @return string The permalink.\n     */\n    public function permalink();\n\n    /**\n     * Returns the canonical URL for a page\n     *\n     * @param bool $include_lang\n     * @return string\n     */\n    public function canonical($include_lang = true);\n\n    /**\n     * Gets the url for the Page.\n     *\n     * @param bool $include_host Defaults false, but true would include http://yourhost.com\n     * @param bool $canonical true to return the canonical URL\n     * @param bool $include_lang\n     * @param bool $raw_route\n     * @return string The url.\n     */\n    public function url($include_host = false, $canonical = false, $include_lang = true, $raw_route = false);\n\n    /**\n     * Gets the route for the page based on the route headers if available, else from\n     * the parents route and the current Page's slug.\n     *\n     * @param  string|null $var Set new default route.\n     * @return string|null  The route for the Page.\n     */\n    public function route($var = null);\n\n    /**\n     * Helper method to clear the route out so it regenerates next time you use it\n     */\n    public function unsetRouteSlug();\n\n    /**\n     * Gets and Sets the page raw route\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function rawRoute($var = null);\n\n    /**\n     * Gets the route aliases for the page based on page headers.\n     *\n     * @param  array|null $var list of route aliases\n     * @return array  The route aliases for the Page.\n     */\n    public function routeAliases($var = null);\n\n    /**\n     * Gets the canonical route for this page if its set. If provided it will use\n     * that value, else if it's `true` it will use the default route.\n     *\n     * @param string|null $var\n     * @return bool|string\n     */\n    public function routeCanonical($var = null);\n\n    /**\n     * Gets the redirect set in the header.\n     *\n     * @param  string|null $var redirect url\n     * @return string\n     */\n    public function redirect($var = null);\n\n    /**\n     * Returns the clean path to the page file\n     */\n    public function relativePagePath();\n\n    /**\n     * Gets and sets the path to the folder where the .md for this Page object resides.\n     * This is equivalent to the filePath but without the filename.\n     *\n     * @param  string|null $var the path\n     * @return string|null      the path\n     */\n    public function path($var = null);\n\n    /**\n     * Get/set the folder.\n     *\n     * @param string|null $var Optional path\n     * @return string|null\n     */\n    public function folder($var = null);\n\n    /**\n     * Gets and Sets the parent object for this page\n     *\n     * @param  PageInterface|null $var the parent page object\n     * @return PageInterface|null the parent page object if it exists.\n     */\n    public function parent(PageInterface $var = null);\n\n    /**\n     * Gets the top parent object for this page. Can return page itself.\n     *\n     * @return PageInterface The top parent page object.\n     */\n    public function topParent();\n\n    /**\n     * Returns the item in the current position.\n     *\n     * @return int|null The index of the current page.\n     */\n    public function currentPosition();\n\n    /**\n     * Returns whether or not this page is the currently active page requested via the URL.\n     *\n     * @return bool True if it is active\n     */\n    public function active();\n\n    /**\n     * Returns whether or not this URI's URL contains the URL of the active page.\n     * Or in other words, is this page's URL in the current URL\n     *\n     * @return bool True if active child exists\n     */\n    public function activeChild();\n\n    /**\n     * Returns whether or not this page is the currently configured home page.\n     *\n     * @return bool True if it is the homepage\n     */\n    public function home();\n\n    /**\n     * Returns whether or not this page is the root node of the pages tree.\n     *\n     * @return bool True if it is the root\n     */\n    public function root();\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Interfaces/PageTranslateInterface.php",
    "content": "<?php\nnamespace Grav\\Common\\Page\\Interfaces;\n\n/**\n * Interface PageTranslateInterface\n * @package Grav\\Common\\Page\\Interfaces\n */\ninterface PageTranslateInterface\n{\n    /**\n     * @return bool\n     */\n    public function translated(): bool;\n\n    /**\n     * Return an array with the routes of other translated languages\n     *\n     * @param bool $onlyPublished only return published translations\n     * @return array the page translated languages\n     */\n    public function translatedLanguages($onlyPublished = false);\n\n    /**\n     * Return an array listing untranslated languages available\n     *\n     * @param bool $includeUnpublished also list unpublished translations\n     * @return array the page untranslated languages\n     */\n    public function untranslatedLanguages($includeUnpublished = false);\n\n    /**\n     * Get page language\n     *\n     * @param string|null $var\n     * @return mixed\n     */\n    public function language($var = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Interfaces/PagesSourceInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Interfaces;\n\n/**\n * Interface PagesSourceInterface\n * @package Grav\\Common\\Page\\Interfaces\n */\ninterface PagesSourceInterface // extends \\Iterator\n{\n    /**\n     * Get timestamp for the page source.\n     *\n     * @return int\n     */\n    public function getTimestamp(): int;\n\n    /**\n     * Get checksum for the page source.\n     *\n     * @return string\n     */\n    public function getChecksum(): string;\n\n    /**\n     * Returns true if the source contains a page for the given route.\n     *\n     * @param string $route\n     * @return bool\n     */\n    public function has(string $route): bool;\n\n    /**\n     * Get the page for the given route.\n     *\n     * @param string $route\n     * @return PageInterface|null\n     */\n    public function get(string $route): ?PageInterface;\n\n    /**\n     * Get the children for the given route.\n     *\n     * @param string $route\n     * @param array|null $options\n     * @return array\n     */\n    public function getChildren(string $route, array $options = null): array;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Markdown/Excerpts.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Markdown;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Medium\\Link;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Page\\Medium\\Medium;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function array_key_exists;\nuse function call_user_func_array;\nuse function count;\nuse function dirname;\nuse function in_array;\nuse function is_bool;\nuse function is_string;\n\n/**\n * Class Excerpts\n * @package Grav\\Common\\Page\\Markdown\n */\nclass Excerpts\n{\n    /** @var PageInterface|null */\n    protected $page;\n    /** @var array */\n    protected $config;\n\n    /**\n     * Excerpts constructor.\n     * @param PageInterface|null $page\n     * @param array|null $config\n     */\n    public function __construct(PageInterface $page = null, array $config = null)\n    {\n        $this->page = $page ?? Grav::instance()['page'] ?? null;\n\n        // Add defaults to the configuration.\n        if (null === $config || !isset($config['markdown'], $config['images'])) {\n            $c = Grav::instance()['config'];\n            $config = $config ?? [];\n            $config += [\n                'markdown' => $c->get('system.pages.markdown', []),\n                'images' => $c->get('system.images', [])\n            ];\n        }\n\n        $this->config = $config;\n    }\n\n    /**\n     * @return PageInterface|null\n     */\n    public function getPage(): ?PageInterface\n    {\n        return $this->page;\n    }\n\n    /**\n     * @return array\n     */\n    public function getConfig(): array\n    {\n        return $this->config;\n    }\n\n    /**\n     * @param object $markdown\n     * @return void\n     */\n    public function fireInitializedEvent($markdown): void\n    {\n        $grav = Grav::instance();\n\n        $grav->fireEvent('onMarkdownInitialized', new Event(['markdown' => $markdown, 'page' => $this->page]));\n    }\n\n    /**\n     * Process a Link excerpt\n     *\n     * @param array $excerpt\n     * @param string $type\n     * @return array\n     */\n    public function processLinkExcerpt(array $excerpt, string $type = 'link'): array\n    {\n        $grav = Grav::instance();\n        $url = htmlspecialchars_decode(rawurldecode($excerpt['element']['attributes']['href']));\n        $url_parts = $this->parseUrl($url);\n\n        // If there is a query, then parse it and build action calls.\n        if (isset($url_parts['query'])) {\n            $actions = array_reduce(\n                explode('&', $url_parts['query']),\n                static function ($carry, $item) {\n                    $parts = explode('=', $item, 2);\n                    $value = isset($parts[1]) ? rawurldecode($parts[1]) : true;\n                    $carry[$parts[0]] = $value;\n\n                    return $carry;\n                },\n                []\n            );\n\n            // Valid attributes supported.\n            $valid_attributes = $grav['config']->get('system.pages.markdown.valid_link_attributes') ?? [];\n\n            $skip = [];\n            // Unless told to not process, go through actions.\n            if (array_key_exists('noprocess', $actions)) {\n                $skip = is_bool($actions['noprocess']) ? $actions : explode(',', $actions['noprocess']);\n                unset($actions['noprocess']);\n            }\n\n            // Loop through actions for the image and call them.\n            foreach ($actions as $attrib => $value) {\n                if (!in_array($attrib, $skip)) {\n                    $key = $attrib;\n\n                    if (in_array($attrib, $valid_attributes, true)) {\n                        // support both class and classes.\n                        if ($attrib === 'classes') {\n                            $attrib = 'class';\n                        }\n                        $excerpt['element']['attributes'][$attrib] = str_replace(',', ' ', $value);\n                        unset($actions[$key]);\n                    }\n                }\n            }\n\n            $url_parts['query'] = http_build_query($actions, '', '&', PHP_QUERY_RFC3986);\n        }\n\n        // If no query elements left, unset query.\n        if (empty($url_parts['query'])) {\n            unset($url_parts['query']);\n        }\n\n        // Set path to / if not set.\n        if (empty($url_parts['path'])) {\n            $url_parts['path'] = '';\n        }\n\n        // If scheme isn't http(s)..\n        if (!empty($url_parts['scheme']) && !in_array($url_parts['scheme'], ['http', 'https'])) {\n            // Handle custom streams.\n            /** @var UniformResourceLocator $locator */\n            $locator = $grav['locator'];\n            if ($type === 'link' && $locator->isStream($url)) {\n                $path = $locator->findResource($url, false) ?: $locator->findResource($url, false, true);\n                $url_parts['path'] = $grav['base_url_relative'] . '/' . $path;\n                unset($url_parts['stream'], $url_parts['scheme']);\n            }\n\n            $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts);\n\n            return $excerpt;\n        }\n\n        // Handle paths and such.\n        $url_parts = Uri::convertUrl($this->page, $url_parts, $type);\n\n        // Build the URL from the component parts and set it on the element.\n        $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts);\n\n        return $excerpt;\n    }\n\n    /**\n     * Process an image excerpt\n     *\n     * @param array $excerpt\n     * @return array\n     */\n    public function processImageExcerpt(array $excerpt): array\n    {\n        $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['src']));\n        $url_parts = $this->parseUrl($url);\n\n        $media = null;\n        $filename = null;\n\n        if (!empty($url_parts['stream'])) {\n            $filename = $url_parts['scheme'] . '://' . ($url_parts['path'] ?? '');\n\n            $media = $this->page->getMedia();\n        } else {\n            $grav = Grav::instance();\n            /** @var Pages $pages */\n            $pages = $grav['pages'];\n\n            // File is also local if scheme is http(s) and host matches.\n            $local_file = isset($url_parts['path'])\n                && (empty($url_parts['scheme']) || in_array($url_parts['scheme'], ['http', 'https'], true))\n                && (empty($url_parts['host']) || $url_parts['host'] === $grav['uri']->host());\n\n            if ($local_file) {\n                $filename = Utils::basename($url_parts['path']);\n                $folder = dirname($url_parts['path']);\n\n                // Get the local path to page media if possible.\n                if ($this->page && $folder === $this->page->url(false, false, false)) {\n                    // Get the media objects for this page.\n                    $media = $this->page->getMedia();\n                } else {\n                    // see if this is an external page to this one\n                    $base_url = rtrim($grav['base_url_relative'] . $pages->base(), '/');\n                    $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/');\n\n                    $ext_page = $pages->find($page_route, true);\n                    if ($ext_page) {\n                        $media = $ext_page->getMedia();\n                    } else {\n                        $grav->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media]));\n                    }\n                }\n            }\n        }\n\n        // If there is a media file that matches the path referenced..\n        if ($media && $filename && isset($media[$filename])) {\n            // Get the medium object.\n            /** @var Medium $medium */\n            $medium = $media[$filename];\n\n            // Process operations\n            $medium = $this->processMediaActions($medium, $url_parts);\n            $element_excerpt = $excerpt['element']['attributes'];\n\n            $alt = $element_excerpt['alt'] ?? '';\n            $title = $element_excerpt['title'] ?? '';\n            $class = $element_excerpt['class'] ?? '';\n            $id = $element_excerpt['id'] ?? '';\n\n            $excerpt['element'] = $medium->parsedownElement($title, $alt, $class, $id, true);\n        } else {\n            // Not a current page media file, see if it needs converting to relative.\n            $excerpt['element']['attributes']['src'] = Uri::buildUrl($url_parts);\n        }\n\n        return $excerpt;\n    }\n\n    /**\n     * Process media actions\n     *\n     * @param Medium $medium\n     * @param string|array $url\n     * @return Medium|Link\n     */\n    public function processMediaActions($medium, $url)\n    {\n        $url_parts = is_string($url) ? $this->parseUrl($url) : $url;\n        $actions = [];\n\n\n        // if there is a query, then parse it and build action calls\n        if (isset($url_parts['query'])) {\n            $actions = array_reduce(\n                explode('&', $url_parts['query']),\n                static function ($carry, $item) {\n                    $parts = explode('=', $item, 2);\n                    $value = $parts[1] ?? null;\n                    $carry[] = ['method' => $parts[0], 'params' => $value];\n\n                    return $carry;\n                },\n                []\n            );\n        }\n\n        $defaults = $this->config['images']['defaults'] ?? [];\n        if (count($defaults)) {\n            foreach ($defaults as $method => $params) {\n                if (array_search($method, array_column($actions, 'method')) === false) {\n                    $actions[] = [\n                        'method' => $method,\n                        'params' => $params,\n                    ];\n                }\n            }\n        }\n\n        // loop through actions for the image and call them\n        foreach ($actions as $action) {\n            $matches = [];\n\n            if (preg_match('/\\[(.*)\\]/', $action['params'], $matches)) {\n                $args = [explode(',', $matches[1])];\n            } else {\n                $args = explode(',', $action['params']);\n            }\n\n            $medium = call_user_func_array([$medium, $action['method']], $args);\n        }\n\n        if (isset($url_parts['fragment'])) {\n            $medium->urlHash($url_parts['fragment']);\n        }\n\n        return $medium;\n    }\n\n    /**\n     * Variation of parse_url() which works also with local streams.\n     *\n     * @param string $url\n     * @return array\n     */\n    protected function parseUrl(string $url)\n    {\n        $url_parts = Utils::multibyteParseUrl($url);\n\n        if (isset($url_parts['scheme'])) {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n\n            // Special handling for the streams.\n            if ($locator->schemeExists($url_parts['scheme'])) {\n                if (isset($url_parts['host'])) {\n                    // Merge host and path into a path.\n                    $url_parts['path'] = $url_parts['host'] . (isset($url_parts['path']) ? '/' . $url_parts['path'] : '');\n                    unset($url_parts['host']);\n                }\n\n                $url_parts['stream'] = true;\n            }\n        }\n\n        return $url_parts;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Media.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page;\n\nuse FilesystemIterator;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Media\\Interfaces\\MediaObjectInterface;\nuse Grav\\Common\\Yaml;\nuse Grav\\Common\\Page\\Medium\\AbstractMedia;\nuse Grav\\Common\\Page\\Medium\\GlobalMedia;\nuse Grav\\Common\\Page\\Medium\\MediumFactory;\nuse RocketTheme\\Toolbox\\File\\File;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function in_array;\n\n/**\n * Class Media\n * @package Grav\\Common\\Page\n */\nclass Media extends AbstractMedia\n{\n    /** @var GlobalMedia */\n    protected static $global;\n\n    /** @var array */\n    protected $standard_exif = ['FileSize', 'MimeType', 'height', 'width'];\n\n    /**\n     * @param string $path\n     * @param array|null $media_order\n     * @param bool   $load\n     */\n    public function __construct($path, array $media_order = null, $load = true)\n    {\n        $this->setPath($path);\n        $this->media_order = $media_order;\n\n        $this->__wakeup();\n        if ($load) {\n            $this->init();\n        }\n    }\n\n    /**\n     * Initialize static variables on unserialize.\n     */\n    public function __wakeup()\n    {\n        if (null === static::$global) {\n            // Add fallback to global media.\n            static::$global = GlobalMedia::getInstance();\n        }\n    }\n\n    /**\n     * Return raw route to the page.\n     *\n     * @return string|null Route to the page or null if media isn't for a page.\n     */\n    public function getRawRoute(): ?string\n    {\n        $path = $this->getPath();\n        if ($path) {\n            /** @var Pages $pages */\n            $pages = $this->getGrav()['pages'];\n            $page = $pages->get($path);\n            if ($page) {\n                return $page->rawRoute();\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * Return page route.\n     *\n     * @return string|null Route to the page or null if media isn't for a page.\n     */\n    public function getRoute(): ?string\n    {\n        $path = $this->getPath();\n        if ($path) {\n            /** @var Pages $pages */\n            $pages = $this->getGrav()['pages'];\n            $page = $pages->get($path);\n            if ($page) {\n                return $page->route();\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * @param string $offset\n     * @return bool\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset)\n    {\n        return parent::offsetExists($offset) ?: isset(static::$global[$offset]);\n    }\n\n    /**\n     * @param string $offset\n     * @return MediaObjectInterface|null\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        return parent::offsetGet($offset) ?: static::$global[$offset];\n    }\n\n    /**\n     * Initialize class.\n     *\n     * @return void\n     */\n    protected function init()\n    {\n        $path = $this->getPath();\n\n        // Handle special cases where page doesn't exist in filesystem.\n        if (!$path || !is_dir($path)) {\n            return;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        $exif_reader = isset($grav['exif']) ? $grav['exif']->getReader() : null;\n        $media_types = array_keys($config->get('media.types', []));\n\n        $iterator = new FilesystemIterator($path, FilesystemIterator::UNIX_PATHS | FilesystemIterator::SKIP_DOTS);\n\n        $media = [];\n\n        foreach ($iterator as $file => $info) {\n            // Ignore folders and Markdown files.\n            $filename = $info->getFilename();\n            if (!$info->isFile() || $info->getExtension() === 'md' || $filename === 'frontmatter.yaml' || $filename === 'media.json' || strpos($filename, '.') === 0) {\n                continue;\n            }\n\n            // Find out what type we're dealing with\n            [$basename, $ext, $type, $extra] = $this->getFileParts($filename);\n\n            if (!in_array(strtolower($ext), $media_types, true)) {\n                continue;\n            }\n\n            if ($type === 'alternative') {\n                $media[\"{$basename}.{$ext}\"][$type][$extra] = ['file' => $file, 'size' => $info->getSize()];\n            } else {\n                $media[\"{$basename}.{$ext}\"][$type] = ['file' => $file, 'size' => $info->getSize()];\n            }\n        }\n\n        foreach ($media as $name => $types) {\n            // First prepare the alternatives in case there is no base medium\n            if (!empty($types['alternative'])) {\n                /**\n                 * @var string|int $ratio\n                 * @var array $alt\n                 */\n                foreach ($types['alternative'] as $ratio => &$alt) {\n                    $alt['file'] = $this->createFromFile($alt['file']);\n\n                    if (empty($alt['file'])) {\n                        unset($types['alternative'][$ratio]);\n                    } else {\n                        $alt['file']->set('size', $alt['size']);\n                    }\n                }\n                unset($alt);\n            }\n\n            $file_path = null;\n\n            // Create the base medium\n            if (empty($types['base'])) {\n                if (!isset($types['alternative'])) {\n                    continue;\n                }\n\n                $max = max(array_keys($types['alternative']));\n                $medium = $types['alternative'][$max]['file'];\n                $file_path = $medium->path();\n                $medium = MediumFactory::scaledFromMedium($medium, $max, 1)['file'];\n            } else {\n                $medium = $this->createFromFile($types['base']['file']);\n                if ($medium) {\n                    $medium->set('size', $types['base']['size']);\n                    $file_path = $medium->path();\n                }\n            }\n\n            if (empty($medium)) {\n                continue;\n            }\n\n            // metadata file\n            $meta_path = $file_path . '.meta.yaml';\n\n            if (file_exists($meta_path)) {\n                $types['meta']['file'] = $meta_path;\n            } elseif ($file_path && $exif_reader && $medium->get('mime') === 'image/jpeg' && empty($types['meta']) && $config->get('system.media.auto_metadata_exif')) {\n                $meta = $exif_reader->read($file_path);\n\n                if ($meta) {\n                    $meta_data = $meta->getData();\n                    $meta_trimmed = array_diff_key($meta_data, array_flip($this->standard_exif));\n                    if ($meta_trimmed) {\n                        if ($locator->isStream($meta_path)) {\n                            $file = File::instance($locator->findResource($meta_path, true, true));\n                        } else {\n                            $file = File::instance($meta_path);\n                        }\n                        $file->save(Yaml::dump($meta_trimmed));\n                        $types['meta']['file'] = $meta_path;\n                    }\n                }\n            }\n\n            if (!empty($types['meta'])) {\n                $medium->addMetaFile($types['meta']['file']);\n            }\n\n            if (!empty($types['thumb'])) {\n                // We will not turn it into medium yet because user might never request the thumbnail\n                // not wasting any resources on that, maybe we should do this for medium in general?\n                $medium->set('thumbnails.page', $types['thumb']['file']);\n            }\n\n            // Build missing alternatives\n            if (!empty($types['alternative'])) {\n                $alternatives = $types['alternative'];\n                $max = max(array_keys($alternatives));\n\n                for ($i=$max; $i > 1; $i--) {\n                    if (isset($alternatives[$i])) {\n                        continue;\n                    }\n\n                    $types['alternative'][$i] = MediumFactory::scaledFromMedium($alternatives[$max]['file'], $max, $i);\n                }\n\n                foreach ($types['alternative'] as $altMedium) {\n                    if ($altMedium['file'] != $medium) {\n                        $altWidth = $altMedium['file']->get('width');\n                        $medWidth = $medium->get('width');\n                        if ($altWidth && $medWidth) {\n                            $ratio = $altWidth / $medWidth;\n                            $medium->addAlternative($ratio, $altMedium['file']);\n                        }\n                    }\n                }\n            }\n\n            $this->add($name, $medium);\n        }\n    }\n\n    /**\n     * @return string|null\n     * @deprecated 1.6 Use $this->getPath() instead.\n     */\n    public function path(): ?string\n    {\n        return $this->getPath();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/AbstractMedia.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Media\\Interfaces\\MediaCollectionInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaObjectInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaUploadInterface;\nuse Grav\\Common\\Media\\Traits\\MediaUploadTrait;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\ArrayTraits\\ArrayAccess;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Countable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Export;\nuse RocketTheme\\Toolbox\\ArrayTraits\\ExportInterface;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Iterator;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function is_array;\n\n/**\n * Class AbstractMedia\n * @package Grav\\Common\\Page\\Medium\n */\nabstract class AbstractMedia implements ExportInterface, MediaCollectionInterface, MediaUploadInterface\n{\n    use ArrayAccess;\n    use Countable;\n    use Iterator;\n    use Export;\n    use MediaUploadTrait;\n\n    /** @var array */\n    protected $items = [];\n    /** @var string|null */\n    protected $path;\n    /** @var array */\n    protected $images = [];\n    /** @var array */\n    protected $videos = [];\n    /** @var array */\n    protected $audios = [];\n    /** @var array */\n    protected $files = [];\n    /** @var array|null */\n    protected $media_order;\n\n    /**\n     * Return media path.\n     *\n     * @return string|null\n     */\n    public function getPath(): ?string\n    {\n        return $this->path;\n    }\n\n    /**\n     * @param string|null $path\n     * @return void\n     */\n    public function setPath(?string $path): void\n    {\n        $this->path = $path;\n    }\n\n    /**\n     * Get medium by filename.\n     *\n     * @param string $filename\n     * @return MediaObjectInterface|null\n     */\n    public function get($filename)\n    {\n        return $this->offsetGet($filename);\n    }\n\n    /**\n     * Call object as function to get medium by filename.\n     *\n     * @param string $filename\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function __invoke($filename)\n    {\n        return $this->offsetGet($filename);\n    }\n\n    /**\n     * Set file modification timestamps (query params) for all the media files.\n     *\n     * @param string|int|null $timestamp\n     * @return $this\n     */\n    public function setTimestamps($timestamp = null)\n    {\n        foreach ($this->items as $instance) {\n            $instance->setTimestamp($timestamp);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Get a list of all media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function all()\n    {\n        $this->items = $this->orderMedia($this->items);\n\n        return $this->items;\n    }\n\n    /**\n     * Get a list of all image media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function images()\n    {\n        $this->images = $this->orderMedia($this->images);\n\n        return $this->images;\n    }\n\n    /**\n     * Get a list of all video media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function videos()\n    {\n        $this->videos = $this->orderMedia($this->videos);\n\n        return $this->videos;\n    }\n\n    /**\n     * Get a list of all audio media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function audios()\n    {\n        $this->audios = $this->orderMedia($this->audios);\n\n        return $this->audios;\n    }\n\n    /**\n     * Get a list of all file media.\n     *\n     * @return MediaObjectInterface[]\n     */\n    public function files()\n    {\n        $this->files = $this->orderMedia($this->files);\n\n        return $this->files;\n    }\n\n    /**\n     * @param string $name\n     * @param MediaObjectInterface|null $file\n     * @return void\n     */\n    public function add($name, $file)\n    {\n        if (null === $file) {\n            return;\n        }\n\n        $this->offsetSet($name, $file);\n\n        switch ($file->type) {\n            case 'image':\n                $this->images[$name] = $file;\n                break;\n            case 'video':\n                $this->videos[$name] = $file;\n                break;\n            case 'audio':\n                $this->audios[$name] = $file;\n                break;\n            default:\n                $this->files[$name] = $file;\n        }\n    }\n\n    /**\n     * @param string $name\n     * @return void\n     */\n    public function hide($name)\n    {\n        $this->offsetUnset($name);\n\n        unset($this->images[$name], $this->videos[$name], $this->audios[$name], $this->files[$name]);\n    }\n\n    /**\n     * Create Medium from a file.\n     *\n     * @param  string $file\n     * @param  array  $params\n     * @return Medium|null\n     */\n    public function createFromFile($file, array $params = [])\n    {\n        return MediumFactory::fromFile($file, $params);\n    }\n\n        /**\n     * Create Medium from array of parameters\n     *\n     * @param  array          $items\n     * @param  Blueprint|null $blueprint\n     * @return Medium|null\n     */\n    public function createFromArray(array $items = [], Blueprint $blueprint = null)\n    {\n        return MediumFactory::fromArray($items, $blueprint);\n    }\n\n    /**\n     * @param MediaObjectInterface $mediaObject\n     * @return ImageFile\n     */\n    public function getImageFileObject(MediaObjectInterface $mediaObject): ImageFile\n    {\n        return ImageFile::open($mediaObject->get('filepath'));\n    }\n\n    /**\n     * Order the media based on the page's media_order\n     *\n     * @param array $media\n     * @return array\n     */\n    protected function orderMedia($media)\n    {\n        if (null === $this->media_order) {\n            $path = $this->getPath();\n            if (null !== $path) {\n                /** @var Pages $pages */\n                $pages = Grav::instance()['pages'];\n                $page = $pages->get($path);\n                if ($page && isset($page->header()->media_order)) {\n                    $this->media_order = array_map('trim', explode(',', $page->header()->media_order));\n                }\n            }\n        }\n\n        if (!empty($this->media_order) && is_array($this->media_order)) {\n            $media = Utils::sortArrayByArray($media, $this->media_order);\n        } else {\n            ksort($media, SORT_NATURAL | SORT_FLAG_CASE);\n        }\n\n        return $media;\n    }\n\n    protected function fileExists(string $filename, string $destination): bool\n    {\n        return file_exists(\"{$destination}/{$filename}\");\n    }\n\n    /**\n     * Get filename, extension and meta part.\n     *\n     * @param  string $filename\n     * @return array\n     */\n    protected function getFileParts($filename)\n    {\n        if (preg_match('/(.*)@(\\d+)x\\.(.*)$/', $filename, $matches)) {\n            $name = $matches[1];\n            $extension = $matches[3];\n            $extra = (int) $matches[2];\n            $type = 'alternative';\n\n            if ($extra === 1) {\n                $type = 'base';\n                $extra = null;\n            }\n        } else {\n            $fileParts = explode('.', $filename);\n\n            $name = array_shift($fileParts);\n            $extension = null;\n            $extra = null;\n            $type = 'base';\n\n            while (($part = array_shift($fileParts)) !== null) {\n                if ($part !== 'meta' && $part !== 'thumb') {\n                    if (null !== $extension) {\n                        $name .= '.' . $extension;\n                    }\n                    $extension = $part;\n                } else {\n                    $type = $part;\n                    $extra = '.' . $part . '.' . implode('.', $fileParts);\n                    break;\n                }\n            }\n        }\n\n        return [$name, $extension, $type, $extra];\n    }\n\n    protected function getGrav(): Grav\n    {\n        return Grav::instance();\n    }\n\n    protected function getConfig(): Config\n    {\n        return $this->getGrav()['config'];\n    }\n\n    protected function getLanguage(): Language\n    {\n        return $this->getGrav()['language'];\n    }\n\n    protected function clearCache(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->getGrav()['locator'];\n        $locator->clearCache();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/AudioMedium.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Media\\Interfaces\\AudioMediaInterface;\nuse Grav\\Common\\Media\\Traits\\AudioMediaTrait;\n\n/**\n * Class AudioMedium\n * @package Grav\\Common\\Page\\Medium\n */\nclass AudioMedium extends Medium implements AudioMediaInterface\n{\n    use AudioMediaTrait;\n\n    /**\n     * Reset medium.\n     *\n     * @return $this\n     */\n    public function reset()\n    {\n        parent::reset();\n\n        $this->resetPlayer();\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/GlobalMedia.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Media\\Interfaces\\MediaObjectInterface;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function dirname;\n\n/**\n * Class GlobalMedia\n * @package Grav\\Common\\Page\\Medium\n */\nclass GlobalMedia extends AbstractMedia\n{\n    /** @var self */\n    protected static $instance;\n\n    public static function getInstance(): self\n    {\n        if (null === self::$instance) {\n            self::$instance = new self();\n        }\n\n        return self::$instance;\n    }\n\n    /**\n     * Return media path.\n     *\n     * @return string|null\n     */\n    public function getPath(): ?string\n    {\n        return null;\n    }\n\n    /**\n     * @param string $offset\n     * @return bool\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset)\n    {\n        return parent::offsetExists($offset) ?: !empty($this->resolveStream($offset));\n    }\n\n    /**\n     * @param string $offset\n     * @return MediaObjectInterface|null\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        return parent::offsetGet($offset) ?: $this->addMedium($offset);\n    }\n\n    /**\n     * @param string $filename\n     * @return string|null\n     */\n    protected function resolveStream($filename)\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        if (!$locator->isStream($filename)) {\n            return null;\n        }\n\n        return $locator->findResource($filename) ?: null;\n    }\n\n    /**\n     * @param string $stream\n     * @return MediaObjectInterface|null\n     */\n    protected function addMedium($stream)\n    {\n        $filename = $this->resolveStream($stream);\n        if (!$filename) {\n            return null;\n        }\n\n        $path = dirname($filename);\n        [$basename, $ext,, $extra] = $this->getFileParts(Utils::basename($filename));\n        $medium = MediumFactory::fromFile($filename);\n\n        if (null === $medium) {\n            return null;\n        }\n\n        $medium->set('size', filesize($filename));\n        $scale = (int) ($extra ?: 1);\n\n        if ($scale !== 1) {\n            $altMedium = $medium;\n\n            // Create scaled down regular sized image.\n            $medium = MediumFactory::scaledFromMedium($altMedium, $scale, 1)['file'];\n\n            if (empty($medium)) {\n                return null;\n            }\n\n            // Add original sized image as alternative.\n            $medium->addAlternative($scale, $altMedium['file']);\n\n            // Locate or generate smaller retina images.\n            for ($i = $scale-1; $i > 1; $i--) {\n                $altFilename = \"{$path}/{$basename}@{$i}x.{$ext}\";\n\n                if (file_exists($altFilename)) {\n                    $scaled = MediumFactory::fromFile($altFilename);\n                } else {\n                    $scaled = MediumFactory::scaledFromMedium($altMedium, $scale, $i)['file'];\n                }\n\n                if ($scaled) {\n                    $medium->addAlternative($i, $scaled);\n                }\n            }\n        }\n\n        $meta = \"{$path}/{$basename}.{$ext}.yaml\";\n        if (file_exists($meta)) {\n            $medium->addMetaFile($meta);\n        }\n        $meta = \"{$path}/{$basename}.{$ext}.meta.yaml\";\n        if (file_exists($meta)) {\n            $medium->addMetaFile($meta);\n        }\n\n        $thumb = \"{$path}/{$basename}.thumb.{$ext}\";\n        if (file_exists($thumb)) {\n            $medium->set('thumbnails.page', $thumb);\n        }\n\n        $this->add($stream, $medium);\n\n        return $medium;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/ImageFile.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Exception;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Gregwar\\Image\\Exceptions\\GenerationError;\nuse Gregwar\\Image\\Image;\nuse Gregwar\\Image\\Source;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function array_key_exists;\nuse function count;\nuse function extension_loaded;\nuse function in_array;\n\n/**\n * Class ImageFile\n * @package Grav\\Common\\Page\\Medium\n *\n * @method Image applyExifOrientation($exif_orienation)\n */\nclass ImageFile extends Image\n{\n    /**\n     * Image constructor with adapter configuration from Grav.\n     *\n     * @param string|null $originalFile\n     * @param int|null $width\n     * @param int|null $height\n     */\n    public function __construct($originalFile = null, $width = null, $height = null)\n    {\n        parent::__construct($originalFile, $width, $height);\n        \n        // Set the adapter based on Grav configuration\n        $grav = Grav::instance();\n        $adapter = $grav['config']->get('system.images.adapter', 'gd');\n        try {\n            $this->setAdapter($adapter);\n        } catch (Exception $e) {\n            $grav['log']->error(\n                'Image adapter \"' . $adapter . '\" is not available. Falling back to GD adapter.'\n            );\n        }\n    }\n\n    /**\n     * Destruct also image object.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __destruct()\n    {\n        $adapter = $this->adapter;\n        if ($adapter) {\n            $adapter->deinit();\n        }\n    }\n\n    /**\n     * Clear previously applied operations\n     *\n     * @return void\n     */\n    public function clearOperations()\n    {\n        $this->operations = [];\n    }\n\n    /**\n     * This is the same as the Gregwar Image class except this one fires a Grav Event on creation of new cached file\n     *\n     * @param string $type the image type\n     * @param int $quality the quality (for JPEG)\n     * @param bool $actual\n     * @param array $extras\n     * @return string\n     */\n    public function cacheFile($type = 'jpg', $quality = 80, $actual = false, $extras = [])\n    {\n        if ($type === 'guess') {\n            $type = $this->guessType();\n        }\n\n        if (!$this->forceCache && !count($this->operations) && $type === $this->guessType()) {\n            return $this->getFilename($this->getFilePath());\n        }\n\n        // Computes the hash\n        $this->hash = $this->getHash($type, $quality, $extras);\n\n        /** @var Config $config */\n        $config = Grav::instance()['config'];\n\n        // Seo friendly image names\n        $seofriendly = $config->get('system.images.seofriendly', false);\n\n        if ($seofriendly) {\n            $mini_hash = substr($this->hash, 0, 4) . substr($this->hash, -4);\n            $cacheFile = \"{$this->prettyName}-{$mini_hash}\";\n        } else {\n            $cacheFile = \"{$this->hash}-{$this->prettyName}\";\n        }\n\n        $cacheFile .= '.' . $type;\n\n        // If the files does not exists, save it\n        $image = $this;\n\n        // Target file should be younger than all the current image\n        // dependencies\n        $conditions = array(\n            'younger-than' => $this->getDependencies()\n        );\n\n        // The generating function\n        $generate = function ($target) use ($image, $type, $quality) {\n            $result = $image->save($target, $type, $quality);\n\n            if ($result !== $target) {\n                throw new GenerationError($result);\n            }\n\n            Grav::instance()->fireEvent('onImageMediumSaved', new Event(['image' => $target]));\n        };\n\n        // Asking the cache for the cacheFile\n        try {\n            $perms = $config->get('system.images.cache_perms', '0755');\n            $perms = octdec($perms);\n            $file = $this->getCacheSystem()->setDirectoryMode($perms)->getOrCreateFile($cacheFile, $conditions, $generate, $actual);\n        } catch (GenerationError $e) {\n            $file = $e->getNewFile();\n        }\n\n        // Nulling the resource\n        $adapter = $this->getAdapter();\n        $adapter->setSource(new Source\\File($file));\n        $adapter->deinit();\n\n        if ($actual) {\n            return $file;\n        }\n\n        return $this->getFilename($file);\n    }\n\n    /**\n     * Gets the hash.\n     *\n     * @param string $type\n     * @param int $quality\n     * @param array $extras\n     * @return string\n     */\n    public function getHash($type = 'guess', $quality = 80, $extras = [])\n    {\n        if (null === $this->hash) {\n            $this->generateHash($type, $quality, $extras);\n        }\n\n        return $this->hash;\n    }\n\n    /**\n     * Generates the hash.\n     *\n     * @param string $type\n     * @param int $quality\n     * @param array $extras\n     */\n    public function generateHash($type = 'guess', $quality = 80, $extras = [])\n    {\n        $inputInfos = $this->source->getInfos();\n\n        $data = [\n            $inputInfos,\n            $this->serializeOperations(),\n            $type,\n            $quality,\n            $extras\n        ];\n\n        $this->hash = sha1(serialize($data));\n    }\n\n    /**\n     * Read exif rotation from file and apply it.\n     */\n    public function fixOrientation()\n    {\n        if (!extension_loaded('exif')) {\n            throw new RuntimeException('You need to EXIF PHP Extension to use this function');\n        }\n\n        if (!file_exists($this->source->getInfos()) || !in_array(exif_imagetype($this->source->getInfos()), [IMAGETYPE_JPEG, IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM], true)) {\n            return $this;\n        }\n\n        // resolve any streams\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $filepath = $this->source->getInfos();\n        if ($locator->isStream($filepath)) {\n            $filepath = $locator->findResource($this->source->getInfos(), true, true);\n        }\n\n        // Make sure file exists\n        if (!file_exists($filepath)) {\n            return $this;\n        }\n\n        try {\n            $exif = @exif_read_data($filepath);\n        } catch (Exception $e) {\n            Grav::instance()['log']->error($filepath . ' - ' . $e->getMessage());\n            return $this;\n        }\n\n        if ($exif === false || !array_key_exists('Orientation', $exif)) {\n            return $this;\n        }\n\n        return $this->applyExifOrientation($exif['Orientation']);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/ImageMedium.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse BadFunctionCallException;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Media\\Interfaces\\ImageManipulateInterface;\nuse Grav\\Common\\Media\\Interfaces\\ImageMediaInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaLinkInterface;\nuse Grav\\Common\\Media\\Traits\\ImageLoadingTrait;\nuse Grav\\Common\\Media\\Traits\\ImageDecodingTrait;\nuse Grav\\Common\\Media\\Traits\\ImageFetchPriorityTrait;\nuse Grav\\Common\\Media\\Traits\\ImageMediaTrait;\nuse Grav\\Common\\Utils;\nuse Gregwar\\Image\\Image;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function func_get_args;\nuse function in_array;\n\n/**\n * Class ImageMedium\n * @package Grav\\Common\\Page\\Medium\n */\nclass ImageMedium extends Medium implements ImageMediaInterface, ImageManipulateInterface\n{\n    use ImageMediaTrait;\n    use ImageLoadingTrait;\n    use ImageDecodingTrait;\n    use ImageFetchPriorityTrait;\n\n    /**\n     * @var mixed|string\n     */\n    private $saved_image_path;\n\n    /**\n     * Construct.\n     *\n     * @param array $items\n     * @param Blueprint|null $blueprint\n     */\n    public function __construct($items = [], Blueprint $blueprint = null)\n    {\n        parent::__construct($items, $blueprint);\n\n        $config = $this->getGrav()['config'];\n\n        $this->thumbnailTypes = ['page', 'media', 'default'];\n        $this->default_quality = $config->get('system.images.default_image_quality', 85);\n        $this->def('debug', $config->get('system.images.debug'));\n\n        $path = $this->get('filepath');\n        if (!$path || !file_exists($path) || !filesize($path)) {\n            return;\n        }\n\n        $this->set('thumbnails.media', $path);\n\n        if (!($this->offsetExists('width') && $this->offsetExists('height') && $this->offsetExists('mime'))) {\n            $image_info = getimagesize($path);\n            if ($image_info) {\n                $this->def('width', (int) $image_info[0]);\n                $this->def('height', (int) $image_info[1]);\n                $this->def('mime', $image_info['mime']);\n            }\n        }\n\n        $this->reset();\n\n        if ($config->get('system.images.cache_all', false)) {\n            $this->cache();\n        }\n    }\n\n    /**\n     * @return array\n     */\n    public function getMeta(): array\n    {\n        return [\n            'width' => $this->width,\n            'height' => $this->height,\n        ] + parent::getMeta();\n    }\n\n    /**\n     * Also unset the image on destruct.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __destruct()\n    {\n        unset($this->image);\n    }\n\n    /**\n     * Also clone image.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __clone()\n    {\n        if ($this->image) {\n            $this->image = clone $this->image;\n        }\n\n        parent::__clone();\n    }\n\n    /**\n     * Reset image.\n     *\n     * @return $this\n     */\n    public function reset()\n    {\n        parent::reset();\n\n        if ($this->image) {\n            $this->image();\n            $this->medium_querystring = [];\n            $this->filter();\n            $this->clearAlternatives();\n        }\n\n        $this->format = 'guess';\n        $this->quality = $this->default_quality;\n\n        $this->debug_watermarked = false;\n\n        $config = $this->getGrav()['config'];\n        // Set CLS configuration\n        $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false);\n        $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false);\n        $this->retina_scale = $config->get('system.images.cls.retina_scale', 1);\n\n        return $this;\n    }\n\n    /**\n     * Add meta file for the medium.\n     *\n     * @param string $filepath\n     * @return $this\n     */\n    public function addMetaFile($filepath)\n    {\n        parent::addMetaFile($filepath);\n\n        // Apply filters in meta file\n        $this->reset();\n\n        return $this;\n    }\n\n    /**\n     * Return PATH to image.\n     *\n     * @param bool $reset\n     * @return string path to image\n     */\n    public function path($reset = true)\n    {\n        $output = $this->saveImage();\n\n        if ($reset) {\n            $this->reset();\n        }\n\n        return $output;\n    }\n\n    /**\n     * Return URL to image.\n     *\n     * @param bool $reset\n     * @return string\n     */\n    public function url($reset = true)\n    {\n        $grav = $this->getGrav();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n        $image_path = (string)($locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true));\n        $saved_image_path = $this->saved_image_path = $this->saveImage();\n\n        $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path) ?: $saved_image_path;\n\n        if ($locator->isStream($output)) {\n            $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true));\n        }\n\n        if (Utils::startsWith($output, $image_path)) {\n            $image_dir = $locator->findResource('cache://images', false);\n            $output = '/' . $image_dir . preg_replace('|^' . preg_quote($image_path, '|') . '|', '', $output);\n        }\n\n        if ($reset) {\n            $this->reset();\n        }\n\n        return trim($grav['base_url'] . '/' . $this->urlQuerystring($output), '\\\\');\n    }\n\n    /**\n     * Return srcset string for this Medium and its alternatives.\n     *\n     * @param bool $reset\n     * @return string\n     */\n    public function srcset($reset = true)\n    {\n        if (empty($this->alternatives)) {\n            if ($reset) {\n                $this->reset();\n            }\n\n            return '';\n        }\n\n        $srcset = [];\n        foreach ($this->alternatives as $ratio => $medium) {\n            $srcset[] = $medium->url($reset) . ' ' . $medium->get('width') . 'w';\n        }\n        $srcset[] = str_replace(' ', '%20', $this->url($reset)) . ' ' . $this->get('width') . 'w';\n\n        return implode(', ', $srcset);\n    }\n\n    /**\n     * Parsedown element for source display mode\n     *\n     * @param  array $attributes\n     * @param  bool $reset\n     * @return array\n     */\n    public function sourceParsedownElement(array $attributes, $reset = true)\n    {\n        empty($attributes['src']) && $attributes['src'] = $this->url(false);\n\n        $srcset = $this->srcset($reset);\n        if ($srcset) {\n            empty($attributes['srcset']) && $attributes['srcset'] = $srcset;\n            $attributes['sizes'] = $this->sizes();\n        }\n\n        if ($this->saved_image_path && $this->auto_sizes) {\n            if (!array_key_exists('height', $this->attributes) && !array_key_exists('width', $this->attributes)) {\n                $info = getimagesize($this->saved_image_path);\n                $width = (int)$info[0];\n                $height = (int)$info[1];\n\n                $scaling_factor = $this->retina_scale > 0 ? $this->retina_scale : 1;\n                $attributes['width'] = (int)($width / $scaling_factor);\n                $attributes['height'] = (int)($height / $scaling_factor);\n\n                if ($this->aspect_ratio) {\n                    $style = ($attributes['style'] ?? ' ') . \"--aspect-ratio: $width/$height;\";\n                    $attributes['style'] = trim($style);\n                }\n            }\n        }\n\n        return ['name' => 'img', 'attributes' => $attributes];\n    }\n\n    /**\n     * Turn the current Medium into a Link\n     *\n     * @param  bool $reset\n     * @param  array  $attributes\n     * @return MediaLinkInterface\n     */\n    public function link($reset = true, array $attributes = [])\n    {\n        $attributes['href'] = $this->url(false);\n        $srcset = $this->srcset(false);\n        if ($srcset) {\n            $attributes['data-srcset'] = $srcset;\n        }\n\n        return parent::link($reset, $attributes);\n    }\n\n    /**\n     * Turn the current Medium into a Link with lightbox enabled\n     *\n     * @param  int  $width\n     * @param  int  $height\n     * @param  bool $reset\n     * @return MediaLinkInterface\n     */\n    public function lightbox($width = null, $height = null, $reset = true)\n    {\n        if ($this->mode !== 'source') {\n            $this->display('source');\n        }\n\n        if ($width && $height) {\n            $this->__call('cropResize', [(int) $width, (int) $height]);\n        }\n\n        return parent::lightbox($width, $height, $reset);\n    }\n\n    /**\n     * @param string $enabled\n     * @return $this\n     */\n    public function autoSizes($enabled = 'true')\n    {\n        $this->auto_sizes = $enabled === 'true' ?: false;\n\n        return $this;\n    }\n\n    /**\n     * @param string $enabled\n     * @return $this\n     */\n    public function aspectRatio($enabled = 'true')\n    {\n        $this->aspect_ratio = $enabled === 'true' ?: false;\n\n        return $this;\n    }\n\n    /**\n     * @param int $scale\n     * @return $this\n     */\n    public function retinaScale($scale = 1)\n    {\n        $this->retina_scale = (int)$scale;\n\n        return $this;\n    }\n\n    /**\n     * @param string|null $image\n     * @param string|null $position\n     * @param int|float|null $scale\n     * @return $this\n     */\n    public function watermark($image = null, $position = null, $scale = null)\n    {\n        $grav = $this->getGrav();\n\n        $locator = $grav['locator'];\n        $config = $grav['config'];\n\n        $args = func_get_args();\n\n        $file = $args[0] ?? '1'; // using '1' because of markdown. doing ![](image.jpg?watermark) returns $args[0]='1';\n        $file = $file === '1' ? $config->get('system.images.watermark.image') : $args[0];\n\n        $watermark = $locator->findResource($file);\n        $watermark = ImageFile::open($watermark);\n\n        // Scaling operations\n        $scale     = ($scale ?? $config->get('system.images.watermark.scale', 100)) / 100;\n        $wwidth    = (int) ($this->get('width')  * $scale);\n        $wheight   = (int) ($this->get('height') * $scale);\n        $watermark->resize($wwidth, $wheight);\n\n        // Position operations\n        $position = !empty($args[1]) ? explode('-',  $args[1]) : ['center', 'center']; // todo change to config\n        $positionY = $position[0] ?? $config->get('system.images.watermark.position_y', 'center');\n        $positionX = $position[1] ?? $config->get('system.images.watermark.position_x', 'center');\n\n        switch ($positionY)\n        {\n            case 'top':\n                $positionY = 0;\n                break;\n\n            case 'bottom':\n                $positionY = (int)$this->get('height')-$wheight;\n                break;\n\n            case 'center':\n                $positionY = ((int)$this->get('height')/2) - ($wheight/2);\n                break;\n        }\n\n        switch ($positionX)\n        {\n            case 'left':\n                $positionX = 0;\n                break;\n\n            case 'right':\n                $positionX = (int) ($this->get('width')-$wwidth);\n                break;\n\n            case 'center':\n                $positionX = (int) (($this->get('width')/2) - ($wwidth/2));\n                break;\n        }\n\n        $this->__call('merge', [$watermark,$positionX, $positionY]);\n\n        return $this;\n    }\n\n    /**\n     * Handle this commonly used variant\n     *\n     * @return $this\n     */\n    public function cropZoom()\n    {\n        $this->__call('zoomCrop', func_get_args());\n\n        return $this;\n    }\n\n    /**\n     * Add a frame to image\n     *\n     * @return $this\n     */\n    public function addFrame(int $border = 10, string $color = '0x000000')\n    {\n      if($border > 0 && preg_match('/^0x[a-f0-9]{6}$/i', $color)) { // $border must be an integer and bigger than 0; $color must be formatted as an HEX value (0x??????).\n        $image = ImageFile::open($this->path());\n      }\n      else {\n        return $this;\n      }\n\n      $dst_width = (int) ($image->width()+2*$border);\n      $dst_height = (int) ($image->height()+2*$border);\n\n      $frame = ImageFile::create($dst_width, $dst_height);\n\n      $frame->__call('fill', [$color]);\n\n      $this->image = $frame;\n\n      $this->__call('merge', [$image, $border, $border]);\n\n      $this->saveImage();\n\n      return $this;\n\n    }\n\n    /**\n     * Forward the call to the image processing method.\n     *\n     * @param string $method\n     * @param mixed $args\n     * @return $this|mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function __call($method, $args)\n    {\n        if (!in_array($method, static::$magic_actions, true)) {\n            return parent::__call($method, $args);\n        }\n\n        // Always initialize image.\n        if (!$this->image) {\n            $this->image();\n        }\n\n        try {\n            $this->image->{$method}(...$args);\n\n            /** @var ImageMediaInterface $medium */\n            foreach ($this->alternatives as $medium) {\n                $args_copy = $args;\n\n                // regular image: resize 400x400 -> 200x200\n                // --> @2x: resize 800x800->400x400\n                if (isset(static::$magic_resize_actions[$method])) {\n                    foreach (static::$magic_resize_actions[$method] as $param) {\n                        if (isset($args_copy[$param])) {\n                            $args_copy[$param] *= $medium->get('ratio');\n                        }\n                    }\n                }\n\n                // Do the same call for alternative media.\n                $medium->__call($method, $args_copy);\n            }\n        } catch (BadFunctionCallException $e) {\n        }\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/Link.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse BadMethodCallException;\nuse Grav\\Common\\Media\\Interfaces\\MediaLinkInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaObjectInterface;\nuse RuntimeException;\nuse function call_user_func_array;\nuse function get_class;\nuse function is_array;\nuse function is_callable;\n\n/**\n * Class Link\n * @package Grav\\Common\\Page\\Medium\n */\nclass Link implements RenderableInterface, MediaLinkInterface\n{\n    use ParsedownHtmlTrait;\n\n    /** @var array */\n    protected $attributes = [];\n    /** @var MediaObjectInterface|MediaLinkInterface */\n    protected $source;\n\n    /**\n     * Construct.\n     * @param array  $attributes\n     * @param MediaObjectInterface $medium\n     */\n    public function __construct(array $attributes, MediaObjectInterface $medium)\n    {\n        $this->attributes = $attributes;\n\n        $source = $medium->reset()->thumbnail('auto')->display('thumbnail');\n        if (!$source instanceof MediaObjectInterface) {\n            throw new RuntimeException('Media has no thumbnail set');\n        }\n\n        $source->set('linked', true);\n\n        $this->source = $source;\n    }\n\n    /**\n     * Get an element (is array) that can be rendered by the Parsedown engine\n     *\n     * @param  string|null  $title\n     * @param  string|null  $alt\n     * @param  string|null  $class\n     * @param  string|null  $id\n     * @param  bool $reset\n     * @return array\n     */\n    public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true)\n    {\n        $innerElement = $this->source->parsedownElement($title, $alt, $class, $id, $reset);\n\n        return [\n            'name' => 'a',\n            'attributes' => $this->attributes,\n            'handler' => is_array($innerElement) ? 'element' : 'line',\n            'text' => $innerElement\n        ];\n    }\n\n    /**\n     * Forward the call to the source element\n     *\n     * @param string $method\n     * @param mixed $args\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function __call($method, $args)\n    {\n        $object = $this->source;\n        $callable = [$object, $method];\n        if (!is_callable($callable)) {\n            throw new BadMethodCallException(get_class($object) . '::' . $method . '() not found.');\n        }\n\n        $object = call_user_func_array($callable, $args);\n        if (!$object instanceof MediaLinkInterface) {\n            // Don't start nesting links, if user has multiple link calls in his\n            // actions, we will drop the previous links.\n            return $this;\n        }\n\n        $this->source = $object;\n\n        return $object;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/Medium.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Media\\Interfaces\\MediaFileInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaLinkInterface;\nuse Grav\\Common\\Media\\Traits\\MediaFileTrait;\nuse Grav\\Common\\Media\\Traits\\MediaObjectTrait;\n\n/**\n * Class Medium\n * @package Grav\\Common\\Page\\Medium\n *\n * @property string $filepath\n * @property string $filename\n * @property string $basename\n * @property string $mime\n * @property int $size\n * @property int $modified\n * @property array $metadata\n * @property int|string $timestamp\n */\nclass Medium extends Data implements RenderableInterface, MediaFileInterface\n{\n    use MediaObjectTrait;\n    use MediaFileTrait;\n    use ParsedownHtmlTrait;\n\n    /**\n     * Construct.\n     *\n     * @param array $items\n     * @param Blueprint|null $blueprint\n     */\n    public function __construct($items = [], Blueprint $blueprint = null)\n    {\n        parent::__construct($items, $blueprint);\n\n        if (Grav::instance()['config']->get('system.media.enable_media_timestamp', true)) {\n            $this->timestamp = Grav::instance()['cache']->getKey();\n        }\n\n        $this->def('mime', 'application/octet-stream');\n\n        if (!$this->offsetExists('size')) {\n            $path = $this->get('filepath');\n            $this->def('size', filesize($path));\n        }\n\n        $this->reset();\n    }\n\n    /**\n     * Clone medium.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __clone()\n    {\n        // Allows future compatibility as parent::__clone() works.\n    }\n\n    /**\n     * Add meta file for the medium.\n     *\n     * @param string $filepath\n     */\n    public function addMetaFile($filepath)\n    {\n        $this->metadata = (array)CompiledYamlFile::instance($filepath)->content();\n        $this->merge($this->metadata);\n    }\n\n    /**\n     * @return array\n     */\n    public function getMeta(): array\n    {\n        return [\n            'mime' => $this->mime,\n            'size' => $this->size,\n            'modified' => $this->modified,\n        ];\n    }\n\n    /**\n     * Return string representation of the object (html).\n     *\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return $this->html();\n    }\n\n    /**\n     * @param string $thumb\n     * @return Medium|null\n     */\n    protected function createThumbnail($thumb)\n    {\n        return MediumFactory::fromFile($thumb, ['type' => 'thumbnail']);\n    }\n\n    /**\n     * @param array $attributes\n     * @return MediaLinkInterface\n     */\n    protected function createLink(array $attributes)\n    {\n        return new Link($attributes, $this);\n    }\n\n    /**\n     * @return Grav\n     */\n    protected function getGrav(): Grav\n    {\n        return Grav::instance();\n    }\n\n    /**\n     * @return array\n     */\n    protected function getItems(): array\n    {\n        return $this->items;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/MediumFactory.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Media\\Interfaces\\ImageMediaInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaObjectInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Form\\FormFlashFile;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse function dirname;\nuse function is_array;\n\n/**\n * Class MediumFactory\n * @package Grav\\Common\\Page\\Medium\n */\nclass MediumFactory\n{\n    /**\n     * Create Medium from a file\n     *\n     * @param  string $file\n     * @param  array  $params\n     * @return Medium|null\n     */\n    public static function fromFile($file, array $params = [])\n    {\n        if (!file_exists($file)) {\n            return null;\n        }\n\n        $parts = Utils::pathinfo($file);\n        $path = $parts['dirname'];\n        $filename = $parts['basename'];\n        $ext = $parts['extension'] ?? '';\n        $basename = $parts['filename'];\n\n        $config = Grav::instance()['config'];\n\n        $media_params = $ext ? $config->get('media.types.' . strtolower($ext)) : null;\n        if (!is_array($media_params)) {\n            return null;\n        }\n\n        // Remove empty 'image' attribute\n        if (isset($media_params['image']) && empty($media_params['image'])) {\n            unset($media_params['image']);\n        }\n\n        $params += $media_params;\n\n        // Add default settings for undefined variables.\n        $params += (array)$config->get('media.types.defaults');\n        $params += [\n            'type' => 'file',\n            'thumb' => 'media/thumb.png',\n            'mime' => 'application/octet-stream',\n            'filepath' => $file,\n            'filename' => $filename,\n            'basename' => $basename,\n            'extension' => $ext,\n            'path' => $path,\n            'modified' => filemtime($file),\n            'thumbnails' => []\n        ];\n\n        $locator = Grav::instance()['locator'];\n\n        $file = $locator->findResource(\"image://{$params['thumb']}\");\n        if ($file) {\n            $params['thumbnails']['default'] = $file;\n        }\n\n        return static::fromArray($params);\n    }\n\n    /**\n     * Create Medium from an uploaded file\n     *\n     * @param  UploadedFileInterface $uploadedFile\n     * @param  array  $params\n     * @return Medium|null\n     */\n    public static function fromUploadedFile(UploadedFileInterface $uploadedFile, array $params = [])\n    {\n        // For now support only FormFlashFiles, which exist over multiple requests. Also ignore errored and moved media.\n        if (!$uploadedFile instanceof FormFlashFile || $uploadedFile->getError() !== \\UPLOAD_ERR_OK || $uploadedFile->isMoved()) {\n            return null;\n        }\n\n        $clientName = $uploadedFile->getClientFilename();\n        if (!$clientName) {\n            return null;\n        }\n\n        $parts = Utils::pathinfo($clientName);\n        $filename = $parts['basename'];\n        $ext = $parts['extension'] ?? '';\n        $basename = $parts['filename'];\n        $file = $uploadedFile->getTmpFile();\n        $path = $file ? dirname($file) : '';\n\n        $config = Grav::instance()['config'];\n\n        $media_params = $ext ? $config->get('media.types.' . strtolower($ext)) : null;\n        if (!is_array($media_params)) {\n            return null;\n        }\n\n        $params += $media_params;\n\n        // Add default settings for undefined variables.\n        $params += (array)$config->get('media.types.defaults');\n        $params += [\n            'type' => 'file',\n            'thumb' => 'media/thumb.png',\n            'mime' => 'application/octet-stream',\n            'filepath' => $file,\n            'filename' => $filename,\n            'basename' => $basename,\n            'extension' => $ext,\n            'path' => $path,\n            'modified' => $file ? filemtime($file) : 0,\n            'thumbnails' => []\n        ];\n\n        $locator = Grav::instance()['locator'];\n\n        $file = $locator->findResource(\"image://{$params['thumb']}\");\n        if ($file) {\n            $params['thumbnails']['default'] = $file;\n        }\n\n        return static::fromArray($params);\n    }\n\n    /**\n     * Create Medium from array of parameters\n     *\n     * @param  array          $items\n     * @param  Blueprint|null $blueprint\n     * @return Medium\n     */\n    public static function fromArray(array $items = [], Blueprint $blueprint = null)\n    {\n        $type = $items['type'] ?? null;\n\n        switch ($type) {\n            case 'image':\n                return new ImageMedium($items, $blueprint);\n            case 'thumbnail':\n                return new ThumbnailImageMedium($items, $blueprint);\n            case 'vector':\n                return new VectorImageMedium($items, $blueprint);\n            case 'animated':\n                return new StaticImageMedium($items, $blueprint);\n            case 'video':\n                return new VideoMedium($items, $blueprint);\n            case 'audio':\n                return new AudioMedium($items, $blueprint);\n            default:\n                return new Medium($items, $blueprint);\n        }\n    }\n\n    /**\n     * Create a new ImageMedium by scaling another ImageMedium object.\n     *\n     * @param  ImageMediaInterface|MediaObjectInterface $medium\n     * @param  int         $from\n     * @param  int         $to\n     * @return ImageMediaInterface|MediaObjectInterface|array\n     */\n    public static function scaledFromMedium($medium, $from, $to)\n    {\n        if (!$medium instanceof ImageMedium) {\n            return $medium;\n        }\n\n        if ($to > $from) {\n            return $medium;\n        }\n\n        $ratio = $to / $from;\n        $width = $medium->get('width') * $ratio;\n        $height = $medium->get('height') * $ratio;\n\n        $prev_basename = $medium->get('basename');\n        $basename = str_replace('@' . $from . 'x', $to !== 1 ? '@' . $to . 'x' : '', $prev_basename);\n\n        $debug = $medium->get('debug');\n        $medium->set('debug', false);\n        $medium->setImagePrettyName($basename);\n\n        $file = $medium->resize($width, $height)->path();\n\n        $medium->set('debug', $debug);\n        $medium->setImagePrettyName($prev_basename);\n\n        $size = filesize($file);\n\n        $medium = self::fromFile($file);\n        if ($medium) {\n            $medium->set('basename', $basename);\n            $medium->set('filename', $basename . '.' . $medium->extension);\n            $medium->set('size', $size);\n        }\n\n        return ['file' => $medium, 'size' => $size];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Markdown\\Parsedown;\nuse Grav\\Common\\Page\\Markdown\\Excerpts;\n\n/**\n * Trait ParsedownHtmlTrait\n * @package Grav\\Common\\Page\\Medium\n */\ntrait ParsedownHtmlTrait\n{\n    /** @var Parsedown|null */\n    protected $parsedown;\n\n    /**\n     * Return HTML markup from the medium.\n     *\n     * @param string|null $title\n     * @param string|null $alt\n     * @param string|null $class\n     * @param string|null $id\n     * @param bool $reset\n     * @return string\n     */\n    public function html($title = null, $alt = null, $class = null, $id = null, $reset = true)\n    {\n        $element = $this->parsedownElement($title, $alt, $class, $id, $reset);\n\n        if (!$this->parsedown) {\n            $this->parsedown = new Parsedown(new Excerpts());\n        }\n\n        return $this->parsedown->elementToHtml($element);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/RenderableInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\n/**\n * Interface RenderableInterface\n * @package Grav\\Common\\Page\\Medium\n */\ninterface RenderableInterface\n{\n    /**\n     * Return HTML markup from the medium.\n     *\n     * @param string|null $title\n     * @param string|null $alt\n     * @param string|null $class\n     * @param string|null $id\n     * @param bool $reset\n     * @return string\n     */\n    public function html($title = null, $alt = null, $class = null, $id = null, $reset = true);\n\n    /**\n     * Return Parsedown Element from the medium.\n     *\n     * @param string|null $title\n     * @param string|null $alt\n     * @param string|null $class\n     * @param string|null $id\n     * @param bool $reset\n     * @return array\n     */\n    public function parsedownElement($title = null, $alt = null, $class = null, $id = null, $reset = true);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/StaticImageMedium.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Media\\Interfaces\\ImageMediaInterface;\nuse Grav\\Common\\Media\\Traits\\ImageLoadingTrait;\nuse Grav\\Common\\Media\\Traits\\StaticResizeTrait;\n\n/**\n * Class StaticImageMedium\n * @package Grav\\Common\\Page\\Medium\n */\nclass StaticImageMedium extends Medium implements ImageMediaInterface\n{\n    use StaticResizeTrait;\n    use ImageLoadingTrait;\n\n    /**\n     * Parsedown element for source display mode\n     *\n     * @param  array $attributes\n     * @param  bool $reset\n     * @return array\n     */\n    protected function sourceParsedownElement(array $attributes, $reset = true)\n    {\n        if (empty($attributes['src'])) {\n            $attributes['src'] = $this->url($reset);\n        }\n\n        return ['name' => 'img', 'attributes' => $attributes];\n    }\n\n    /**\n     * @return $this\n     */\n    public function higherQualityAlternative()\n    {\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/StaticResizeTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Media\\Traits\\StaticResizeTrait as NewResizeTrait;\n\nuser_error('Grav\\Common\\Page\\Medium\\StaticResizeTrait is deprecated since Grav 1.7, use Grav\\Common\\Media\\Traits\\StaticResizeTrait instead', E_USER_DEPRECATED);\n\n/**\n * Trait StaticResizeTrait\n * @package Grav\\Common\\Page\\Medium\n * @deprecated 1.7 Use `Grav\\Common\\Media\\Traits\\StaticResizeTrait` instead\n */\ntrait StaticResizeTrait\n{\n    use NewResizeTrait;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/ThumbnailImageMedium.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Media\\Traits\\ThumbnailMediaTrait;\n\n/**\n * Class ThumbnailImageMedium\n * @package Grav\\Common\\Page\\Medium\n */\nclass ThumbnailImageMedium extends ImageMedium\n{\n    use ThumbnailMediaTrait;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/VectorImageMedium.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Data\\Blueprint;\n\n\n/**\n * Class StaticImageMedium\n * @package Grav\\Common\\Page\\Medium\n */\nclass VectorImageMedium extends StaticImageMedium\n{\n    /**\n     * Construct.\n     *\n     * @param array $items\n     * @param Blueprint|null $blueprint\n     */\n    public function __construct($items = [], Blueprint $blueprint = null)\n    {\n        parent::__construct($items, $blueprint);\n\n        // If we already have the image size, we do not need to do anything else.\n        $width = $this->get('width');\n        $height = $this->get('height');\n        if ($width && $height) {\n            return;\n        }\n\n        // Make sure that getting image size is supported.\n        if ($this->mime !== 'image/svg+xml' || !\\extension_loaded('simplexml')) {\n            return;\n        }\n\n        // Make sure that the image exists.\n        $path = $this->get('filepath');\n        if (!$path || !file_exists($path) || !filesize($path)) {\n            return;\n        }\n\n        $xml = simplexml_load_string(file_get_contents($path));\n        $attr = $xml ? $xml->attributes() : null;\n        if (!$attr instanceof \\SimpleXMLElement) {\n            return;\n        }\n\n        // Get the size from svg image.\n        if ($attr->width && $attr->height) {\n            $width = (string)$attr->width;\n            $height = (string)$attr->height;\n        } elseif ($attr->viewBox && \\count($size = explode(' ', (string)$attr->viewBox)) === 4) {\n            [,$width,$height,] = $size;\n        }\n\n        if ($width && $height) {\n            $this->def('width', (int)$width);\n            $this->def('height', (int)$height);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Medium/VideoMedium.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page\\Medium;\n\nuse Grav\\Common\\Media\\Interfaces\\VideoMediaInterface;\nuse Grav\\Common\\Media\\Traits\\VideoMediaTrait;\n\n/**\n * Class VideoMedium\n * @package Grav\\Common\\Page\\Medium\n */\nclass VideoMedium extends Medium implements VideoMediaInterface\n{\n    use VideoMediaTrait;\n\n    /**\n     * Reset medium.\n     *\n     * @return $this\n     */\n    public function reset()\n    {\n        parent::reset();\n\n        $this->resetPlayer();\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Page.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page;\n\nuse Exception;\nuse Grav\\Common\\Cache;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Markdown\\Parsedown;\nuse Grav\\Common\\Markdown\\ParsedownExtra;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Media\\Traits\\MediaTrait;\nuse Grav\\Common\\Page\\Markdown\\Excerpts;\nuse Grav\\Common\\Page\\Traits\\PageFormTrait;\nuse Grav\\Common\\Twig\\Twig;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Grav\\Common\\Yaml;\nuse Grav\\Framework\\Flex\\Flex;\nuse InvalidArgumentException;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\File\\MarkdownFile;\nuse RuntimeException;\nuse SplFileInfo;\nuse function dirname;\nuse function in_array;\nuse function is_array;\nuse function is_object;\nuse function is_string;\nuse function strlen;\n\ndefine('PAGE_ORDER_PREFIX_REGEX', '/^[0-9]+\\./u');\n\n/**\n * Class Page\n * @package Grav\\Common\\Page\n */\nclass Page implements PageInterface\n{\n    use PageFormTrait;\n    use MediaTrait;\n\n    /** @var string|null Filename. Leave as null if page is folder. */\n    protected $name;\n    /** @var bool */\n    protected $initialized = false;\n    /** @var string */\n    protected $folder;\n    /** @var string */\n    protected $path;\n    /** @var string */\n    protected $extension;\n    /** @var string */\n    protected $url_extension;\n    /** @var string */\n    protected $id;\n    /** @var string */\n    protected $parent;\n    /** @var string */\n    protected $template;\n    /** @var int */\n    protected $expires;\n    /** @var string */\n    protected $cache_control;\n    /** @var bool */\n    protected $visible;\n    /** @var bool */\n    protected $published;\n    /** @var int */\n    protected $publish_date;\n    /** @var int|null */\n    protected $unpublish_date;\n    /** @var string */\n    protected $slug;\n    /** @var string|null */\n    protected $route;\n    /** @var string|null */\n    protected $raw_route;\n    /** @var string */\n    protected $url;\n    /** @var array */\n    protected $routes;\n    /** @var bool */\n    protected $routable;\n    /** @var int */\n    protected $modified;\n    /** @var string */\n    protected $redirect;\n    /** @var string */\n    protected $external_url;\n    /** @var object|null */\n    protected $header;\n    /** @var string */\n    protected $frontmatter;\n    /** @var string */\n    protected $language;\n    /** @var string|null */\n    protected $content;\n    /** @var array */\n    protected $content_meta;\n    /** @var string|null */\n    protected $summary;\n    /** @var string */\n    protected $raw_content;\n    /** @var array|null */\n    protected $metadata;\n    /** @var string */\n    protected $title;\n    /** @var int */\n    protected $max_count;\n    /** @var string */\n    protected $menu;\n    /** @var int */\n    protected $date;\n    /** @var string */\n    protected $dateformat;\n    /** @var array */\n    protected $taxonomy;\n    /** @var string */\n    protected $order_by;\n    /** @var string */\n    protected $order_dir;\n    /** @var array|string|null */\n    protected $order_manual;\n    /** @var bool */\n    protected $modular_twig;\n    /** @var array */\n    protected $process;\n    /** @var int|null */\n    protected $summary_size;\n    /** @var bool */\n    protected $markdown_extra;\n    /** @var bool */\n    protected $etag;\n    /** @var bool */\n    protected $last_modified;\n    /** @var string */\n    protected $home_route;\n    /** @var bool */\n    protected $hide_home_route;\n    /** @var bool */\n    protected $ssl;\n    /** @var string */\n    protected $template_format;\n    /** @var bool */\n    protected $debugger;\n\n    /** @var PageInterface|null Unmodified (original) version of the page. Used for copying and moving the page. */\n    private $_original;\n    /** @var string Action */\n    private $_action;\n\n    /**\n     * Page Object Constructor\n     */\n    public function __construct()\n    {\n        /** @var Config $config */\n        $config = Grav::instance()['config'];\n\n        $this->taxonomy = [];\n        $this->process = $config->get('system.pages.process');\n        $this->published = true;\n    }\n\n    /**\n     * Initializes the page instance variables based on a file\n     *\n     * @param  SplFileInfo $file The file information for the .md file that the page represents\n     * @param  string|null $extension\n     * @return $this\n     */\n    public function init(SplFileInfo $file, $extension = null)\n    {\n        $config = Grav::instance()['config'];\n\n        $this->initialized = true;\n\n        // some extension logic\n        if (empty($extension)) {\n            $this->extension('.' . $file->getExtension());\n        } else {\n            $this->extension($extension);\n        }\n\n        // extract page language from page extension\n        $language = trim(Utils::basename($this->extension(), 'md'), '.') ?: null;\n        $this->language($language);\n\n        $this->hide_home_route = $config->get('system.home.hide_in_urls', false);\n        $this->home_route = $this->adjustRouteCase($config->get('system.home.alias'));\n        $this->filePath($file->getPathname());\n        $this->modified($file->getMTime());\n        $this->id($this->modified() . md5($this->filePath()));\n        $this->routable(true);\n        $this->header();\n        $this->date();\n        $this->metadata();\n        $this->url();\n        $this->visible();\n        $this->modularTwig(strpos($this->slug(), '_') === 0);\n        $this->setPublishState();\n        $this->published();\n        $this->urlExtension();\n\n        return $this;\n    }\n\n    #[\\ReturnTypeWillChange]\n    public function __clone()\n    {\n        $this->initialized = false;\n        $this->header = $this->header ? clone $this->header : null;\n    }\n\n    /**\n     * @return void\n     */\n    public function initialize(): void\n    {\n        if (!$this->initialized) {\n            $this->initialized = true;\n            $this->route = null;\n            $this->raw_route = null;\n            $this->_forms = null;\n        }\n    }\n\n    /**\n     * @return void\n     */\n    protected function processFrontmatter()\n    {\n        // Quick check for twig output tags in frontmatter if enabled\n        $process_fields = (array)$this->header();\n        if (Utils::contains(json_encode(array_values($process_fields)), '{{')) {\n            $ignored_fields = [];\n            foreach ((array)Grav::instance()['config']->get('system.pages.frontmatter.ignore_fields') as $field) {\n                if (isset($process_fields[$field])) {\n                    $ignored_fields[$field] = $process_fields[$field];\n                    unset($process_fields[$field]);\n                }\n            }\n            $text_header = Grav::instance()['twig']->processString(json_encode($process_fields, JSON_UNESCAPED_UNICODE), ['page' => $this]);\n            $this->header((object)(json_decode($text_header, true) + $ignored_fields));\n        }\n    }\n\n    /**\n     * Return an array with the routes of other translated languages\n     *\n     * @param bool $onlyPublished only return published translations\n     * @return array the page translated languages\n     */\n    public function translatedLanguages($onlyPublished = false)\n    {\n        $grav = Grav::instance();\n\n        /** @var Language $language */\n        $language = $grav['language'];\n\n        $languages = $language->getLanguages();\n        $defaultCode = $language->getDefault();\n\n        $name = substr($this->name, 0, -strlen($this->extension()));\n        $translatedLanguages = [];\n\n        foreach ($languages as $languageCode) {\n            $languageExtension = \".{$languageCode}.md\";\n            $path = $this->path . DS . $this->folder . DS . $name . $languageExtension;\n            $exists = file_exists($path);\n\n            // Default language may be saved without language file location.\n            if (!$exists && $languageCode === $defaultCode) {\n                $languageExtension = '.md';\n                $path = $this->path . DS . $this->folder . DS . $name . $languageExtension;\n                $exists = file_exists($path);\n            }\n\n            if ($exists) {\n                $aPage = new Page();\n                $aPage->init(new SplFileInfo($path), $languageExtension);\n                $aPage->route($this->route());\n                $aPage->rawRoute($this->rawRoute());\n                $route = $aPage->header()->routes['default'] ?? $aPage->rawRoute();\n                if (!$route) {\n                    $route = $aPage->route();\n                }\n\n                if ($onlyPublished && !$aPage->published()) {\n                    continue;\n                }\n\n                $translatedLanguages[$languageCode] = $route;\n            }\n        }\n\n        return $translatedLanguages;\n    }\n\n    /**\n     * Return an array listing untranslated languages available\n     *\n     * @param bool $includeUnpublished also list unpublished translations\n     * @return array the page untranslated languages\n     */\n    public function untranslatedLanguages($includeUnpublished = false)\n    {\n        $grav = Grav::instance();\n\n        /** @var Language $language */\n        $language = $grav['language'];\n\n        $languages = $language->getLanguages();\n        $translated = array_keys($this->translatedLanguages(!$includeUnpublished));\n\n        return array_values(array_diff($languages, $translated));\n    }\n\n    /**\n     * Gets and Sets the raw data\n     *\n     * @param  string|null $var Raw content string\n     * @return string      Raw content string\n     */\n    public function raw($var = null)\n    {\n        $file = $this->file();\n\n        if ($var) {\n            // First update file object.\n            if ($file) {\n                $file->raw($var);\n            }\n\n            // Reset header and content.\n            $this->modified = time();\n            $this->id($this->modified() . md5($this->filePath()));\n            $this->header = null;\n            $this->content = null;\n            $this->summary = null;\n        }\n\n        return $file ? $file->raw() : '';\n    }\n\n    /**\n     * Gets and Sets the page frontmatter\n     *\n     * @param string|null $var\n     *\n     * @return string\n     */\n    public function frontmatter($var = null)\n    {\n        if ($var) {\n            $this->frontmatter = (string)$var;\n\n            // Update also file object.\n            $file = $this->file();\n            if ($file) {\n                $file->frontmatter((string)$var);\n            }\n\n            // Force content re-processing.\n            $this->id(time() . md5($this->filePath()));\n        }\n        if (!$this->frontmatter) {\n            $this->header();\n        }\n\n        return $this->frontmatter;\n    }\n\n    /**\n     * Gets and Sets the header based on the YAML configuration at the top of the .md file\n     *\n     * @param  object|array|null $var a YAML object representing the configuration for the file\n     * @return \\stdClass      the current YAML configuration\n     */\n    public function header($var = null)\n    {\n        if ($var) {\n            $this->header = (object)$var;\n\n            // Update also file object.\n            $file = $this->file();\n            if ($file) {\n                $file->header((array)$var);\n            }\n\n            // Force content re-processing.\n            $this->id(time() . md5($this->filePath()));\n        }\n        if (!$this->header) {\n            $file = $this->file();\n            if ($file) {\n                try {\n                    $this->raw_content = $file->markdown();\n                    $this->frontmatter = $file->frontmatter();\n                    $this->header = (object)$file->header();\n\n                    if (!Utils::isAdminPlugin()) {\n                        // If there's a `frontmatter.yaml` file merge that in with the page header\n                        // note page's own frontmatter has precedence and will overwrite any defaults\n                        $frontmatter_filename = $this->path . '/' . $this->folder . '/frontmatter.yaml';\n                        if (file_exists($frontmatter_filename)) {\n                            $frontmatter_file = CompiledYamlFile::instance($frontmatter_filename);\n                            $frontmatter_data = $frontmatter_file->content();\n                            $this->header = (object)array_replace_recursive(\n                                $frontmatter_data,\n                                (array)$this->header\n                            );\n                            $frontmatter_file->free();\n                        }\n\n                        // Process frontmatter with Twig if enabled\n                        if (Grav::instance()['config']->get('system.pages.frontmatter.process_twig') === true) {\n                            $this->processFrontmatter();\n                        }\n                    }\n                } catch (Exception $e) {\n                    $file->raw(Grav::instance()['language']->translate([\n                        'GRAV.FRONTMATTER_ERROR_PAGE',\n                        $this->slug(),\n                        $file->filename(),\n                        $e->getMessage(),\n                        $file->raw()\n                    ]));\n                    $this->raw_content = $file->markdown();\n                    $this->frontmatter = $file->frontmatter();\n                    $this->header = (object)$file->header();\n                }\n                $var = true;\n            }\n        }\n\n        if ($var) {\n            if (isset($this->header->modified)) {\n                $this->modified($this->header->modified);\n            }\n            if (isset($this->header->slug)) {\n                $this->slug($this->header->slug);\n            }\n            if (isset($this->header->routes)) {\n                $this->routes = (array)$this->header->routes;\n            }\n            if (isset($this->header->title)) {\n                $this->title = trim($this->header->title);\n            }\n            if (isset($this->header->language)) {\n                $this->language = trim($this->header->language);\n            }\n            if (isset($this->header->template)) {\n                $this->template = trim($this->header->template);\n            }\n            if (isset($this->header->menu)) {\n                $this->menu = trim($this->header->menu);\n            }\n            if (isset($this->header->routable)) {\n                $this->routable = (bool)$this->header->routable;\n            }\n            if (isset($this->header->visible)) {\n                $this->visible = (bool)$this->header->visible;\n            }\n            if (isset($this->header->redirect)) {\n                $this->redirect = trim($this->header->redirect);\n            }\n            if (isset($this->header->external_url)) {\n                $this->external_url = trim($this->header->external_url);\n            }\n            if (isset($this->header->order_dir)) {\n                $this->order_dir = trim($this->header->order_dir);\n            }\n            if (isset($this->header->order_by)) {\n                $this->order_by = trim($this->header->order_by);\n            }\n            if (isset($this->header->order_manual)) {\n                $this->order_manual = (array)$this->header->order_manual;\n            }\n            if (isset($this->header->dateformat)) {\n                $this->dateformat($this->header->dateformat);\n            }\n            if (isset($this->header->date)) {\n                $this->date($this->header->date);\n            }\n            if (isset($this->header->markdown_extra)) {\n                $this->markdown_extra = (bool)$this->header->markdown_extra;\n            }\n            if (isset($this->header->taxonomy)) {\n                $this->taxonomy($this->header->taxonomy);\n            }\n            if (isset($this->header->max_count)) {\n                $this->max_count = (int)$this->header->max_count;\n            }\n            if (isset($this->header->process)) {\n                foreach ((array)$this->header->process as $process => $status) {\n                    $this->process[$process] = (bool)$status;\n                }\n            }\n            if (isset($this->header->published)) {\n                $this->published = (bool)$this->header->published;\n            }\n            if (isset($this->header->publish_date)) {\n                $this->publishDate($this->header->publish_date);\n            }\n            if (isset($this->header->unpublish_date)) {\n                $this->unpublishDate($this->header->unpublish_date);\n            }\n            if (isset($this->header->expires)) {\n                $this->expires = (int)$this->header->expires;\n            }\n            if (isset($this->header->cache_control)) {\n                $this->cache_control = $this->header->cache_control;\n            }\n            if (isset($this->header->etag)) {\n                $this->etag = (bool)$this->header->etag;\n            }\n            if (isset($this->header->last_modified)) {\n                $this->last_modified = (bool)$this->header->last_modified;\n            }\n            if (isset($this->header->ssl)) {\n                $this->ssl = (bool)$this->header->ssl;\n            }\n            if (isset($this->header->template_format)) {\n                $this->template_format = $this->header->template_format;\n            }\n            if (isset($this->header->debugger)) {\n                $this->debugger = (bool)$this->header->debugger;\n            }\n            if (isset($this->header->append_url_extension)) {\n                $this->url_extension = $this->header->append_url_extension;\n            }\n        }\n\n        return $this->header;\n    }\n\n    /**\n     * Get page language\n     *\n     * @param string|null $var\n     * @return mixed\n     */\n    public function language($var = null)\n    {\n        if ($var !== null) {\n            $this->language = $var;\n        }\n\n        return $this->language;\n    }\n\n    /**\n     * Modify a header value directly\n     *\n     * @param string $key\n     * @param mixed $value\n     */\n    public function modifyHeader($key, $value)\n    {\n        $this->header->{$key} = $value;\n    }\n\n    /**\n     * @return int\n     */\n    public function httpResponseCode()\n    {\n        return (int)($this->header()->http_response_code ?? 200);\n    }\n\n    /**\n     * @return array\n     */\n    public function httpHeaders()\n    {\n        $headers = [];\n\n        $grav = Grav::instance();\n        $format = $this->templateFormat();\n        $cache_control = $this->cacheControl();\n        $expires = $this->expires();\n\n        // Set Content-Type header\n        $headers['Content-Type'] = Utils::getMimeByExtension($format, 'text/html');\n\n        // Calculate Expires Headers if set to > 0\n        if ($expires > 0) {\n            $expires_date = gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT';\n            if (!$cache_control) {\n                $headers['Cache-Control'] = 'max-age=' . $expires;\n            }\n            $headers['Expires'] = $expires_date;\n        }\n\n        // Set Cache-Control header\n        if ($cache_control) {\n            $headers['Cache-Control'] = strtolower($cache_control);\n        }\n\n        // Set Last-Modified header\n        if ($this->lastModified()) {\n            $last_modified = $this->modified();\n            foreach ($this->children()->modular() as $cpage) {\n                $modular_mtime = $cpage->modified();\n                if ($modular_mtime > $last_modified) {\n                    $last_modified = $modular_mtime;\n                }\n            }\n\n            $last_modified_date = gmdate('D, d M Y H:i:s', $last_modified) . ' GMT';\n            $headers['Last-Modified'] = $last_modified_date;\n        }\n\n        // Ask Grav to calculate ETag from the final content.\n        if ($this->eTag()) {\n            $headers['ETag'] = '1';\n        }\n\n        // Set Vary: Accept-Encoding header\n        if ($grav['config']->get('system.pages.vary_accept_encoding', false)) {\n            $headers['Vary'] = 'Accept-Encoding';\n        }\n\n\n        // Added new Headers event\n        $headers_obj = (object) $headers;\n        Grav::instance()->fireEvent('onPageHeaders', new Event(['headers' => $headers_obj]));\n\n        return (array)$headers_obj;\n    }\n\n    /**\n     * Get the summary.\n     *\n     * @param int|null $size Max summary size.\n     * @param bool $textOnly Only count text size.\n     * @return string\n     */\n    public function summary($size = null, $textOnly = false)\n    {\n        $config = (array)Grav::instance()['config']->get('site.summary');\n        if (isset($this->header->summary)) {\n            $config = array_merge($config, $this->header->summary);\n        }\n\n        // Return summary based on settings in site config file\n        if (!$config['enabled']) {\n            return $this->content();\n        }\n\n        // Set up variables to process summary from page or from custom summary\n        if ($this->summary === null) {\n            $content = $textOnly ? strip_tags($this->content()) : $this->content();\n            $summary_size = $this->summary_size;\n        } else {\n            $content = $textOnly ? strip_tags($this->summary) : $this->summary;\n            $summary_size = mb_strwidth($content, 'utf-8');\n        }\n\n        // Return calculated summary based on summary divider's position\n        $format = $config['format'];\n        // Return entire page content on wrong/ unknown format\n        if (!in_array($format, ['short', 'long'])) {\n            return $content;\n        }\n        if (($format === 'short') && isset($summary_size)) {\n            // Slice the string\n            if (mb_strwidth($content, 'utf8') > $summary_size) {\n                return mb_substr($content, 0, $summary_size);\n            }\n\n            return $content;\n        }\n\n        // Get summary size from site config's file\n        if ($size === null) {\n            $size = $config['size'];\n        }\n\n        // If the size is zero, return the entire page content\n        if ($size === 0) {\n            return $content;\n            // Return calculated summary based on defaults\n        }\n        if (!is_numeric($size) || ($size < 0)) {\n            $size = 300;\n        }\n\n        // Only return string but not html, wrap whatever html tag you want when using\n        if ($textOnly) {\n            if (mb_strwidth($content, 'utf-8') <= $size) {\n                return $content;\n            }\n\n            return mb_strimwidth($content, 0, $size, '…', 'UTF-8');\n        }\n\n        $summary = Utils::truncateHtml($content, $size);\n\n        return html_entity_decode($summary, ENT_COMPAT | ENT_HTML401, 'UTF-8');\n    }\n\n    /**\n     * Sets the summary of the page\n     *\n     * @param string $summary Summary\n     */\n    public function setSummary($summary)\n    {\n        $this->summary = $summary;\n    }\n\n    /**\n     * Gets and Sets the content based on content portion of the .md file\n     *\n     * @param  string|null $var Content\n     * @return string      Content\n     */\n    public function content($var = null)\n    {\n        if ($var !== null) {\n            $this->raw_content = $var;\n\n            // Update file object.\n            $file = $this->file();\n            if ($file) {\n                $file->markdown($var);\n            }\n\n            // Force re-processing.\n            $this->id(time() . md5($this->filePath()));\n            $this->content = null;\n        }\n        // If no content, process it\n        if ($this->content === null) {\n            // Get media\n            $this->media();\n\n            /** @var Config $config */\n            $config = Grav::instance()['config'];\n\n            // Load cached content\n            /** @var Cache $cache */\n            $cache = Grav::instance()['cache'];\n            $cache_id = md5('page' . $this->getCacheKey());\n            $content_obj = $cache->fetch($cache_id);\n\n            if (is_array($content_obj)) {\n                $this->content = $content_obj['content'];\n                $this->content_meta = $content_obj['content_meta'];\n            } else {\n                $this->content = $content_obj;\n            }\n\n\n            $process_markdown = $this->shouldProcess('markdown');\n            $process_twig = $this->shouldProcess('twig') || $this->modularTwig();\n\n            $cache_enable = $this->header->cache_enable ?? $config->get(\n                'system.cache.enabled',\n                true\n            );\n            $twig_first = $this->header->twig_first ?? $config->get(\n                'system.pages.twig_first',\n                false\n            );\n\n            // never cache twig means it's always run after content\n            $never_cache_twig = $this->header->never_cache_twig ?? $config->get(\n                'system.pages.never_cache_twig',\n                true\n            );\n\n            // if no cached-content run everything\n            if ($never_cache_twig) {\n                if ($this->content === false || $cache_enable === false) {\n                    $this->content = $this->raw_content;\n                    Grav::instance()->fireEvent('onPageContentRaw', new Event(['page' => $this]));\n\n                    if ($process_markdown) {\n                        $this->processMarkdown();\n                    }\n\n                    // Content Processed but not cached yet\n                    Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this]));\n\n                    if ($cache_enable) {\n                        $this->cachePageContent();\n                    }\n                }\n\n                if ($process_twig) {\n                    $this->processTwig();\n                }\n            } else {\n                if ($this->content === false || $cache_enable === false) {\n                    $this->content = $this->raw_content;\n                    Grav::instance()->fireEvent('onPageContentRaw', new Event(['page' => $this]));\n\n                    if ($twig_first) {\n                        if ($process_twig) {\n                            $this->processTwig();\n                        }\n                        if ($process_markdown) {\n                            $this->processMarkdown();\n                        }\n\n                        // Content Processed but not cached yet\n                        Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this]));\n                    } else {\n                        if ($process_markdown) {\n                            $this->processMarkdown($process_twig);\n                        }\n\n                        // Content Processed but not cached yet\n                        Grav::instance()->fireEvent('onPageContentProcessed', new Event(['page' => $this]));\n\n                        if ($process_twig) {\n                            $this->processTwig();\n                        }\n                    }\n\n                    if ($cache_enable) {\n                        $this->cachePageContent();\n                    }\n                }\n            }\n\n            // Handle summary divider\n            $delimiter = $config->get('site.summary.delimiter', '===');\n            $divider_pos = mb_strpos($this->content, \"<p>{$delimiter}</p>\");\n            if ($divider_pos !== false) {\n                $this->summary_size = $divider_pos;\n                $this->content = str_replace(\"<p>{$delimiter}</p>\", '', $this->content);\n            }\n\n            // Fire event when Page::content() is called\n            Grav::instance()->fireEvent('onPageContent', new Event(['page' => $this]));\n        }\n\n        return $this->content;\n    }\n\n    /**\n     * Get the contentMeta array and initialize content first if it's not already\n     *\n     * @return mixed\n     */\n    public function contentMeta()\n    {\n        if ($this->content === null) {\n            $this->content();\n        }\n\n        return $this->getContentMeta();\n    }\n\n    /**\n     * Add an entry to the page's contentMeta array\n     *\n     * @param string $name\n     * @param mixed $value\n     */\n    public function addContentMeta($name, $value)\n    {\n        $this->content_meta[$name] = $value;\n    }\n\n    /**\n     * Return the whole contentMeta array as it currently stands\n     *\n     * @param string|null $name\n     *\n     * @return mixed|null\n     */\n    public function getContentMeta($name = null)\n    {\n        if ($name) {\n            return $this->content_meta[$name] ?? null;\n        }\n\n        return $this->content_meta;\n    }\n\n    /**\n     * Sets the whole content meta array in one shot\n     *\n     * @param array $content_meta\n     *\n     * @return array\n     */\n    public function setContentMeta($content_meta)\n    {\n        return $this->content_meta = $content_meta;\n    }\n\n    /**\n     * Process the Markdown content.  Uses Parsedown or Parsedown Extra depending on configuration\n     *\n     * @param bool $keepTwig If true, content between twig tags will not be processed.\n     * @return void\n     */\n    protected function processMarkdown(bool $keepTwig = false)\n    {\n        /** @var Config $config */\n        $config = Grav::instance()['config'];\n\n        $markdownDefaults = (array)$config->get('system.pages.markdown');\n        if (isset($this->header()->markdown)) {\n            $markdownDefaults = array_merge($markdownDefaults, $this->header()->markdown);\n        }\n\n        // pages.markdown_extra is deprecated, but still check it...\n        if (!isset($markdownDefaults['extra']) && (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null)) {\n            user_error('Configuration option \\'system.pages.markdown_extra\\' is deprecated since Grav 1.5, use \\'system.pages.markdown.extra\\' instead', E_USER_DEPRECATED);\n\n            $markdownDefaults['extra'] = $this->markdown_extra ?: $config->get('system.pages.markdown_extra');\n        }\n\n        $extra = $markdownDefaults['extra'] ?? false;\n        $defaults = [\n            'markdown' => $markdownDefaults,\n            'images' => $config->get('system.images', [])\n        ];\n\n        $excerpts = new Excerpts($this, $defaults);\n\n        // Initialize the preferred variant of Parsedown\n        if ($extra) {\n            $parsedown = new ParsedownExtra($excerpts);\n        } else {\n            $parsedown = new Parsedown($excerpts);\n        }\n\n        $content = $this->content;\n        if ($keepTwig) {\n            $token = [\n                '/' . Utils::generateRandomString(3),\n                Utils::generateRandomString(3) . '/'\n            ];\n            // Base64 encode any twig.\n            $content = preg_replace_callback(\n                ['/({#.*?#})/mu', '/({{.*?}})/mu', '/({%.*?%})/mu'],\n                static function ($matches) use ($token) { return $token[0] . base64_encode($matches[1]) . $token[1]; },\n                $content\n            );\n        }\n\n        $content = $parsedown->text($content);\n\n        if ($keepTwig) {\n            // Base64 decode the encoded twig.\n            $content = preg_replace_callback(\n                ['`' . $token[0] . '([A-Za-z0-9+/]+={0,2})' . $token[1] . '`mu'],\n                static function ($matches) { return base64_decode($matches[1]); },\n                $content\n            );\n        }\n\n        $this->content = $content;\n    }\n\n\n    /**\n     * Process the Twig page content.\n     *\n     * @return void\n     */\n    private function processTwig()\n    {\n        /** @var Twig $twig */\n        $twig = Grav::instance()['twig'];\n        $this->content = $twig->processPage($this, $this->content);\n    }\n\n    /**\n     * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page\n     *\n     * @return void\n     */\n    public function cachePageContent()\n    {\n        /** @var Cache $cache */\n        $cache = Grav::instance()['cache'];\n        $cache_id = md5('page' . $this->getCacheKey());\n        $cache->save($cache_id, ['content' => $this->content, 'content_meta' => $this->content_meta]);\n    }\n\n    /**\n     * Needed by the onPageContentProcessed event to get the raw page content\n     *\n     * @return string   the current page content\n     */\n    public function getRawContent()\n    {\n        return $this->content;\n    }\n\n    /**\n     * Needed by the onPageContentProcessed event to set the raw page content\n     *\n     * @param string|null $content\n     * @return void\n     */\n    public function setRawContent($content)\n    {\n        $this->content = $content ?? '';\n    }\n\n    /**\n     * Get value from a page variable (used mostly for creating edit forms).\n     *\n     * @param string $name Variable name.\n     * @param mixed $default\n     * @return mixed\n     */\n    public function value($name, $default = null)\n    {\n        if ($name === 'content') {\n            return $this->raw_content;\n        }\n        if ($name === 'route') {\n            $parent = $this->parent();\n\n            return $parent ? $parent->rawRoute() : '';\n        }\n        if ($name === 'order') {\n            $order = $this->order();\n\n            return $order ? (int)$this->order() : '';\n        }\n        if ($name === 'ordering') {\n            return (bool)$this->order();\n        }\n        if ($name === 'folder') {\n            return preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder);\n        }\n        if ($name === 'slug') {\n            return $this->slug();\n        }\n        if ($name === 'name') {\n            $name = $this->name();\n            $language = $this->language() ? '.' . $this->language() : '';\n            $pattern = '%(' . preg_quote($language, '%') . ')?\\.md$%';\n            $name = preg_replace($pattern, '', $name);\n\n            if ($this->isModule()) {\n                return 'modular/' . $name;\n            }\n\n            return $name;\n        }\n        if ($name === 'media') {\n            return $this->media()->all();\n        }\n        if ($name === 'media.file') {\n            return $this->media()->files();\n        }\n        if ($name === 'media.video') {\n            return $this->media()->videos();\n        }\n        if ($name === 'media.image') {\n            return $this->media()->images();\n        }\n        if ($name === 'media.audio') {\n            return $this->media()->audios();\n        }\n\n        $path = explode('.', $name);\n        $scope = array_shift($path);\n\n        if ($name === 'frontmatter') {\n            return $this->frontmatter;\n        }\n\n        if ($scope === 'header') {\n            $current = $this->header();\n            foreach ($path as $field) {\n                if (is_object($current) && isset($current->{$field})) {\n                    $current = $current->{$field};\n                } elseif (is_array($current) && isset($current[$field])) {\n                    $current = $current[$field];\n                } else {\n                    return $default;\n                }\n            }\n\n            return $current;\n        }\n\n        return $default;\n    }\n\n    /**\n     * Gets and Sets the Page raw content\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function rawMarkdown($var = null)\n    {\n        if ($var !== null) {\n            $this->raw_content = $var;\n        }\n\n        return $this->raw_content;\n    }\n\n    /**\n     * @return bool\n     * @internal\n     */\n    public function translated(): bool\n    {\n        return $this->initialized;\n    }\n\n    /**\n     * Get file object to the page.\n     *\n     * @return MarkdownFile|null\n     */\n    public function file()\n    {\n        if ($this->name) {\n            return MarkdownFile::instance($this->filePath());\n        }\n\n        return null;\n    }\n\n    /**\n     * Save page if there's a file assigned to it.\n     *\n     * @param bool|array $reorder Internal use.\n     */\n    public function save($reorder = true)\n    {\n        // Perform move, copy [or reordering] if needed.\n        $this->doRelocation();\n\n        $file = $this->file();\n        if ($file) {\n            $file->filename($this->filePath());\n            $file->header((array)$this->header());\n            $file->markdown($this->raw_content);\n            $file->save();\n        }\n\n        // Perform reorder if required\n        if ($reorder && is_array($reorder)) {\n            $this->doReorder($reorder);\n        }\n\n        // We need to signal Flex Pages about the change.\n        /** @var Flex|null $flex */\n        $flex = Grav::instance()['flex'] ?? null;\n        $directory = $flex ? $flex->getDirectory('pages') : null;\n        if (null !== $directory) {\n            $directory->clearCache();\n        }\n\n        $this->_original = null;\n    }\n\n    /**\n     * Prepare move page to new location. Moves also everything that's under the current page.\n     *\n     * You need to call $this->save() in order to perform the move.\n     *\n     * @param PageInterface $parent New parent page.\n     * @return $this\n     */\n    public function move(PageInterface $parent)\n    {\n        if (!$this->_original) {\n            $clone = clone $this;\n            $this->_original = $clone;\n        }\n\n        $this->_action = 'move';\n\n        if ($this->route() === $parent->route()) {\n            throw new RuntimeException('Failed: Cannot set page parent to self');\n        }\n        if (Utils::startsWith($parent->rawRoute(), $this->rawRoute())) {\n            throw new RuntimeException('Failed: Cannot set page parent to a child of current page');\n        }\n\n        $this->parent($parent);\n        $this->id(time() . md5($this->filePath()));\n\n        if ($parent->path()) {\n            $this->path($parent->path() . '/' . $this->folder());\n        }\n\n        if ($parent->route()) {\n            $this->route($parent->route() . '/' . $this->slug());\n        } else {\n            $this->route(Grav::instance()['pages']->root()->route() . '/' . $this->slug());\n        }\n\n        $this->raw_route = null;\n\n        return $this;\n    }\n\n    /**\n     * Prepare a copy from the page. Copies also everything that's under the current page.\n     *\n     * Returns a new Page object for the copy.\n     * You need to call $this->save() in order to perform the move.\n     *\n     * @param PageInterface $parent New parent page.\n     * @return $this\n     */\n    public function copy(PageInterface $parent)\n    {\n        $this->move($parent);\n        $this->_action = 'copy';\n\n        return $this;\n    }\n\n    /**\n     * Get blueprints for the page.\n     *\n     * @return Blueprint\n     */\n    public function blueprints()\n    {\n        $grav = Grav::instance();\n\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n\n        $blueprint = $pages->blueprints($this->blueprintName());\n        $fields = $blueprint->fields();\n        $edit_mode = isset($grav['admin']) ? $grav['config']->get('plugins.admin.edit_mode') : null;\n\n        // override if you only want 'normal' mode\n        if (empty($fields) && ($edit_mode === 'auto' || $edit_mode === 'normal')) {\n            $blueprint = $pages->blueprints('default');\n        }\n\n        // override if you only want 'expert' mode\n        if (!empty($fields) && $edit_mode === 'expert') {\n            $blueprint = $pages->blueprints('');\n        }\n\n        return $blueprint;\n    }\n\n    /**\n     * Returns the blueprint from the page.\n     *\n     * @param string $name Not used.\n     * @return Blueprint Returns a Blueprint.\n     */\n    public function getBlueprint(string $name = '')\n    {\n        return $this->blueprints();\n    }\n\n    /**\n     * Get the blueprint name for this page.  Use the blueprint form field if set\n     *\n     * @return string\n     */\n    public function blueprintName()\n    {\n        if (!isset($_POST['blueprint'])) {\n            return $this->template();\n        }\n\n        $post_value = $_POST['blueprint'];\n        $sanitized_value = htmlspecialchars(strip_tags($post_value), ENT_QUOTES, 'UTF-8');\n\n        return $sanitized_value ?: $this->template();\n    }\n\n    /**\n     * Validate page header.\n     *\n     * @return void\n     * @throws Exception\n     */\n    public function validate()\n    {\n        $blueprints = $this->blueprints();\n        $blueprints->validate($this->toArray());\n    }\n\n    /**\n     * Filter page header from illegal contents.\n     *\n     * @return void\n     */\n    public function filter()\n    {\n        $blueprints = $this->blueprints();\n        $values = $blueprints->filter($this->toArray());\n        if ($values && isset($values['header'])) {\n            $this->header($values['header']);\n        }\n    }\n\n    /**\n     * Get unknown header variables.\n     *\n     * @return array\n     */\n    public function extra()\n    {\n        $blueprints = $this->blueprints();\n\n        return $blueprints->extra($this->toArray()['header'], 'header.');\n    }\n\n    /**\n     * Convert page to an array.\n     *\n     * @return array\n     */\n    public function toArray()\n    {\n        return [\n            'header' => (array)$this->header(),\n            'content' => (string)$this->value('content')\n        ];\n    }\n\n    /**\n     * Convert page to YAML encoded string.\n     *\n     * @return string\n     */\n    public function toYaml()\n    {\n        return Yaml::dump($this->toArray(), 20);\n    }\n\n    /**\n     * Convert page to JSON encoded string.\n     *\n     * @return string\n     */\n    public function toJson()\n    {\n        return json_encode($this->toArray());\n    }\n\n    /**\n     * @return string\n     */\n    public function getCacheKey(): string\n    {\n        return $this->id();\n    }\n\n    /**\n     * Gets and sets the associated media as found in the page folder.\n     *\n     * @param  Media|null $var Representation of associated media.\n     * @return Media      Representation of associated media.\n     */\n    public function media($var = null)\n    {\n        if ($var) {\n            $this->setMedia($var);\n        }\n\n        /** @var Media $media */\n        $media = $this->getMedia();\n\n        return $media;\n    }\n\n    /**\n     * Get filesystem path to the associated media.\n     *\n     * @return string|null\n     */\n    public function getMediaFolder()\n    {\n        return $this->path();\n    }\n\n    /**\n     * Get display order for the associated media.\n     *\n     * @return array Empty array means default ordering.\n     */\n    public function getMediaOrder()\n    {\n        $header = $this->header();\n\n        return isset($header->media_order) ? array_map('trim', explode(',', (string)$header->media_order)) : [];\n    }\n\n    /**\n     * Gets and sets the name field.  If no name field is set, it will return 'default.md'.\n     *\n     * @param  string|null $var The name of this page.\n     * @return string      The name of this page.\n     */\n    public function name($var = null)\n    {\n        if ($var !== null) {\n            $this->name = $var;\n        }\n\n        return $this->name ?: 'default.md';\n    }\n\n    /**\n     * Returns child page type.\n     *\n     * @return string\n     */\n    public function childType()\n    {\n        return isset($this->header->child_type) ? (string)$this->header->child_type : '';\n    }\n\n    /**\n     * Gets and sets the template field. This is used to find the correct Twig template file to render.\n     * If no field is set, it will return the name without the .md extension\n     *\n     * @param  string|null $var the template name\n     * @return string      the template name\n     */\n    public function template($var = null)\n    {\n        if ($var !== null) {\n            $this->template = $var;\n        }\n        if (empty($this->template)) {\n            $this->template = ($this->isModule() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name());\n        }\n\n        return $this->template;\n    }\n\n    /**\n     * Allows a page to override the output render format, usually the extension provided in the URL.\n     * (e.g. `html`, `json`, `xml`, etc).\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function templateFormat($var = null)\n    {\n        if (null !== $var) {\n            $this->template_format = is_string($var) ? $var : null;\n        }\n\n        if (!isset($this->template_format)) {\n            $this->template_format = ltrim($this->header->append_url_extension ?? Utils::getPageFormat(), '.');\n        }\n\n        return $this->template_format;\n    }\n\n    /**\n     * Gets and sets the extension field.\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function extension($var = null)\n    {\n        if ($var !== null) {\n            $this->extension = $var;\n        }\n        if (empty($this->extension)) {\n            $this->extension = '.' . Utils::pathinfo($this->name(), PATHINFO_EXTENSION);\n        }\n\n        return $this->extension;\n    }\n\n    /**\n     * Returns the page extension, got from the page `url_extension` config and falls back to the\n     * system config `system.pages.append_url_extension`.\n     *\n     * @return string      The extension of this page. For example `.html`\n     */\n    public function urlExtension()\n    {\n        if ($this->home()) {\n            return '';\n        }\n\n        // if not set in the page get the value from system config\n        if (null === $this->url_extension) {\n            $this->url_extension = Grav::instance()['config']->get('system.pages.append_url_extension', '');\n        }\n\n        return $this->url_extension;\n    }\n\n    /**\n     * Gets and sets the expires field. If not set will return the default\n     *\n     * @param  int|null $var The new expires value.\n     * @return int      The expires value\n     */\n    public function expires($var = null)\n    {\n        if ($var !== null) {\n            $this->expires = $var;\n        }\n\n        return $this->expires ?? Grav::instance()['config']->get('system.pages.expires');\n    }\n\n    /**\n     * Gets and sets the cache-control property.  If not set it will return the default value (null)\n     * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options\n     *\n     * @param string|null $var\n     * @return string|null\n     */\n    public function cacheControl($var = null)\n    {\n        if ($var !== null) {\n            $this->cache_control = $var;\n        }\n\n        return $this->cache_control ?? Grav::instance()['config']->get('system.pages.cache_control');\n    }\n\n    /**\n     * Gets and sets the title for this Page.  If no title is set, it will use the slug() to get a name\n     *\n     * @param  string|null $var the title of the Page\n     * @return string      the title of the Page\n     */\n    public function title($var = null)\n    {\n        if ($var !== null) {\n            $this->title = $var;\n        }\n        if (empty($this->title)) {\n            $this->title = ucfirst($this->slug());\n        }\n\n        return $this->title;\n    }\n\n    /**\n     * Gets and sets the menu name for this Page.  This is the text that can be used specifically for navigation.\n     * If no menu field is set, it will use the title()\n     *\n     * @param  string|null $var the menu field for the page\n     * @return string      the menu field for the page\n     */\n    public function menu($var = null)\n    {\n        if ($var !== null) {\n            $this->menu = $var;\n        }\n        if (empty($this->menu)) {\n            $this->menu = $this->title();\n        }\n\n        return $this->menu;\n    }\n\n    /**\n     * Gets and Sets whether or not this Page is visible for navigation\n     *\n     * @param  bool|null $var true if the page is visible\n     * @return bool      true if the page is visible\n     */\n    public function visible($var = null)\n    {\n        if ($var !== null) {\n            $this->visible = (bool)$var;\n        }\n\n        if ($this->visible === null) {\n            // Set item visibility in menu if folder is different from slug\n            // eg folder = 01.Home and slug = Home\n            if (preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder)) {\n                $this->visible = true;\n            } else {\n                $this->visible = false;\n            }\n        }\n\n        return $this->visible;\n    }\n\n    /**\n     * Gets and Sets whether or not this Page is considered published\n     *\n     * @param  bool|null $var true if the page is published\n     * @return bool      true if the page is published\n     */\n    public function published($var = null)\n    {\n        if ($var !== null) {\n            $this->published = (bool)$var;\n        }\n\n        // If not published, should not be visible in menus either\n        if ($this->published === false) {\n            $this->visible = false;\n        }\n\n        return $this->published;\n    }\n\n    /**\n     * Gets and Sets the Page publish date\n     *\n     * @param  string|null $var string representation of a date\n     * @return int         unix timestamp representation of the date\n     */\n    public function publishDate($var = null)\n    {\n        if ($var !== null) {\n            $this->publish_date = Utils::date2timestamp($var, $this->dateformat);\n        }\n\n        return $this->publish_date;\n    }\n\n    /**\n     * Gets and Sets the Page unpublish date\n     *\n     * @param  string|null $var string representation of a date\n     * @return int|null         unix timestamp representation of the date\n     */\n    public function unpublishDate($var = null)\n    {\n        if ($var !== null) {\n            $this->unpublish_date = Utils::date2timestamp($var, $this->dateformat);\n        }\n\n        return $this->unpublish_date;\n    }\n\n    /**\n     * Gets and Sets whether or not this Page is routable, ie you can reach it\n     * via a URL.\n     * The page must be *routable* and *published*\n     *\n     * @param  bool|null $var true if the page is routable\n     * @return bool      true if the page is routable\n     */\n    public function routable($var = null)\n    {\n        if ($var !== null) {\n            $this->routable = (bool)$var;\n        }\n\n        return $this->routable && $this->published();\n    }\n\n    /**\n     * @param bool|null $var\n     * @return bool\n     */\n    public function ssl($var = null)\n    {\n        if ($var !== null) {\n            $this->ssl = (bool)$var;\n        }\n\n        return $this->ssl;\n    }\n\n    /**\n     * Gets and Sets the process setup for this Page. This is multi-dimensional array that consists of\n     * a simple array of arrays with the form array(\"markdown\"=>true) for example\n     *\n     * @param  array|null $var an Array of name value pairs where the name is the process and value is true or false\n     * @return array      an Array of name value pairs where the name is the process and value is true or false\n     */\n    public function process($var = null)\n    {\n        if ($var !== null) {\n            $this->process = (array)$var;\n        }\n\n        return $this->process;\n    }\n\n    /**\n     * Returns the state of the debugger override setting for this page\n     *\n     * @return bool\n     */\n    public function debugger()\n    {\n        return !(isset($this->debugger) && $this->debugger === false);\n    }\n\n    /**\n     * Function to merge page metadata tags and build an array of Metadata objects\n     * that can then be rendered in the page.\n     *\n     * @param  array|null $var an Array of metadata values to set\n     * @return array      an Array of metadata values for the page\n     */\n    public function metadata($var = null)\n    {\n        if ($var !== null) {\n            $this->metadata = (array)$var;\n        }\n\n        // if not metadata yet, process it.\n        if (null === $this->metadata) {\n            $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible', 'content-security-policy'];\n\n            $this->metadata = [];\n\n            // Set the Generator tag\n            $metadata = [\n                'generator' => 'GravCMS'\n            ];\n\n            $config = Grav::instance()['config'];\n\n            $escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true);\n\n            // Get initial metadata for the page\n            $metadata = array_merge($metadata, $config->get('site.metadata', []));\n\n            if (isset($this->header->metadata) && is_array($this->header->metadata)) {\n                // Merge any site.metadata settings in with page metadata\n                $metadata = array_merge($metadata, $this->header->metadata);\n            }\n\n            // Build an array of meta objects..\n            foreach ((array)$metadata as $key => $value) {\n                // Lowercase the key\n                $key = strtolower($key);\n                // If this is a property type metadata: \"og\", \"twitter\", \"facebook\" etc\n                // Backward compatibility for nested arrays in metas\n                if (is_array($value)) {\n                    foreach ($value as $property => $prop_value) {\n                        $prop_key = $key . ':' . $property;\n                        $this->metadata[$prop_key] = [\n                            'name' => $prop_key,\n                            'property' => $prop_key,\n                            'content' => $escape ? htmlspecialchars($prop_value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $prop_value\n                        ];\n                    }\n                } else {\n                    // If it this is a standard meta data type\n                    if ($value) {\n                        if (in_array($key, $header_tag_http_equivs, true)) {\n                            $this->metadata[$key] = [\n                                'http_equiv' => $key,\n                                'content' => $escape ? htmlspecialchars($value, ENT_COMPAT, 'UTF-8') : $value\n                            ];\n                        } elseif ($key === 'charset') {\n                            $this->metadata[$key] = ['charset' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value];\n                        } else {\n                            // if it's a social metadata with separator, render as property\n                            $separator = strpos($key, ':');\n                            $hasSeparator = $separator && $separator < strlen($key) - 1;\n                            $entry = [\n                                'content' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value\n                            ];\n\n                            if ($hasSeparator && !Utils::startsWith($key, ['twitter', 'flattr','fediverse'])) {\n                                $entry['property'] = $key;\n                            } else {\n                                $entry['name'] = $key;\n                            }\n\n                            $this->metadata[$key] = $entry;\n                        }\n                    }\n                }\n            }\n        }\n\n        return $this->metadata;\n    }\n\n    /**\n     * Reset the metadata and pull from header again\n     */\n    public function resetMetadata()\n    {\n        $this->metadata = null;\n    }\n\n    /**\n     * Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses\n     * the parent folder from the path\n     *\n     * @param  string|null $var the slug, e.g. 'my-blog'\n     * @return string      the slug\n     */\n    public function slug($var = null)\n    {\n        if ($var !== null && $var !== '') {\n            $this->slug = $var;\n        }\n\n        if (empty($this->slug)) {\n            $this->slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', (string) $this->folder)) ?: null;\n        }\n\n        return $this->slug;\n    }\n\n    /**\n     * Get/set order number of this page.\n     *\n     * @param int|null $var\n     * @return string|bool\n     */\n    public function order($var = null)\n    {\n        if ($var !== null) {\n            $order = $var ? sprintf('%02d.', (int)$var) : '';\n            $this->folder($order . preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder));\n\n            return $order;\n        }\n\n        preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder, $order);\n\n        return $order[0] ?? false;\n    }\n\n    /**\n     * Gets the URL for a page - alias of url().\n     *\n     * @param bool $include_host\n     * @return string the permalink\n     */\n    public function link($include_host = false)\n    {\n        return $this->url($include_host);\n    }\n\n    /**\n     * Gets the URL with host information, aka Permalink.\n     * @return string The permalink.\n     */\n    public function permalink()\n    {\n        return $this->url(true, false, true, true);\n    }\n\n    /**\n     * Returns the canonical URL for a page\n     *\n     * @param bool $include_lang\n     * @return string\n     */\n    public function canonical($include_lang = true)\n    {\n        return $this->url(true, true, $include_lang);\n    }\n\n    /**\n     * Gets the url for the Page.\n     *\n     * @param bool $include_host Defaults false, but true would include http://yourhost.com\n     * @param bool $canonical    True to return the canonical URL\n     * @param bool $include_base Include base url on multisite as well as language code\n     * @param bool $raw_route\n     * @return string The url.\n     */\n    public function url($include_host = false, $canonical = false, $include_base = true, $raw_route = false)\n    {\n        // Override any URL when external_url is set\n        if (isset($this->external_url)) {\n            return $this->external_url;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        // get base route (multi-site base and language)\n        $route = $include_base ? $pages->baseRoute() : '';\n\n        // add full route if configured to do so\n        if (!$include_host && $config->get('system.absolute_urls', false)) {\n            $include_host = true;\n        }\n\n        if ($canonical) {\n            $route .= $this->routeCanonical();\n        } elseif ($raw_route) {\n            $route .= $this->rawRoute();\n        } else {\n            $route .= $this->route();\n        }\n\n        /** @var Uri $uri */\n        $uri = $grav['uri'];\n        $url = $uri->rootUrl($include_host) . '/' . trim($route, '/') . $this->urlExtension();\n\n        return Uri::filterPath($url);\n    }\n\n    /**\n     * Gets the route for the page based on the route headers if available, else from\n     * the parents route and the current Page's slug.\n     *\n     * @param  string|null $var Set new default route.\n     * @return string|null  The route for the Page.\n     */\n    public function route($var = null)\n    {\n        if ($var !== null) {\n            $this->route = $var;\n        }\n\n        if (empty($this->route)) {\n            $baseRoute = null;\n\n            // calculate route based on parent slugs\n            $parent = $this->parent();\n            if (isset($parent)) {\n                if ($this->hide_home_route && $parent->route() === $this->home_route) {\n                    $baseRoute = '';\n                } else {\n                    $baseRoute = (string)$parent->route();\n                }\n            }\n\n            $this->route = isset($baseRoute) ? $baseRoute . '/' . $this->slug() : null;\n\n            if (!empty($this->routes) && isset($this->routes['default'])) {\n                $this->routes['aliases'][] = $this->route;\n                $this->route = $this->routes['default'];\n\n                return $this->route;\n            }\n        }\n\n        return $this->route;\n    }\n\n    /**\n     * Helper method to clear the route out so it regenerates next time you use it\n     */\n    public function unsetRouteSlug()\n    {\n        unset($this->route, $this->slug);\n    }\n\n    /**\n     * Gets and Sets the page raw route\n     *\n     * @param string|null $var\n     * @return null|string\n     */\n    public function rawRoute($var = null)\n    {\n        if ($var !== null) {\n            $this->raw_route = $var;\n        }\n\n        if (empty($this->raw_route)) {\n            $parent = $this->parent();\n            $baseRoute = $parent ? (string)$parent->rawRoute() : null;\n\n            $slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder));\n\n            $this->raw_route = isset($baseRoute) ? $baseRoute . '/' . $slug : null;\n        }\n\n        return $this->raw_route;\n    }\n\n    /**\n     * Gets the route aliases for the page based on page headers.\n     *\n     * @param  array|null $var list of route aliases\n     * @return array  The route aliases for the Page.\n     */\n    public function routeAliases($var = null)\n    {\n        if ($var !== null) {\n            $this->routes['aliases'] = (array)$var;\n        }\n\n        if (!empty($this->routes) && isset($this->routes['aliases'])) {\n            return $this->routes['aliases'];\n        }\n\n        return [];\n    }\n\n    /**\n     * Gets the canonical route for this page if its set. If provided it will use\n     * that value, else if it's `true` it will use the default route.\n     *\n     * @param string|null $var\n     * @return bool|string\n     */\n    public function routeCanonical($var = null)\n    {\n        if ($var !== null) {\n            $this->routes['canonical'] = $var;\n        }\n\n        if (!empty($this->routes) && isset($this->routes['canonical'])) {\n            return $this->routes['canonical'];\n        }\n\n        return $this->route();\n    }\n\n    /**\n     * Gets and sets the identifier for this Page object.\n     *\n     * @param  string|null $var the identifier\n     * @return string      the identifier\n     */\n    public function id($var = null)\n    {\n        if (null === $this->id) {\n            // We need to set unique id to avoid potential cache conflicts between pages.\n            $var = time() . md5($this->filePath());\n        }\n        if ($var !== null) {\n            // store unique per language\n            $active_lang = Grav::instance()['language']->getLanguage() ?: '';\n            $id = $active_lang . $var;\n            $this->id = $id;\n        }\n\n        return $this->id;\n    }\n\n    /**\n     * Gets and sets the modified timestamp.\n     *\n     * @param  int|null $var modified unix timestamp\n     * @return int      modified unix timestamp\n     */\n    public function modified($var = null)\n    {\n        if ($var !== null) {\n            $this->modified = $var;\n        }\n\n        return $this->modified;\n    }\n\n    /**\n     * Gets the redirect set in the header.\n     *\n     * @param  string|null $var redirect url\n     * @return string|null\n     */\n    public function redirect($var = null)\n    {\n        if ($var !== null) {\n            $this->redirect = $var;\n        }\n\n        return $this->redirect ?: null;\n    }\n\n    /**\n     * Gets and sets the option to show the etag header for the page.\n     *\n     * @param  bool|null $var show etag header\n     * @return bool      show etag header\n     */\n    public function eTag($var = null): bool\n    {\n        if ($var !== null) {\n            $this->etag = $var;\n        }\n        if (!isset($this->etag)) {\n            $this->etag = (bool)Grav::instance()['config']->get('system.pages.etag');\n        }\n\n        return $this->etag ?? false;\n    }\n\n    /**\n     * Gets and sets the option to show the last_modified header for the page.\n     *\n     * @param  bool|null $var show last_modified header\n     * @return bool      show last_modified header\n     */\n    public function lastModified($var = null)\n    {\n        if ($var !== null) {\n            $this->last_modified = $var;\n        }\n        if (!isset($this->last_modified)) {\n            $this->last_modified = (bool)Grav::instance()['config']->get('system.pages.last_modified');\n        }\n\n        return $this->last_modified;\n    }\n\n    /**\n     * Gets and sets the path to the .md file for this Page object.\n     *\n     * @param  string|null $var the file path\n     * @return string|null      the file path\n     */\n    public function filePath($var = null)\n    {\n        if ($var !== null) {\n            // Filename of the page.\n            $this->name = Utils::basename($var);\n            // Folder of the page.\n            $this->folder = Utils::basename(dirname($var));\n            // Path to the page.\n            $this->path = dirname($var, 2);\n        }\n\n        return rtrim($this->path . '/' . $this->folder . '/' . ($this->name() ?: ''), '/');\n    }\n\n    /**\n     * Gets the relative path to the .md file\n     *\n     * @return string The relative file path\n     */\n    public function filePathClean()\n    {\n        return str_replace(GRAV_ROOT . DS, '', $this->filePath());\n    }\n\n    /**\n     * Returns the clean path to the page file\n     *\n     * @return string\n     */\n    public function relativePagePath()\n    {\n        return str_replace('/' . $this->name(), '', $this->filePathClean());\n    }\n\n    /**\n     * Gets and sets the path to the folder where the .md for this Page object resides.\n     * This is equivalent to the filePath but without the filename.\n     *\n     * @param  string|null $var the path\n     * @return string|null      the path\n     */\n    public function path($var = null)\n    {\n        if ($var !== null) {\n            // Folder of the page.\n            $this->folder = Utils::basename($var);\n            // Path to the page.\n            $this->path = dirname($var);\n        }\n\n        return $this->path ? $this->path . '/' . $this->folder : null;\n    }\n\n    /**\n     * Get/set the folder.\n     *\n     * @param string|null $var Optional path\n     * @return string|null\n     */\n    public function folder($var = null)\n    {\n        if ($var !== null) {\n            $this->folder = $var;\n        }\n\n        return $this->folder;\n    }\n\n    /**\n     * Gets and sets the date for this Page object. This is typically passed in via the page headers\n     *\n     * @param  string|null $var string representation of a date\n     * @return int         unix timestamp representation of the date\n     */\n    public function date($var = null)\n    {\n        if ($var !== null) {\n            $this->date = Utils::date2timestamp($var, $this->dateformat);\n        }\n\n        if (!$this->date) {\n            $this->date = $this->modified;\n        }\n\n        return $this->date;\n    }\n\n    /**\n     * Gets and sets the date format for this Page object. This is typically passed in via the page headers\n     * using typical PHP date string structure - http://php.net/manual/en/function.date.php\n     *\n     * @param  string|null $var string representation of a date format\n     * @return string      string representation of a date format\n     */\n    public function dateformat($var = null)\n    {\n        if ($var !== null) {\n            $this->dateformat = $var;\n        }\n\n        return $this->dateformat;\n    }\n\n    /**\n     * Gets and sets the order by which any sub-pages should be sorted.\n     *\n     * @param  string|null $var the order, either \"asc\" or \"desc\"\n     * @return string      the order, either \"asc\" or \"desc\"\n     * @deprecated 1.6\n     */\n    public function orderDir($var = null)\n    {\n        //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED);\n\n        if ($var !== null) {\n            $this->order_dir = $var;\n        }\n\n        if (empty($this->order_dir)) {\n            $this->order_dir = 'asc';\n        }\n\n        return $this->order_dir;\n    }\n\n    /**\n     * Gets and sets the order by which the sub-pages should be sorted.\n     *\n     * default - is the order based on the file system, ie 01.Home before 02.Advark\n     * title - is the order based on the title set in the pages\n     * date - is the order based on the date set in the pages\n     * folder - is the order based on the name of the folder with any numerics omitted\n     *\n     * @param  string|null $var supported options include \"default\", \"title\", \"date\", and \"folder\"\n     * @return string      supported options include \"default\", \"title\", \"date\", and \"folder\"\n     * @deprecated 1.6\n     */\n    public function orderBy($var = null)\n    {\n        //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED);\n\n        if ($var !== null) {\n            $this->order_by = $var;\n        }\n\n        return $this->order_by;\n    }\n\n    /**\n     * Gets the manual order set in the header.\n     *\n     * @param  string|null $var supported options include \"default\", \"title\", \"date\", and \"folder\"\n     * @return array\n     * @deprecated 1.6\n     */\n    public function orderManual($var = null)\n    {\n        //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED);\n\n        if ($var !== null) {\n            $this->order_manual = $var;\n        }\n\n        return (array)$this->order_manual;\n    }\n\n    /**\n     * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the\n     * sub_pages header property is set for this page object.\n     *\n     * @param  int|null $var the maximum number of sub-pages\n     * @return int      the maximum number of sub-pages\n     * @deprecated 1.6\n     */\n    public function maxCount($var = null)\n    {\n        //user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED);\n\n        if ($var !== null) {\n            $this->max_count = (int)$var;\n        }\n        if (empty($this->max_count)) {\n            /** @var Config $config */\n            $config = Grav::instance()['config'];\n            $this->max_count = (int)$config->get('system.pages.list.count');\n        }\n\n        return $this->max_count;\n    }\n\n    /**\n     * Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with.\n     *\n     * @param  array|null $var an array of taxonomies\n     * @return array      an array of taxonomies\n     */\n    public function taxonomy($var = null)\n    {\n        if ($var !== null) {\n            // make sure first level are arrays\n            array_walk($var, static function (&$value) {\n                $value = (array) $value;\n            });\n            // make sure all values are strings\n            array_walk_recursive($var, static function (&$value) {\n                $value = (string) $value;\n            });\n            $this->taxonomy = $var;\n        }\n\n        return $this->taxonomy;\n    }\n\n    /**\n     * Gets and sets the modular var that helps identify this page is a modular child\n     *\n     * @param  bool|null $var true if modular_twig\n     * @return bool      true if modular_twig\n     * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead.\n     */\n    public function modular($var = null)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->isModule() or ->modularTwig() method instead', E_USER_DEPRECATED);\n\n        return $this->modularTwig($var);\n    }\n\n    /**\n     * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need\n     * twig processing handled differently from a regular page.\n     *\n     * @param  bool|null $var true if modular_twig\n     * @return bool      true if modular_twig\n     */\n    public function modularTwig($var = null)\n    {\n        if ($var !== null) {\n            $this->modular_twig = (bool)$var;\n            if ($var) {\n                $this->visible(false);\n                // some routable logic\n                if (empty($this->header->routable)) {\n                    $this->routable = false;\n                }\n            }\n        }\n\n        return $this->modular_twig ?? false;\n    }\n\n    /**\n     * Gets the configured state of the processing method.\n     *\n     * @param  string $process the process, eg \"twig\" or \"markdown\"\n     * @return bool            whether or not the processing method is enabled for this Page\n     */\n    public function shouldProcess($process)\n    {\n        return (bool)($this->process[$process] ?? false);\n    }\n\n    /**\n     * Gets and Sets the parent object for this page\n     *\n     * @param  PageInterface|null $var the parent page object\n     * @return PageInterface|null the parent page object if it exists.\n     */\n    public function parent(PageInterface $var = null)\n    {\n        if ($var) {\n            $this->parent = $var->path();\n\n            return $var;\n        }\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->get($this->parent);\n    }\n\n    /**\n     * Gets the top parent object for this page. Can return page itself.\n     *\n     * @return PageInterface The top parent page object.\n     */\n    public function topParent()\n    {\n        $topParent = $this;\n\n        while (true) {\n            $theParent = $topParent->parent();\n            if ($theParent !== null && $theParent->parent() !== null) {\n                $topParent = $theParent;\n            } else {\n                break;\n            }\n        }\n\n        return $topParent;\n    }\n\n    /**\n     * Returns children of this page.\n     *\n     * @return PageCollectionInterface|Collection\n     */\n    public function children()\n    {\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->children($this->path());\n    }\n\n\n    /**\n     * Check to see if this item is the first in an array of sub-pages.\n     *\n     * @return bool True if item is first.\n     */\n    public function isFirst()\n    {\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if ($collection instanceof Collection) {\n            return $collection->isFirst($this->path());\n        }\n\n        return true;\n    }\n\n    /**\n     * Check to see if this item is the last in an array of sub-pages.\n     *\n     * @return bool True if item is last\n     */\n    public function isLast()\n    {\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if ($collection instanceof Collection) {\n            return $collection->isLast($this->path());\n        }\n\n        return true;\n    }\n\n    /**\n     * Gets the previous sibling based on current position.\n     *\n     * @return PageInterface the previous Page item\n     */\n    public function prevSibling()\n    {\n        return $this->adjacentSibling(-1);\n    }\n\n    /**\n     * Gets the next sibling based on current position.\n     *\n     * @return PageInterface the next Page item\n     */\n    public function nextSibling()\n    {\n        return $this->adjacentSibling(1);\n    }\n\n    /**\n     * Returns the adjacent sibling based on a direction.\n     *\n     * @param  int $direction either -1 or +1\n     * @return PageInterface|false             the sibling page\n     */\n    public function adjacentSibling($direction = 1)\n    {\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if ($collection instanceof Collection) {\n            return $collection->adjacentSibling($this->path(), $direction);\n        }\n\n        return false;\n    }\n\n    /**\n     * Returns the item in the current position.\n     *\n     * @return int|null   The index of the current page.\n     */\n    public function currentPosition()\n    {\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if ($collection instanceof Collection) {\n            return $collection->currentPosition($this->path());\n        }\n\n        return 1;\n    }\n\n    /**\n     * Returns whether or not this page is the currently active page requested via the URL.\n     *\n     * @return bool True if it is active\n     */\n    public function active()\n    {\n        $uri_path = rtrim(urldecode(Grav::instance()['uri']->path()), '/') ?: '/';\n        $routes = Grav::instance()['pages']->routes();\n\n        return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path();\n    }\n\n    /**\n     * Returns whether or not this URI's URL contains the URL of the active page.\n     * Or in other words, is this page's URL in the current URL\n     *\n     * @return bool True if active child exists\n     */\n    public function activeChild()\n    {\n        $grav = Grav::instance();\n        /** @var Uri $uri */\n        $uri = $grav['uri'];\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n        $uri_path = rtrim(urldecode($uri->path()), '/');\n        $routes = $pages->routes();\n\n        if (isset($routes[$uri_path])) {\n            $page = $pages->find($uri->route());\n            /** @var PageInterface|null $child_page */\n            $child_page = $page ? $page->parent() : null;\n            while ($child_page && !$child_page->root()) {\n                if ($this->path() === $child_page->path()) {\n                    return true;\n                }\n                $child_page = $child_page->parent();\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Returns whether or not this page is the currently configured home page.\n     *\n     * @return bool True if it is the homepage\n     */\n    public function home()\n    {\n        $home = Grav::instance()['config']->get('system.home.alias');\n\n        return $this->route() === $home || $this->rawRoute() === $home;\n    }\n\n    /**\n     * Returns whether or not this page is the root node of the pages tree.\n     *\n     * @return bool True if it is the root\n     */\n    public function root()\n    {\n        return !$this->parent && !$this->name && !$this->visible;\n    }\n\n    /**\n     * Helper method to return an ancestor page.\n     *\n     * @param bool|null $lookup Name of the parent folder\n     * @return PageInterface page you were looking for if it exists\n     */\n    public function ancestor($lookup = null)\n    {\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->ancestor($this->route, $lookup);\n    }\n\n    /**\n     * Helper method to return an ancestor page to inherit from. The current\n     * page object is returned.\n     *\n     * @param string $field Name of the parent folder\n     * @return PageInterface\n     */\n    public function inherited($field)\n    {\n        [$inherited, $currentParams] = $this->getInheritedParams($field);\n\n        $this->modifyHeader($field, $currentParams);\n\n        return $inherited;\n    }\n\n    /**\n     * Helper method to return an ancestor field only to inherit from. The\n     * first occurrence of an ancestor field will be returned if at all.\n     *\n     * @param string $field Name of the parent folder\n     *\n     * @return array\n     */\n    public function inheritedField($field)\n    {\n        [$inherited, $currentParams] = $this->getInheritedParams($field);\n\n        return $currentParams;\n    }\n\n    /**\n     * Method that contains shared logic for inherited() and inheritedField()\n     *\n     * @param string $field Name of the parent folder\n     * @return array\n     */\n    protected function getInheritedParams($field)\n    {\n        $pages = Grav::instance()['pages'];\n\n        /** @var Pages $pages */\n        $inherited = $pages->inherited($this->route, $field);\n        $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : [];\n        $currentParams = (array)$this->value('header.' . $field);\n        if ($inheritedParams && is_array($inheritedParams)) {\n            $currentParams = array_replace_recursive($inheritedParams, $currentParams);\n        }\n\n        return [$inherited, $currentParams];\n    }\n\n    /**\n     * Helper method to return a page.\n     *\n     * @param string $url the url of the page\n     * @param bool $all\n     *\n     * @return PageInterface page you were looking for if it exists\n     */\n    public function find($url, $all = false)\n    {\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->find($url, $all);\n    }\n\n    /**\n     * Get a collection of pages in the current context.\n     *\n     * @param string|array $params\n     * @param bool $pagination\n     *\n     * @return PageCollectionInterface|Collection\n     * @throws InvalidArgumentException\n     */\n    public function collection($params = 'content', $pagination = true)\n    {\n        if (is_string($params)) {\n            // Look into a page header field.\n            $params = (array)$this->value('header.' . $params);\n        } elseif (!is_array($params)) {\n            throw new InvalidArgumentException('Argument should be either header variable name or array of parameters');\n        }\n\n        $params['filter'] = ($params['filter'] ?? []) + ['translated' => true];\n        $context = [\n            'pagination' => $pagination,\n            'self' => $this\n        ];\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->getCollection($params, $context);\n    }\n\n    /**\n     * @param string|array $value\n     * @param bool $only_published\n     * @return PageCollectionInterface|Collection\n     */\n    public function evaluate($value, $only_published = true)\n    {\n        $params = [\n            'items' => $value,\n            'published' => $only_published\n        ];\n        $context = [\n            'event' => false,\n            'pagination' => false,\n            'url_taxonomy_filters' => false,\n            'self' => $this\n        ];\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->getCollection($params, $context);\n    }\n\n    /**\n     * Returns whether or not this Page object has a .md file associated with it or if its just a directory.\n     *\n     * @return bool True if its a page with a .md file associated\n     */\n    public function isPage()\n    {\n        if ($this->name) {\n            return true;\n        }\n\n        return false;\n    }\n\n    /**\n     * Returns whether or not this Page object is a directory or a page.\n     *\n     * @return bool True if its a directory\n     */\n    public function isDir()\n    {\n        return !$this->isPage();\n    }\n\n    /**\n     * @return bool\n     */\n    public function isModule(): bool\n    {\n        return $this->modularTwig();\n    }\n\n    /**\n     * Returns whether the page exists in the filesystem.\n     *\n     * @return bool\n     */\n    public function exists()\n    {\n        $file = $this->file();\n\n        return $file && $file->exists();\n    }\n\n    /**\n     * Returns whether or not the current folder exists\n     *\n     * @return bool\n     */\n    public function folderExists()\n    {\n        return file_exists($this->path());\n    }\n\n    /**\n     * Cleans the path.\n     *\n     * @param  string $path the path\n     * @return string       the path\n     */\n    protected function cleanPath($path)\n    {\n        $lastchunk = strrchr($path, DS);\n        if (strpos($lastchunk, ':') !== false) {\n            $path = str_replace($lastchunk, '', $path);\n        }\n\n        return $path;\n    }\n\n    /**\n     * Reorders all siblings according to a defined order\n     *\n     * @param array|null $new_order\n     */\n    protected function doReorder($new_order)\n    {\n        if (!$this->_original) {\n            return;\n        }\n\n        $pages = Grav::instance()['pages'];\n        $pages->init();\n\n        $this->_original->path($this->path());\n\n        $parent = $this->parent();\n        $siblings = $parent ? $parent->children() : null;\n\n        if ($siblings) {\n            $siblings->order('slug', 'asc', $new_order);\n\n            $counter = 0;\n\n            // Reorder all moved pages.\n            foreach ($siblings as $slug => $page) {\n                $order = (int)trim($page->order(), '.');\n                $counter++;\n\n                if ($order) {\n                    if ($page->path() === $this->path() && $this->folderExists()) {\n                        // Handle current page; we do want to change ordering number, but nothing else.\n                        $this->order($counter);\n                        $this->save(false);\n                    } else {\n                        // Handle all the other pages.\n                        $page = $pages->get($page->path());\n                        if ($page && $page->folderExists() && !$page->_action) {\n                            $page = $page->move($this->parent());\n                            $page->order($counter);\n                            $page->save(false);\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    /**\n     * Moves or copies the page in filesystem.\n     *\n     * @internal\n     * @return void\n     * @throws Exception\n     */\n    protected function doRelocation()\n    {\n        if (!$this->_original) {\n            return;\n        }\n\n        if (is_dir($this->_original->path())) {\n            if ($this->_action === 'move') {\n                Folder::move($this->_original->path(), $this->path());\n            } elseif ($this->_action === 'copy') {\n                Folder::copy($this->_original->path(), $this->path());\n            }\n        }\n\n        if ($this->name() !== $this->_original->name()) {\n            $path = $this->path();\n            if (is_file($path . '/' . $this->_original->name())) {\n                rename($path . '/' . $this->_original->name(), $path . '/' . $this->name());\n            }\n        }\n    }\n\n    /**\n     * @return void\n     */\n    protected function setPublishState()\n    {\n        // Handle publishing dates if no explicit published option set\n        if (Grav::instance()['config']->get('system.pages.publish_dates') && !isset($this->header->published)) {\n            // unpublish if required, if not clear cache right before page should be unpublished\n            if ($this->unpublishDate()) {\n                if ($this->unpublishDate() < time()) {\n                    $this->published(false);\n                } else {\n                    $this->published();\n                    Grav::instance()['cache']->setLifeTime($this->unpublishDate());\n                }\n            }\n            // publish if required, if not clear cache right before page is published\n            if ($this->publishDate() && $this->publishDate() > time()) {\n                $this->published(false);\n                Grav::instance()['cache']->setLifeTime($this->publishDate());\n            }\n        }\n    }\n\n    /**\n     * @param string $route\n     * @return string\n     */\n    protected function adjustRouteCase($route)\n    {\n        $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls');\n\n        return $case_insensitive ? mb_strtolower($route) : $route;\n    }\n\n    /**\n     * Gets the Page Unmodified (original) version of the page.\n     *\n     * @return PageInterface The original version of the page.\n     */\n    public function getOriginal()\n    {\n        return $this->_original;\n    }\n\n    /**\n     * Gets the action.\n     *\n     * @return string|null The Action string.\n     */\n    public function getAction()\n    {\n        return $this->_action;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Pages.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page;\n\nuse Exception;\nuse FilesystemIterator;\nuse Grav\\Common\\Cache;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\Blueprints;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Flex\\Types\\Pages\\PageCollection;\nuse Grav\\Common\\Flex\\Types\\Pages\\PageIndex;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Taxonomy;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Grav\\Events\\TypesEvent;\nuse Grav\\Framework\\Flex\\Flex;\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexTranslateInterface;\nuse Grav\\Framework\\Flex\\Pages\\FlexPageObject;\nuse Grav\\Plugin\\Admin;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse SplFileInfo;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\nuse Whoops\\Exception\\ErrorException;\nuse Collator;\nuse function array_key_exists;\nuse function array_search;\nuse function count;\nuse function dirname;\nuse function extension_loaded;\nuse function in_array;\nuse function is_array;\nuse function is_int;\nuse function is_string;\n\n/**\n * Class Pages\n * @package Grav\\Common\\Page\n */\nclass Pages\n{\n    /** @var FlexDirectory|null */\n    private $directory;\n\n    /** @var Grav */\n    protected $grav;\n    /** @var array<PageInterface> */\n    protected $instances = [];\n    /** @var array<PageInterface|string> */\n    protected $index = [];\n    /** @var array */\n    protected $children;\n    /** @var string */\n    protected $base = '';\n    /** @var string[] */\n    protected $baseRoute = [];\n    /** @var string[] */\n    protected $routes = [];\n    /** @var array */\n    protected $sort;\n    /** @var Blueprints */\n    protected $blueprints;\n    /** @var bool */\n    protected $enable_pages = true;\n    /** @var int */\n    protected $last_modified;\n    /** @var string[] */\n    protected $ignore_files;\n    /** @var string[] */\n    protected $ignore_folders;\n    /** @var bool */\n    protected $ignore_hidden;\n    /** @var string */\n    protected $check_method;\n    /** @var string */\n    protected $simple_pages_hash;\n    /** @var string */\n    protected $pages_cache_id;\n    /** @var bool */\n    protected $initialized = false;\n    /** @var string */\n    protected $active_lang;\n    /** @var bool */\n    protected $fire_events = false;\n    /** @var Types|null */\n    protected static $types;\n    /** @var string|null */\n    protected static $home_route;\n\n\n    /**\n     * Constructor\n     *\n     * @param Grav $grav\n     */\n    public function __construct(Grav $grav)\n    {\n        $this->grav = $grav;\n    }\n\n    /**\n     * @return FlexDirectory|null\n     */\n    public function getDirectory(): ?FlexDirectory\n    {\n        return $this->directory;\n    }\n\n    /**\n     * Method used in admin to disable frontend pages from being initialized.\n     */\n    public function disablePages(): void\n    {\n        $this->enable_pages = false;\n    }\n\n    /**\n     * Method used in admin to later load frontend pages.\n     */\n    public function enablePages(): void\n    {\n        if (!$this->enable_pages) {\n            $this->enable_pages = true;\n\n            $this->init();\n        }\n    }\n\n    /**\n     * Get or set base path for the pages.\n     *\n     * @param  string|null $path\n     * @return string\n     */\n    public function base($path = null)\n    {\n        if ($path !== null) {\n            $path = trim($path, '/');\n            $this->base = $path ? '/' . $path : '';\n            $this->baseRoute = [];\n        }\n\n        return $this->base;\n    }\n\n    /**\n     *\n     * Get base route for Grav pages.\n     *\n     * @param  string|null $lang     Optional language code for multilingual routes.\n     * @return string\n     */\n    public function baseRoute($lang = null)\n    {\n        $key = $lang ?: $this->active_lang ?: 'default';\n\n        if (!isset($this->baseRoute[$key])) {\n            /** @var Language $language */\n            $language = $this->grav['language'];\n\n            $path_base = rtrim($this->base(), '/');\n            $path_lang = $language->enabled() ? $language->getLanguageURLPrefix($lang) : '';\n\n            $this->baseRoute[$key] = $path_base . $path_lang;\n        }\n\n        return $this->baseRoute[$key];\n    }\n\n    /**\n     *\n     * Get route for Grav site.\n     *\n     * @param  string $route    Optional route to the page.\n     * @param  string|null $lang     Optional language code for multilingual links.\n     * @return string\n     */\n    public function route($route = '/', $lang = null)\n    {\n        if (!$route || $route === '/') {\n            return $this->baseRoute($lang) ?: '/';\n        }\n\n        return $this->baseRoute($lang) . $route;\n    }\n\n    /**\n     * Get relative referrer route and language code. Returns null if the route isn't within the current base, language (if set) and route.\n     *\n     * @example `$langCode = null; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within /admin and updates $langCode\n     * @example `$langCode = 'en'; $referrer = $pages->referrerRoute($langCode, '/admin');` returns relative referrer url within the /en/admin\n     *\n     * @param string|null $langCode Variable to store the language code. If already set, check only against that language.\n     * @param string $route Optional route within the site.\n     * @return string|null\n     * @since 1.7.23\n     */\n    public function referrerRoute(?string &$langCode, string $route = '/'): ?string\n    {\n        $referrer = $_SERVER['HTTP_REFERER'] ?? null;\n\n        // Start by checking that referrer came from our site.\n        $root = $this->grav['base_url_absolute'];\n        if (!is_string($referrer) || !str_starts_with($referrer, $root)) {\n            return null;\n        }\n\n        /** @var Language $language */\n        $language = $this->grav['language'];\n\n        // Get all language codes and append no language.\n        if (null === $langCode) {\n            $languages = $language->enabled() ? $language->getLanguages() : [];\n            $languages[] = '';\n        } else {\n            $languages[] = $langCode;\n        }\n\n        $path_base = rtrim($this->base(), '/');\n        $path_route = rtrim($route, '/');\n\n        // Try to figure out the language code.\n        foreach ($languages as $code) {\n            $path_lang = $code ? \"/{$code}\" : '';\n\n            $base = $path_base . $path_lang . $path_route;\n            if ($referrer === $base || str_starts_with($referrer, \"{$base}/\")) {\n                if (null === $langCode) {\n                    $langCode = $code;\n                }\n\n                return substr($referrer, \\strlen($base));\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     *\n     * Get base URL for Grav pages.\n     *\n     * @param  string|null $lang     Optional language code for multilingual links.\n     * @param  bool|null  $absolute If true, return absolute url, if false, return relative url. Otherwise return default.\n     * @return string\n     */\n    public function baseUrl($lang = null, $absolute = null)\n    {\n        if ($absolute === null) {\n            $type = 'base_url';\n        } elseif ($absolute) {\n            $type = 'base_url_absolute';\n        } else {\n            $type = 'base_url_relative';\n        }\n\n        return $this->grav[$type] . $this->baseRoute($lang);\n    }\n\n    /**\n     *\n     * Get home URL for Grav site.\n     *\n     * @param  string|null $lang     Optional language code for multilingual links.\n     * @param  bool|null   $absolute If true, return absolute url, if false, return relative url. Otherwise return default.\n     * @return string\n     */\n    public function homeUrl($lang = null, $absolute = null)\n    {\n        return $this->baseUrl($lang, $absolute) ?: '/';\n    }\n\n    /**\n     *\n     * Get URL for Grav site.\n     *\n     * @param  string $route    Optional route to the page.\n     * @param  string|null $lang     Optional language code for multilingual links.\n     * @param  bool|null   $absolute If true, return absolute url, if false, return relative url. Otherwise return default.\n     * @return string\n     */\n    public function url($route = '/', $lang = null, $absolute = null)\n    {\n        if (!$route || $route === '/') {\n            return $this->homeUrl($lang, $absolute);\n        }\n\n        return $this->baseUrl($lang, $absolute) . Uri::filterPath($route);\n    }\n\n    /**\n     * @param string $method\n     * @return void\n     */\n    public function setCheckMethod($method): void\n    {\n        $this->check_method = strtolower($method);\n    }\n\n    /**\n     * @return void\n     */\n    public function register(): void\n    {\n        $config = $this->grav['config'];\n        $type = $config->get('system.pages.type');\n        if ($type === 'flex') {\n            $this->initFlexPages();\n        }\n    }\n\n    /**\n     * Reset pages (used in search indexing etc).\n     *\n     * @return void\n     */\n    public function reset(): void\n    {\n        $this->initialized = false;\n\n        $this->init();\n    }\n\n    /**\n     * Class initialization. Must be called before using this class.\n     */\n    public function init(): void\n    {\n        if ($this->initialized) {\n            return;\n        }\n\n        $config = $this->grav['config'];\n        $this->ignore_files = (array)$config->get('system.pages.ignore_files');\n        $this->ignore_folders = (array)$config->get('system.pages.ignore_folders');\n        $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden');\n        $this->fire_events = (bool)$config->get('system.pages.events.page');\n\n        $this->instances = [];\n        $this->index = [];\n        $this->children = [];\n        $this->routes = [];\n\n        if (!$this->check_method) {\n            $this->setCheckMethod($config->get('system.cache.check.method', 'file'));\n        }\n\n        if ($this->enable_pages === false) {\n            $page = $this->buildRootPage();\n            $this->instances[$page->path()] = $page;\n\n            return;\n        }\n\n        $this->buildPages();\n\n        $this->initialized = true;\n    }\n\n    /**\n     * Get or set last modification time.\n     *\n     * @param int|null $modified\n     * @return int|null\n     */\n    public function lastModified($modified = null)\n    {\n        if ($modified && $modified > $this->last_modified) {\n            $this->last_modified = $modified;\n        }\n\n        return $this->last_modified;\n    }\n\n    /**\n     * Returns a list of all pages.\n     *\n     * @return PageInterface[]\n     */\n    public function instances()\n    {\n        $instances = [];\n        foreach ($this->index as $path => $instance) {\n            $page = $this->get($path);\n            if ($page) {\n                $instances[$path] = $page;\n            }\n        }\n\n        return $instances;\n    }\n\n    /**\n     * Returns a list of all routes.\n     *\n     * @return array\n     */\n    public function routes()\n    {\n        return $this->routes;\n    }\n\n    /**\n     * Adds a page and assigns a route to it.\n     *\n     * @param PageInterface   $page  Page to be added.\n     * @param string|null $route Optional route (uses route from the object if not set).\n     */\n    public function addPage(PageInterface $page, $route = null): void\n    {\n        $path = $page->path() ?? '';\n        if (!isset($this->index[$path])) {\n            $this->index[$path] = $page;\n            $this->instances[$path] = $page;\n        }\n        $route = $page->route($route);\n        $parent = $page->parent();\n        if ($parent) {\n            $this->children[$parent->path() ?? ''][$path] = ['slug' => $page->slug()];\n        }\n        $this->routes[$route] = $path;\n\n        $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));\n    }\n\n    /**\n     * Get a collection of pages in the given context.\n     *\n     * @param array $params\n     * @param array $context\n     * @return PageCollectionInterface|Collection\n     */\n    public function getCollection(array $params = [], array $context = [])\n    {\n        if (!isset($params['items'])) {\n            return new Collection();\n        }\n\n        /** @var Config $config */\n        $config = $this->grav['config'];\n\n        $context += [\n            'event' => true,\n            'pagination' => true,\n            'url_taxonomy_filters' => $config->get('system.pages.url_taxonomy_filters'),\n            'taxonomies' => (array)$config->get('site.taxonomies'),\n            'pagination_page' => 1,\n            'self' => null,\n        ];\n\n        // Include taxonomies from the URL if requested.\n        $process_taxonomy = $params['url_taxonomy_filters'] ?? $context['url_taxonomy_filters'];\n        if ($process_taxonomy) {\n            /** @var Uri $uri */\n            $uri = $this->grav['uri'];\n            foreach ($context['taxonomies'] as $taxonomy) {\n                $param = $uri->param(rawurlencode($taxonomy));\n                $items = is_string($param) ? explode(',', $param) : [];\n                foreach ($items as $item) {\n                    $params['taxonomies'][$taxonomy][] = htmlspecialchars_decode(rawurldecode($item), ENT_QUOTES);\n                }\n            }\n        }\n\n        $pagination = $params['pagination'] ?? $context['pagination'];\n        if ($pagination && !isset($params['page'], $params['start'])) {\n            /** @var Uri $uri */\n            $uri = $this->grav['uri'];\n            $context['current_page'] = $uri->currentPage();\n        }\n\n        $collection = $this->evaluate($params['items'], $context['self']);\n        $collection->setParams($params);\n\n        // Filter by taxonomies.\n        foreach ($params['taxonomies'] ?? [] as $taxonomy => $items) {\n            foreach ($collection as $page) {\n                // Don't include modules\n                if ($page->isModule()) {\n                    continue;\n                }\n\n                $test = $page->taxonomy()[$taxonomy] ?? [];\n                foreach ($items as $item) {\n                    if (!$test || !in_array($item, $test, true)) {\n                        $collection->remove($page->path());\n                    }\n                }\n            }\n        }\n\n        $filters = $params['filter'] ?? [];\n\n        // Assume published=true if not set.\n        if (!isset($filters['published']) && !isset($filters['non-published'])) {\n            $filters['published'] = true;\n        }\n\n        // Remove any inclusive sets from filter.\n        $sets = ['published', 'visible', 'modular', 'routable'];\n        foreach ($sets as $type) {\n            $nonType = \"non-{$type}\";\n            if (isset($filters[$type], $filters[$nonType]) && $filters[$type] === $filters[$nonType]) {\n                if (!$filters[$type]) {\n                    // Both options are false, return empty collection as nothing can match the filters.\n                    return new Collection();\n                }\n\n                // Both options are true, remove opposite filters as all pages will match the filters.\n                unset($filters[$type], $filters[$nonType]);\n            }\n        }\n\n        // Filter the collection\n        foreach ($filters as $type => $filter) {\n            if (null === $filter) {\n                continue;\n            }\n\n            // Convert non-type to type.\n            if (str_starts_with($type, 'non-')) {\n                $type = substr($type, 4);\n                $filter = !$filter;\n            }\n\n            switch ($type) {\n                case 'translated':\n                    if ($filter) {\n                        $collection = $collection->translated();\n                    } else {\n                        $collection = $collection->nonTranslated();\n                    }\n                    break;\n                case 'published':\n                    if ($filter) {\n                        $collection = $collection->published();\n                    } else {\n                        $collection = $collection->nonPublished();\n                    }\n                    break;\n                case 'visible':\n                    if ($filter) {\n                        $collection = $collection->visible();\n                    } else {\n                        $collection = $collection->nonVisible();\n                    }\n                    break;\n                case 'page':\n                    if ($filter) {\n                        $collection = $collection->pages();\n                    } else {\n                        $collection = $collection->modules();\n                    }\n                    break;\n                case 'module':\n                case 'modular':\n                    if ($filter) {\n                        $collection = $collection->modules();\n                    } else {\n                        $collection = $collection->pages();\n                    }\n                    break;\n                case 'routable':\n                    if ($filter) {\n                        $collection = $collection->routable();\n                    } else {\n                        $collection = $collection->nonRoutable();\n                    }\n                    break;\n                case 'type':\n                    $collection = $collection->ofType($filter);\n                    break;\n                case 'types':\n                    $collection = $collection->ofOneOfTheseTypes($filter);\n                    break;\n                case 'access':\n                    $collection = $collection->ofOneOfTheseAccessLevels($filter);\n                    break;\n            }\n        }\n\n        if (isset($params['dateRange'])) {\n            $start = $params['dateRange']['start'] ?? null;\n            $end = $params['dateRange']['end'] ?? null;\n            $field = $params['dateRange']['field'] ?? null;\n            $collection = $collection->dateRange($start, $end, $field);\n        }\n\n        if (isset($params['order'])) {\n            $by = $params['order']['by'] ?? 'default';\n            $dir = $params['order']['dir'] ?? 'asc';\n            $custom = $params['order']['custom'] ?? null;\n            $sort_flags = $params['order']['sort_flags'] ?? null;\n\n            if (is_array($sort_flags)) {\n                $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value\n                $sort_flags = array_reduce($sort_flags, static function ($a, $b) {\n                    return $a | $b;\n                }, 0); //merge constant values using bit or\n            }\n\n            $collection = $collection->order($by, $dir, $custom, $sort_flags);\n        }\n\n        // New Custom event to handle things like pagination.\n        if ($context['event']) {\n            $this->grav->fireEvent('onCollectionProcessed', new Event(['collection' => $collection, 'context' => $context]));\n        }\n\n        if ($context['pagination']) {\n            // Slice and dice the collection if pagination is required\n            $params = $collection->params();\n\n            $limit = (int)($params['limit'] ?? 0);\n            $page = (int)($params['page'] ?? $context['current_page'] ?? 0);\n            $start = (int)($params['start'] ?? 0);\n            $start = $limit > 0 && $page > 0 ? ($page - 1) * $limit : max(0, $start);\n\n            if ($start || ($limit && $collection->count() > $limit)) {\n                $collection->slice($start, $limit ?: null);\n            }\n        }\n\n        return $collection;\n    }\n\n    /**\n     * @param array|string $value\n     * @param PageInterface|null $self\n     * @return Collection\n     */\n    protected function evaluate($value, PageInterface $self = null)\n    {\n        // Parse command.\n        if (is_string($value)) {\n            // Format: @command.param\n            $cmd = $value;\n            $params = [];\n        } elseif (is_array($value) && count($value) === 1 && !is_int(key($value))) {\n            // Format: @command.param: { attr1: value1, attr2: value2 }\n            $cmd = (string)key($value);\n            $params = (array)current($value);\n        } else {\n            $result = [];\n            foreach ((array)$value as $key => $val) {\n                if (is_int($key)) {\n                    $result = $result + $this->evaluate($val, $self)->toArray();\n                } else {\n                    $result = $result + $this->evaluate([$key => $val], $self)->toArray();\n                }\n            }\n\n            return new Collection($result);\n        }\n\n        $parts = explode('.', $cmd);\n        $scope = array_shift($parts);\n        $type = $parts[0] ?? null;\n\n        /** @var PageInterface|null $page */\n        $page = null;\n        switch ($scope) {\n            case 'self@':\n            case '@self':\n                $page = $self;\n                break;\n\n            case 'page@':\n            case '@page':\n                $page = isset($params[0]) ? $this->find($params[0]) : null;\n                break;\n\n            case 'root@':\n            case '@root':\n                $page = $this->root();\n                break;\n\n            case 'taxonomy@':\n            case '@taxonomy':\n                // Gets a collection of pages by using one of the following formats:\n                // @taxonomy.category: blog\n                // @taxonomy.category: [ blog, featured ]\n                // @taxonomy: { category: [ blog, featured ], level: 1 }\n\n                /** @var Taxonomy $taxonomy_map */\n                $taxonomy_map = Grav::instance()['taxonomy'];\n\n                if (!empty($parts)) {\n                    $params = [implode('.', $parts) => $params];\n                }\n\n                return $taxonomy_map->findTaxonomy($params);\n        }\n\n        if (!$page) {\n            return new Collection();\n        }\n\n        // Handle '@page', '@page.modular: false', '@self' and '@self.modular: false'.\n        if (null === $type || (in_array($type, ['modular', 'modules']) && ($params[0] ?? null) === false)) {\n            $type = 'children';\n        }\n\n        switch ($type) {\n            case 'all':\n                $collection = $page->children();\n                break;\n            case 'modules':\n            case 'modular':\n                $collection = $page->children()->modules();\n                break;\n            case 'pages':\n            case 'children':\n                $collection = $page->children()->pages();\n                break;\n            case 'page':\n            case 'self':\n                $collection = !$page->root() ? (new Collection())->addPage($page) : new Collection();\n                break;\n            case 'parent':\n                $parent = $page->parent();\n                $collection = new Collection();\n                $collection = $parent ? $collection->addPage($parent) : $collection;\n                break;\n            case 'siblings':\n                $parent = $page->parent();\n                if ($parent) {\n                    /** @var Collection $collection */\n                    $collection = $parent->children();\n                    $collection = $collection->remove($page->path());\n                } else {\n                    $collection = new Collection();\n                }\n                break;\n            case 'descendants':\n                $collection = $this->all($page)->remove($page->path())->pages();\n                break;\n            default:\n                // Unknown type; return empty collection.\n                $collection = new Collection();\n                break;\n        }\n\n        return $collection;\n    }\n\n    /**\n     * Sort sub-pages in a page.\n     *\n     * @param PageInterface   $page\n     * @param string|null $order_by\n     * @param string|null $order_dir\n     * @return array\n     */\n    public function sort(PageInterface $page, $order_by = null, $order_dir = null, $sort_flags = null)\n    {\n        if ($order_by === null) {\n            $order_by = $page->orderBy();\n        }\n        if ($order_dir === null) {\n            $order_dir = $page->orderDir();\n        }\n\n        $path = $page->path();\n        if (null === $path) {\n            return [];\n        }\n\n        $children = $this->children[$path] ?? [];\n\n        if (!$children) {\n            return $children;\n        }\n\n        if (!isset($this->sort[$path][$order_by])) {\n            $this->buildSort($path, $children, $order_by, $page->orderManual(), $sort_flags);\n        }\n\n        $sort = $this->sort[$path][$order_by];\n\n        if ($order_dir !== 'asc') {\n            $sort = array_reverse($sort);\n        }\n\n        return $sort;\n    }\n\n    /**\n     * @param Collection $collection\n     * @param string     $orderBy\n     * @param string     $orderDir\n     * @param array|null $orderManual\n     * @param int|null   $sort_flags\n     * @return array\n     * @internal\n     */\n    public function sortCollection(Collection $collection, $orderBy, $orderDir = 'asc', $orderManual = null, $sort_flags = null)\n    {\n        $items = $collection->toArray();\n        if (!$items) {\n            return [];\n        }\n\n        $lookup = md5(json_encode($items) . json_encode($orderManual) . $orderBy . $orderDir);\n        if (!isset($this->sort[$lookup][$orderBy])) {\n            $this->buildSort($lookup, $items, $orderBy, $orderManual, $sort_flags);\n        }\n\n        $sort = $this->sort[$lookup][$orderBy];\n\n        if ($orderDir !== 'asc') {\n            $sort = array_reverse($sort);\n        }\n\n        return $sort;\n    }\n\n    /**\n     * Get a page instance.\n     *\n     * @param  string $path The filesystem full path of the page\n     * @return PageInterface|null\n     * @throws RuntimeException\n     */\n    public function get($path)\n    {\n        $path = (string)$path;\n        if ($path === '') {\n            return null;\n        }\n\n        // Check for local instances first.\n        if (array_key_exists($path, $this->instances)) {\n            return $this->instances[$path];\n        }\n\n        $instance = $this->index[$path] ?? null;\n        if (is_string($instance)) {\n            if ($this->directory) {\n                /** @var Language $language */\n                $language = $this->grav['language'];\n                $lang = $language->getActive();\n                if ($lang) {\n                    $languages = $language->getFallbackLanguages($lang, true);\n                    $key = $instance;\n                    $instance = null;\n                    foreach ($languages as $code) {\n                        $test = $code ? $key . ':' . $code : $key;\n                        if (($instance = $this->directory->getObject($test, 'flex_key')) !== null) {\n                            break;\n                        }\n                    }\n                } else {\n                    $instance = $this->directory->getObject($instance, 'flex_key');\n                }\n            }\n\n            if ($instance instanceof PageInterface) {\n                if ($this->fire_events && method_exists($instance, 'initialize')) {\n                    $instance->initialize();\n                }\n            } else {\n                /** @var Debugger $debugger */\n                $debugger = $this->grav['debugger'];\n                $debugger->addMessage(sprintf('Flex page %s is missing or broken!', $instance), 'debug');\n            }\n        }\n\n        if ($instance) {\n            $this->instances[$path] = $instance;\n        }\n\n        return $instance;\n    }\n\n    /**\n     * Get children of the path.\n     *\n     * @param string $path\n     * @return Collection\n     */\n    public function children($path)\n    {\n        $children = $this->children[(string)$path] ?? [];\n\n        return new Collection($children, [], $this);\n    }\n\n    /**\n     * Get a page ancestor.\n     *\n     * @param  string $route The relative URL of the page\n     * @param  string|null $path The relative path of the ancestor folder\n     * @return PageInterface|null\n     */\n    public function ancestor($route, $path = null)\n    {\n        if ($path !== null) {\n            $page = $this->find($route, true);\n\n            if ($page && $page->path() === $path) {\n                return $page;\n            }\n\n            $parent = $page ? $page->parent() : null;\n            if ($parent && !$parent->root()) {\n                return $this->ancestor($parent->route(), $path);\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * Get a page ancestor trait.\n     *\n     * @param  string $route The relative route of the page\n     * @param  string|null $field The field name of the ancestor to query for\n     * @return PageInterface|null\n     */\n    public function inherited($route, $field = null)\n    {\n        if ($field !== null) {\n            $page = $this->find($route, true);\n\n            $parent = $page ? $page->parent() : null;\n            if ($parent && $parent->value('header.' . $field) !== null) {\n                return $parent;\n            }\n            if ($parent && !$parent->root()) {\n                return $this->inherited($parent->route(), $field);\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * Find a page based on route.\n     *\n     * @param string $route The route of the page\n     * @param bool   $all   If true, return also non-routable pages, otherwise return null if page isn't routable\n     * @return PageInterface|null\n     */\n    public function find($route, $all = false)\n    {\n        $route = urldecode((string)$route);\n\n        // Fetch page if there's a defined route to it.\n        $path = $this->routes[$route] ?? null;\n        $page = null !== $path ? $this->get($path) : null;\n\n        // Try without trailing slash\n        if (null === $page && Utils::endsWith($route, '/')) {\n            $path = $this->routes[rtrim($route, '/')] ?? null;\n            $page = null !== $path ? $this->get($path) : null;\n        }\n\n        if (!$all && !isset($this->grav['admin'])) {\n            if (null === $page || !$page->routable()) {\n                // If the page cannot be accessed, look for the site wide routes and wildcards.\n                $page = $this->findSiteBasedRoute($route) ?? $page;\n            }\n        }\n\n        return $page;\n    }\n\n    /**\n     * Check site based routes.\n     *\n     * @param string $route\n     * @return PageInterface|null\n     */\n    protected function findSiteBasedRoute($route)\n    {\n        /** @var Config $config */\n        $config = $this->grav['config'];\n\n        $site_routes = $config->get('site.routes');\n        if (!is_array($site_routes)) {\n            return null;\n        }\n\n        $page = null;\n\n        // See if route matches one in the site configuration\n        $site_route = $site_routes[$route] ?? null;\n        if ($site_route) {\n            $page = $this->find($site_route);\n        } else {\n            // Use reverse order because of B/C (previously matched multiple and returned the last match).\n            foreach (array_reverse($site_routes, true) as $pattern => $replace) {\n                $pattern = '#^' . str_replace('/', '\\/', ltrim($pattern, '^')) . '#';\n                try {\n                    $found = preg_replace($pattern, $replace, $route);\n                    if ($found && $found !== $route) {\n                        $page = $this->find($found);\n                        if ($page) {\n                            return $page;\n                        }\n                    }\n                } catch (ErrorException $e) {\n                    $this->grav['log']->error('site.routes: ' . $pattern . '-> ' . $e->getMessage());\n                }\n            }\n        }\n\n        return $page;\n    }\n\n    /**\n     * Dispatch URI to a page.\n     *\n     * @param string $route The relative URL of the page\n     * @param bool $all If true, return also non-routable pages, otherwise return null if page isn't routable\n     * @param bool $redirect If true, allow redirects\n     * @return PageInterface|null\n     * @throws Exception\n     */\n    public function dispatch($route, $all = false, $redirect = true)\n    {\n        $page = $this->find($route, true);\n\n        // If we want all pages or are in admin, return what we already have.\n        if ($all || isset($this->grav['admin'])) {\n            return $page;\n        }\n\n        if ($page) {\n            $routable = $page->routable();\n            if ($redirect) {\n                if ($page->redirect()) {\n                    // Follow a redirect page.\n                    $this->grav->redirectLangSafe($page->redirect());\n                }\n\n                if (!$routable) {\n                    /** @var Collection $children */\n                    $children = $page->children()->visible()->routable()->published();\n                    $child = $children->first();\n                    if ($child !== null) {\n                        // Redirect to the first visible child as current page isn't routable.\n                        $this->grav->redirectLangSafe($child->route());\n                    }\n                }\n            }\n\n            if ($routable) {\n                return $page;\n            }\n        }\n\n        $route = urldecode((string)$route);\n\n        // The page cannot be reached, look into site wide redirects, routes and wildcards.\n        $redirectedPage = $this->findSiteBasedRoute($route);\n        if ($redirectedPage) {\n            $page = $this->dispatch($redirectedPage->route(), false, $redirect);\n        }\n\n        /** @var Config $config */\n        $config = $this->grav['config'];\n\n        /** @var Uri $uri */\n        $uri = $this->grav['uri'];\n        /** @var \\Grav\\Framework\\Uri\\Uri $source_url */\n        $source_url = $uri->uri(false);\n\n        // Try Regex style redirects\n        $site_redirects = $config->get('site.redirects');\n        if (is_array($site_redirects)) {\n            foreach ((array)$site_redirects as $pattern => $replace) {\n                $pattern = ltrim($pattern, '^');\n                $pattern = '#^' . str_replace('/', '\\/', $pattern) . '#';\n                try {\n                    /** @var string $found */\n                    $found = preg_replace($pattern, $replace, $source_url);\n                    if ($found && $found !== $source_url) {\n                        $this->grav->redirectLangSafe($found);\n                    }\n                } catch (ErrorException $e) {\n                    $this->grav['log']->error('site.redirects: ' . $pattern . '-> ' . $e->getMessage());\n                }\n            }\n        }\n\n        return $page;\n    }\n\n    /**\n     * Get root page.\n     *\n     * @return PageInterface\n     * @throws RuntimeException\n     */\n    public function root()\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n\n        $path = $locator->findResource('page://');\n        $root = is_string($path) ? $this->get(rtrim($path, '/')) : null;\n        if (null === $root) {\n            throw new RuntimeException('Internal error');\n        }\n\n        return $root;\n    }\n\n    /**\n     * Get a blueprint for a page type.\n     *\n     * @param  string $type\n     * @return Blueprint\n     */\n    public function blueprints($type)\n    {\n        if ($this->blueprints === null) {\n            $this->blueprints = new Blueprints(self::getTypes());\n        }\n\n        try {\n            $blueprint = $this->blueprints->get($type);\n        } catch (RuntimeException $e) {\n            $blueprint = $this->blueprints->get('default');\n        }\n\n        if (empty($blueprint->initialized)) {\n            $blueprint->initialized = true;\n            $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type]));\n        }\n\n        return $blueprint;\n    }\n\n    /**\n     * Get all pages\n     *\n     * @param PageInterface|null $current\n     * @return Collection\n     */\n    public function all(PageInterface $current = null)\n    {\n        $all = new Collection();\n\n        /** @var PageInterface $current */\n        $current = $current ?: $this->root();\n\n        if (!$current->root()) {\n            $all[$current->path()] = ['slug' => $current->slug()];\n        }\n\n        foreach ($current->children() as $next) {\n            $all->append($this->all($next));\n        }\n\n        return $all;\n    }\n\n    /**\n     * Get available parents raw routes.\n     *\n     * @return array\n     */\n    public static function parentsRawRoutes()\n    {\n        $rawRoutes = true;\n\n        return self::getParents($rawRoutes);\n    }\n\n    /**\n     * Get available parents routes\n     *\n     * @param bool $rawRoutes get the raw route or the normal route\n     * @return array\n     */\n    private static function getParents($rawRoutes)\n    {\n        $grav = Grav::instance();\n\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n\n        $parents = $pages->getList(null, 0, $rawRoutes);\n\n        if (isset($grav['admin'])) {\n            // Remove current route from parents\n\n            /** @var Admin $admin */\n            $admin = $grav['admin'];\n\n            $page = $admin->getPage($admin->route);\n            $page_route = $page->route();\n            if (isset($parents[$page_route])) {\n                unset($parents[$page_route]);\n            }\n        }\n\n        return $parents;\n    }\n\n    /**\n     * Get list of route/title of all pages. Title is in HTML.\n     *\n     * @param PageInterface|null $current\n     * @param int $level\n     * @param bool $rawRoutes\n     * @param bool $showAll\n     * @param bool $showFullpath\n     * @param bool $showSlug\n     * @param bool $showModular\n     * @param bool $limitLevels\n     * @return array\n     */\n    public function getList(PageInterface $current = null, $level = 0, $rawRoutes = false, $showAll = true, $showFullpath = false, $showSlug = false, $showModular = false, $limitLevels = false)\n    {\n        if (!$current) {\n            if ($level) {\n                throw new RuntimeException('Internal error');\n            }\n\n            $current = $this->root();\n        }\n\n        $list = [];\n\n        if (!$current->root()) {\n            if ($rawRoutes) {\n                $route = $current->rawRoute();\n            } else {\n                $route = $current->route();\n            }\n\n            if ($showFullpath) {\n                $option = htmlspecialchars($current->route());\n            } else {\n                $extra  = $showSlug ? '(' . $current->slug() . ') ' : '';\n                $option = str_repeat('&mdash;-', $level). '&rtrif; ' . $extra . htmlspecialchars($current->title());\n            }\n\n            $list[$route] = $option;\n        }\n\n        if ($limitLevels === false || ($level+1 < $limitLevels)) {\n            foreach ($current->children() as $next) {\n                if ($showAll || $next->routable() || ($next->isModule() && $showModular)) {\n                    $list = array_merge($list, $this->getList($next, $level + 1, $rawRoutes, $showAll, $showFullpath, $showSlug, $showModular, $limitLevels));\n                }\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * Get available page types.\n     *\n     * @return Types\n     */\n    public static function getTypes()\n    {\n        if (null === self::$types) {\n            $grav = Grav::instance();\n\n            /** @var UniformResourceLocator $locator */\n            $locator = $grav['locator'];\n\n            // Prevent calls made before theme:// has been initialized (happens when upgrading old version of Admin plugin).\n            if (!$locator->isStream('theme://')) {\n                return new Types();\n            }\n\n            $scanBlueprintsAndTemplates = static function (Types $types) use ($grav) {\n                // Scan blueprints\n                $event = new TypesEvent();\n                $event->types = $types;\n                $grav->fireEvent('onGetPageBlueprints', $event);\n\n                $types->init();\n\n                // Try new location first.\n                $lookup = 'theme://blueprints/pages/';\n                if (!is_dir($lookup)) {\n                    $lookup = 'theme://blueprints/';\n                }\n                $types->scanBlueprints($lookup);\n\n                // Scan templates\n                $event = new TypesEvent();\n                $event->types = $types;\n                $grav->fireEvent('onGetPageTemplates', $event);\n\n                $types->scanTemplates('theme://templates/');\n            };\n\n            if ($grav['config']->get('system.cache.enabled')) {\n                /** @var Cache $cache */\n                $cache = $grav['cache'];\n\n                // Use cached types if possible.\n                $types_cache_id = md5('types');\n                $types = $cache->fetch($types_cache_id);\n\n                if (!$types instanceof Types) {\n                    $types = new Types();\n                    $scanBlueprintsAndTemplates($types);\n                    $cache->save($types_cache_id, $types);\n                }\n            } else {\n                $types = new Types();\n                $scanBlueprintsAndTemplates($types);\n            }\n\n            // Register custom paths to the locator.\n            $locator = $grav['locator'];\n            foreach ($types as $type => $paths) {\n                foreach ($paths as $k => $path) {\n                    if (strpos($path, 'blueprints://') === 0) {\n                        unset($paths[$k]);\n                    }\n                }\n                if ($paths) {\n                    $locator->addPath('blueprints', \"pages/$type.yaml\", $paths);\n                }\n            }\n\n            self::$types = $types;\n        }\n\n        return self::$types;\n    }\n\n    /**\n     * Get available page types.\n     *\n     * @return array\n     */\n    public static function types()\n    {\n        $types = self::getTypes();\n\n        return $types->pageSelect();\n    }\n\n    /**\n     * Get available page types.\n     *\n     * @return array\n     */\n    public static function modularTypes()\n    {\n        $types = self::getTypes();\n\n        return $types->modularSelect();\n    }\n\n    /**\n     * Get template types based on page type (standard or modular)\n     *\n     * @param string|null $type\n     * @return array\n     */\n    public static function pageTypes($type = null)\n    {\n        if (null === $type && isset(Grav::instance()['admin'])) {\n            /** @var Admin $admin */\n            $admin = Grav::instance()['admin'];\n\n            /** @var PageInterface|null $page */\n            $page = $admin->page();\n\n            $type = $page && $page->isModule() ? 'modular' : 'standard';\n        }\n\n        switch ($type) {\n            case 'standard':\n                return static::types();\n            case 'modular':\n                return static::modularTypes();\n        }\n\n        return [];\n    }\n\n    /**\n     * Get access levels of the site pages\n     *\n     * @return array\n     */\n    public function accessLevels()\n    {\n        $accessLevels = [];\n        foreach ($this->all() as $page) {\n            if ($page instanceof PageInterface && isset($page->header()->access)) {\n                if (is_array($page->header()->access)) {\n                    foreach ($page->header()->access as $index => $accessLevel) {\n                        if (is_array($accessLevel)) {\n                            foreach ($accessLevel as $innerIndex => $innerAccessLevel) {\n                                $accessLevels[] = $innerIndex;\n                            }\n                        } else {\n                            $accessLevels[] = $index;\n                        }\n                    }\n                } else {\n                    $accessLevels[] = $page->header()->access;\n                }\n            }\n        }\n\n        return array_unique($accessLevels);\n    }\n\n    /**\n     * Get available parents routes\n     *\n     * @return array\n     */\n    public static function parents()\n    {\n        $rawRoutes = false;\n\n        return self::getParents($rawRoutes);\n    }\n\n    /**\n     * Gets the home route\n     *\n     * @return string\n     */\n    public static function getHomeRoute()\n    {\n        if (empty(self::$home_route)) {\n            $grav = Grav::instance();\n\n            /** @var Config $config */\n            $config = $grav['config'];\n\n            /** @var Language $language */\n            $language = $grav['language'];\n\n            $home = $config->get('system.home.alias');\n\n            if ($language->enabled()) {\n                $home_aliases = $config->get('system.home.aliases');\n                if ($home_aliases) {\n                    $active = $language->getActive();\n                    $default = $language->getDefault();\n\n                    try {\n                        if ($active) {\n                            $home = $home_aliases[$active];\n                        } else {\n                            $home = $home_aliases[$default];\n                        }\n                    } catch (ErrorException $e) {\n                        $home = $home_aliases[$default];\n                    }\n                }\n            }\n\n            self::$home_route = trim($home, '/');\n        }\n\n        return self::$home_route;\n    }\n\n    /**\n     * Needed for testing where we change the home route via config\n     *\n     * @return string|null\n     */\n    public static function resetHomeRoute()\n    {\n        self::$home_route = null;\n\n        return self::getHomeRoute();\n    }\n\n    protected function initFlexPages(): void\n    {\n        /** @var Debugger $debugger */\n        $debugger = $this->grav['debugger'];\n        $debugger->addMessage('Pages: Flex Directory');\n\n        /** @var Flex $flex */\n        $flex = $this->grav['flex'];\n        $directory = $flex->getDirectory('pages');\n\n        /** @var EventDispatcher $dispatcher */\n        $dispatcher = $this->grav['events'];\n\n        // Stop /admin/pages from working, display error instead.\n        $dispatcher->addListener(\n            'onAdminPage',\n            static function (Event $event) use ($directory) {\n                $grav = Grav::instance();\n                $admin = $grav['admin'];\n                [$base,$location,] = $admin->getRouteDetails();\n                if ($location !== 'pages' || isset($grav['flex_objects'])) {\n                    return;\n                }\n\n                /** @var PageInterface $page */\n                $page = $event['page'];\n                $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md'));\n                $page->routable(true);\n                $header = $page->header();\n                $header->title = 'Please install missing plugin';\n                $page->content(\"## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex Pages**.\");\n\n                /** @var Header $header */\n                $header = $page->header();\n                $menu = $directory->getConfig('admin.menu.list');\n                $header->access = $menu['authorize'] ?? ['admin.super'];\n            },\n            100000\n        );\n\n        $this->directory = $directory;\n    }\n\n    /**\n     * Builds pages.\n     *\n     * @internal\n     */\n    protected function buildPages(): void\n    {\n        /** @var Debugger $debugger */\n        $debugger = $this->grav['debugger'];\n        $debugger->startTimer('build-pages', 'Init frontend routes');\n\n        if ($this->directory) {\n            $this->buildFlexPages($this->directory);\n        } else {\n            $this->buildRegularPages();\n        }\n        $debugger->stopTimer('build-pages');\n    }\n\n    protected function buildFlexPages(FlexDirectory $directory): void\n    {\n        /** @var Config $config */\n        $config = $this->grav['config'];\n\n        // TODO: right now we are just emulating normal pages, it is inefficient and bad... but works!\n        /** @var PageCollection|PageIndex $collection */\n        $collection = $directory->getIndex(null, 'storage_key');\n        $cache = $directory->getCache('index');\n\n        /** @var Language $language */\n        $language = $this->grav['language'];\n\n        $this->pages_cache_id = 'pages-' . md5($collection->getCacheChecksum() . $language->getActive() . $config->checksum());\n\n        $cached = $cache->get($this->pages_cache_id);\n\n        if ($cached && $this->getVersion() === $cached[0]) {\n            [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached;\n\n            /** @var Taxonomy $taxonomy */\n            $taxonomy = $this->grav['taxonomy'];\n            $taxonomy->taxonomy($taxonomy_map);\n\n            return;\n        }\n\n        /** @var Debugger $debugger */\n        $debugger = $this->grav['debugger'];\n        $debugger->addMessage('Page cache missed, rebuilding Flex Pages..');\n\n        $root = $collection->getRoot();\n        $root_path = $root->path();\n        $this->routes = [];\n        $this->instances = [$root_path => $root];\n        $this->index = [$root_path => $root];\n        $this->children = [];\n        $this->sort = [];\n\n        if ($this->fire_events) {\n            $this->grav->fireEvent('onBuildPagesInitialized');\n        }\n\n        /** @var PageInterface $page */\n        foreach ($collection as $page) {\n            $path = $page->path();\n            if (null === $path) {\n                throw new RuntimeException('Internal error');\n            }\n\n            if ($page instanceof FlexTranslateInterface) {\n                $page = $page->hasTranslation() ? $page->getTranslation() : null;\n            }\n\n            if (!$page instanceof FlexPageObject || $path === $root_path) {\n                continue;\n            }\n\n            if ($this->fire_events) {\n                if (method_exists($page, 'initialize')) {\n                    $page->initialize();\n                } else {\n                    // TODO: Deprecated, only used in 1.7 betas.\n                    $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));\n                }\n            }\n\n            $parent = dirname($path);\n\n            $route = $page->rawRoute();\n\n            // Skip duplicated empty folders (git revert does not remove those).\n            // TODO: still not perfect, will only work if the page has been translated.\n            if (isset($this->routes[$route])) {\n                $oldPath = $this->routes[$route];\n                if ($page->isPage()) {\n                    unset($this->index[$oldPath], $this->children[dirname($oldPath)][$oldPath]);\n                } else {\n                    continue;\n                }\n            }\n\n            $this->routes[$route] = $path;\n            $this->instances[$path] = $page;\n            $this->index[$path] = $page->getFlexKey();\n            // FIXME: ... better...\n            $this->children[$parent][$path] = ['slug' => $page->slug()];\n            if (!isset($this->children[$path])) {\n                $this->children[$path] = [];\n            }\n        }\n\n        foreach ($this->children as $path => $list) {\n            $page = $this->instances[$path] ?? null;\n            if (null === $page) {\n                continue;\n            }\n            // Call onFolderProcessed event.\n            if ($this->fire_events) {\n                $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page]));\n            }\n            // Sort the children.\n            $this->children[$path] = $this->sort($page);\n        }\n\n        $this->routes = [];\n        $this->buildRoutes();\n\n        // cache if needed\n        if (null !== $cache) {\n            /** @var Taxonomy $taxonomy */\n            $taxonomy = $this->grav['taxonomy'];\n            $taxonomy_map = $taxonomy->taxonomy();\n\n            // save pages, routes, taxonomy, and sort to cache\n            $cache->set($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort]);\n        }\n    }\n\n    /**\n     * @return Page\n     */\n    protected function buildRootPage()\n    {\n        $grav = Grav::instance();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n        $path = $locator->findResource('page://');\n        if (!is_string($path)) {\n            throw new RuntimeException('Internal Error');\n        }\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        $page = new Page();\n        $page->path($path);\n        $page->orderDir($config->get('system.pages.order.dir'));\n        $page->orderBy($config->get('system.pages.order.by'));\n        $page->modified(0);\n        $page->routable(false);\n        $page->template('default');\n        $page->extension('.md');\n\n        return $page;\n    }\n\n    protected function buildRegularPages(): void\n    {\n        /** @var Config $config */\n        $config = $this->grav['config'];\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n\n        /** @var Language $language */\n        $language = $this->grav['language'];\n\n        $pages_dirs = $this->getPagesPaths();\n\n        // Set active language\n        $this->active_lang = $language->getActive();\n\n        if ($config->get('system.cache.enabled')) {\n            /** @var Language $language */\n            $language = $this->grav['language'];\n\n            // how should we check for last modified? Default is by file\n            switch ($this->check_method) {\n                case 'none':\n                case 'off':\n                    $hash = 0;\n                    break;\n                case 'folder':\n                    $hash = Folder::lastModifiedFolder($pages_dirs);\n                    break;\n                case 'hash':\n                    $hash = Folder::hashAllFiles($pages_dirs);\n                    break;\n                default:\n                    $hash = Folder::lastModifiedFile($pages_dirs);\n            }\n\n            $this->simple_pages_hash = json_encode($pages_dirs) . $hash . $config->checksum();\n            $this->pages_cache_id = md5($this->simple_pages_hash . $language->getActive());\n\n            /** @var Cache $cache */\n            $cache = $this->grav['cache'];\n            $cached = $cache->fetch($this->pages_cache_id);\n            if ($cached && $this->getVersion() === $cached[0]) {\n                [, $this->index, $this->routes, $this->children, $taxonomy_map, $this->sort] = $cached;\n\n                /** @var Taxonomy $taxonomy */\n                $taxonomy = $this->grav['taxonomy'];\n                $taxonomy->taxonomy($taxonomy_map);\n\n                return;\n            }\n\n            $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..');\n        } else {\n            $this->grav['debugger']->addMessage('Page cache disabled, rebuilding pages..');\n        }\n\n        $this->resetPages($pages_dirs);\n    }\n\n    protected function getPagesPaths(): array\n    {\n        $grav = Grav::instance();\n        $locator = $grav['locator'];\n        $paths = [];\n\n        $dirs = (array) $grav['config']->get('system.pages.dirs', ['page://']);\n        foreach ($dirs as $dir) {\n            $path = $locator->findResource($dir);\n            if (file_exists($path) && !in_array($path, $paths, true)) {\n                $paths[] = $path;\n            }\n        }\n\n        return $paths;\n    }\n\n    /**\n     * Accessible method to manually reset the pages cache\n     *\n     * @param array $pages_dirs\n     */\n    public function resetPages(array $pages_dirs): void\n    {\n        $this->sort = [];\n\n        foreach ($pages_dirs as $dir) {\n            $this->recurse($dir);\n        }\n\n        $this->buildRoutes();\n\n        // cache if needed\n        if ($this->grav['config']->get('system.cache.enabled')) {\n            /** @var Cache $cache */\n            $cache = $this->grav['cache'];\n            /** @var Taxonomy $taxonomy */\n            $taxonomy = $this->grav['taxonomy'];\n\n            // save pages, routes, taxonomy, and sort to cache\n            $cache->save($this->pages_cache_id, [$this->getVersion(), $this->index, $this->routes, $this->children, $taxonomy->taxonomy(), $this->sort]);\n        }\n    }\n\n    /**\n     * Recursive function to load & build page relationships.\n     *\n     * @param string    $directory\n     * @param PageInterface|null $parent\n     * @return PageInterface\n     * @throws RuntimeException\n     * @internal\n     */\n    protected function recurse(string $directory, PageInterface $parent = null)\n    {\n        $directory = rtrim($directory, DS);\n        $page = new Page;\n\n        /** @var Config $config */\n        $config = $this->grav['config'];\n\n        /** @var Language $language */\n        $language = $this->grav['language'];\n\n        // Stuff to do at root page\n        // Fire event for memory and time consuming plugins...\n        if ($parent === null && $this->fire_events) {\n            $this->grav->fireEvent('onBuildPagesInitialized');\n        }\n\n        $page->path($directory);\n        if ($parent) {\n            $page->parent($parent);\n        }\n\n        $page->orderDir($config->get('system.pages.order.dir'));\n        $page->orderBy($config->get('system.pages.order.by'));\n\n        // Add into instances\n        if (!isset($this->index[$page->path()])) {\n            $this->index[$page->path()] = $page;\n            $this->instances[$page->path()] = $page;\n            if ($parent && $page->path()) {\n                $this->children[$parent->path()][$page->path()] = ['slug' => $page->slug()];\n            }\n        } elseif ($parent !== null) {\n            throw new RuntimeException('Fatal error when creating page instances.');\n        }\n\n        // Build regular expression for all the allowed page extensions.\n        $page_extensions = $language->getFallbackPageExtensions();\n        $regex = '/^[^\\.]*(' . implode('|', array_map(\n            static function ($str) {\n                return preg_quote($str, '/');\n            },\n            $page_extensions\n        )) . ')$/';\n\n        $folders = [];\n        $page_found = null;\n        $page_extension = '.md';\n        $last_modified = 0;\n\n        $iterator = new FilesystemIterator($directory);\n        foreach ($iterator as $file) {\n            $filename = $file->getFilename();\n\n            // Ignore all hidden files if set.\n            if ($this->ignore_hidden && $filename && strpos($filename, '.') === 0) {\n                continue;\n            }\n\n            // Handle folders later.\n            if ($file->isDir()) {\n                // But ignore all folders in ignore list.\n                if (!in_array($filename, $this->ignore_folders, true)) {\n                    $folders[] = $file;\n                }\n                continue;\n            }\n\n            // Ignore all files in ignore list.\n            if (in_array($filename, $this->ignore_files, true)) {\n                continue;\n            }\n\n            // Update last modified date to match the last updated file in the folder.\n            $modified = $file->getMTime();\n            if ($modified > $last_modified) {\n                $last_modified = $modified;\n            }\n\n            // Page is the one that matches to $page_extensions list with the lowest index number.\n            if (preg_match($regex, $filename, $matches, PREG_OFFSET_CAPTURE)) {\n                $ext = $matches[1][0];\n\n                if ($page_found === null || array_search($ext, $page_extensions, true) < array_search($page_extension, $page_extensions, true)) {\n                    $page_found = $file;\n                    $page_extension = $ext;\n                }\n            }\n        }\n\n        $content_exists = false;\n        if ($parent && $page_found) {\n            $page->init($page_found, $page_extension);\n\n            $content_exists = true;\n\n            if ($this->fire_events) {\n                $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));\n            }\n        }\n\n        // Now handle all the folders under the page.\n        /** @var FilesystemIterator $file */\n        foreach ($folders as $file) {\n            $filename = $file->getFilename();\n\n            // if folder contains separator, continue\n            if (Utils::contains($file->getFilename(), $config->get('system.param_sep', ':'))) {\n                continue;\n            }\n\n            if (!$page->path()) {\n                $page->path($file->getPath());\n            }\n\n            $path = $directory . DS . $filename;\n            $child = $this->recurse($path, $page);\n\n            if (preg_match('/^(\\d+\\.)_/', $filename)) {\n                $child->routable(false);\n                $child->modularTwig(true);\n            }\n\n            $this->children[$page->path()][$child->path()] = ['slug' => $child->slug()];\n\n            if ($this->fire_events) {\n                $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page]));\n            }\n        }\n\n        if (!$content_exists) {\n            // Set routable to false if no page found\n            $page->routable(false);\n\n            // Hide empty folders if option set\n            if ($config->get('system.pages.hide_empty_folders')) {\n                $page->visible(false);\n            }\n        }\n\n        // Override the modified time if modular\n        if ($page->template() === 'modular') {\n            foreach ($page->collection() as $child) {\n                $modified = $child->modified();\n\n                if ($modified > $last_modified) {\n                    $last_modified = $modified;\n                }\n            }\n        }\n\n        // Override the modified and ID so that it takes the latest change into account\n        $page->modified($last_modified);\n        $page->id($last_modified . md5($page->filePath() ?? ''));\n\n        // Sort based on Defaults or Page Overridden sort order\n        $this->children[$page->path()] = $this->sort($page);\n\n        return $page;\n    }\n\n    /**\n     * @internal\n     */\n    protected function buildRoutes(): void\n    {\n        /** @var Taxonomy $taxonomy */\n        $taxonomy = $this->grav['taxonomy'];\n\n        // Get the home route\n        $home = self::resetHomeRoute();\n        // Build routes and taxonomy map.\n        /** @var PageInterface|string $page */\n        foreach ($this->index as $path => $page) {\n            if (is_string($page)) {\n                $page = $this->get($path);\n            }\n\n            if (!$page || $page->root()) {\n                continue;\n            }\n\n            // process taxonomy\n            $taxonomy->addTaxonomy($page);\n\n            $page_path = $page->path();\n            if (null === $page_path) {\n                throw new RuntimeException('Internal Error');\n            }\n\n            $route = $page->route();\n            $raw_route = $page->rawRoute();\n\n            // add regular route\n            if ($route) {\n                if (isset($this->routes[$route]) && $this->routes[$route] !== $page_path) {\n                    $this->grav['debugger']->addMessage(\"Route '{$route}' already exists: {$this->routes[$route]}, overwriting with {$page_path}\");\n                }\n                $this->routes[$route] = $page_path;\n            }\n\n            // add raw route\n            if ($raw_route) {\n                if (isset($this->routes[$raw_route]) && $this->routes[$route] !== $page_path) {\n                    $this->grav['debugger']->addMessage(\"Raw Route '{$raw_route}' already exists: {$this->routes[$raw_route]}, overwriting with {$page_path}\");\n                }\n                $this->routes[$raw_route] = $page_path;\n            }\n\n            // add canonical route\n            $route_canonical = $page->routeCanonical();\n            if ($route_canonical) {\n                if (isset($this->routes[$route_canonical]) && $this->routes[$route_canonical] !== $page_path) {\n                    $this->grav['debugger']->addMessage(\"Canonical Route '{$route_canonical}' already exists: {$this->routes[$route_canonical]}, overwriting with {$page_path}\");\n                }\n                $this->routes[$route_canonical] = $page_path;\n            }\n\n            // add aliases to routes list if they are provided\n            $route_aliases = $page->routeAliases();\n            if ($route_aliases) {\n                foreach ($route_aliases as $alias) {\n                    if (isset($this->routes[$alias]) && $this->routes[$alias] !== $page_path) {\n                        $this->grav['debugger']->addMessage(\"Alias Route '{$alias}' already exists: {$this->routes[$alias]}, overwriting with {$page_path}\");\n                    }\n                    $this->routes[$alias] = $page_path;\n                }\n            }\n        }\n\n        // Alias and set default route to home page.\n        $homeRoute = \"/{$home}\";\n        if ($home && isset($this->routes[$homeRoute])) {\n            $home = $this->get($this->routes[$homeRoute]);\n            if ($home) {\n                $this->routes['/'] = $this->routes[$homeRoute];\n                $home->route('/');\n            }\n        }\n    }\n\n    /**\n     * @param string $path\n     * @param array  $pages\n     * @param string $order_by\n     * @param array|null  $manual\n     * @param int|null    $sort_flags\n     * @throws RuntimeException\n     * @internal\n     */\n    protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null): void\n    {\n        $list = [];\n        $header_query = null;\n        $header_default = null;\n\n        // do this header query work only once\n        if (strpos($order_by, 'header.') === 0) {\n            $query = explode('|', str_replace('header.', '', $order_by), 2);\n            $header_query = array_shift($query) ?? '';\n            $header_default = array_shift($query);\n        }\n\n        foreach ($pages as $key => $info) {\n            $child = $this->get($key);\n            if (!$child) {\n                throw new RuntimeException(\"Page does not exist: {$key}\");\n            }\n\n            switch ($order_by) {\n                case 'title':\n                    $list[$key] = $child->title();\n                    break;\n                case 'date':\n                    $list[$key] = $child->date();\n                    $sort_flags = SORT_REGULAR;\n                    break;\n                case 'modified':\n                    $list[$key] = $child->modified();\n                    $sort_flags = SORT_REGULAR;\n                    break;\n                case 'publish_date':\n                    $list[$key] = $child->publishDate();\n                    $sort_flags = SORT_REGULAR;\n                    break;\n                case 'unpublish_date':\n                    $list[$key] = $child->unpublishDate();\n                    $sort_flags = SORT_REGULAR;\n                    break;\n                case 'slug':\n                    $list[$key] = $child->slug();\n                    break;\n                case 'basename':\n                    $list[$key] = Utils::basename($key);\n                    break;\n                case 'folder':\n                    $list[$key] = $child->folder();\n                    break;\n                case 'manual':\n                case 'default':\n                default:\n                    if (is_string($header_query)) {\n                        $child_header = $child->header();\n                        if (!$child_header instanceof Header) {\n                            $child_header = new Header((array)$child_header);\n                        }\n                        $header_value = $child_header->get($header_query);\n                        if (is_array($header_value)) {\n                            $list[$key] = implode(',', $header_value);\n                        } elseif ($header_value) {\n                            $list[$key] = $header_value;\n                        } else {\n                            $list[$key] = $header_default ?: $key;\n                        }\n                        $sort_flags = $sort_flags ?: SORT_REGULAR;\n                        break;\n                    }\n                    $list[$key] = $key;\n                    $sort_flags = $sort_flags ?: SORT_REGULAR;\n            }\n        }\n\n        if (!$sort_flags) {\n            $sort_flags = SORT_NATURAL | SORT_FLAG_CASE;\n        }\n\n        // handle special case when order_by is random\n        if ($order_by === 'random') {\n            $list = $this->arrayShuffle($list);\n        } else {\n            // else just sort the list according to specified key\n            if (extension_loaded('intl') && $this->grav['config']->get('system.intl_enabled')) {\n                $locale = setlocale(LC_COLLATE, '0'); //`setlocale` with a '0' param returns the current locale set\n                $col = Collator::create($locale);\n                if ($col) {\n                    $col->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON);\n                    if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) {\n                        $list = preg_replace_callback('~([0-9]+)\\.~', static function ($number) {\n                            return sprintf('%032d.', $number[0]);\n                        }, $list);\n                        if (!is_array($list)) {\n                            throw new RuntimeException('Internal Error');\n                        }\n\n                        $list_vals = array_values($list);\n                        if (is_numeric(array_shift($list_vals))) {\n                            $sort_flags = Collator::SORT_REGULAR;\n                        } else {\n                            $sort_flags = Collator::SORT_STRING;\n                        }\n                    }\n\n                    $col->asort($list, $sort_flags);\n                } else {\n                    asort($list, $sort_flags);\n                }\n            } else {\n                asort($list, $sort_flags);\n            }\n        }\n\n\n        // Move manually ordered items into the beginning of the list. Order of the unlisted items does not change.\n        if (is_array($manual) && !empty($manual)) {\n            $new_list = [];\n            $i = count($manual);\n\n            foreach ($list as $key => $dummy) {\n                $info = $pages[$key];\n                $order = array_search($info['slug'], $manual, true);\n                if ($order === false) {\n                    $order = $i++;\n                }\n                $new_list[$key] = (int)$order;\n            }\n\n            $list = $new_list;\n\n            // Apply manual ordering to the list.\n            asort($list, SORT_NUMERIC);\n        }\n\n        foreach ($list as $key => $sort) {\n            $info = $pages[$key];\n            $this->sort[$path][$order_by][$key] = $info;\n        }\n    }\n\n    /**\n     * Shuffles an associative array\n     *\n     * @param array $list\n     * @return array\n     */\n    protected function arrayShuffle(array $list): array\n    {\n        $keys = array_keys($list);\n        shuffle($keys);\n\n        $new = [];\n        foreach ($keys as $key) {\n            $new[$key] = $list[$key];\n        }\n\n        return $new;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getVersion(): string\n    {\n        return $this->directory ? 'flex' : 'regular';\n    }\n\n    /**\n     * Get the Pages cache ID\n     *\n     * this is particularly useful to know if pages have changed and you want\n     * to sync another cache with pages cache - works best in `onPagesInitialized()`\n     *\n     * @return null|string\n     */\n    public function getPagesCacheId(): ?string\n    {\n        return $this->pages_cache_id;\n    }\n\n    /**\n     * Get the simple pages hash that is not md5 encoded, and isn't specific to language\n     *\n     * @return null|string\n     */\n    public function getSimplePagesHash(): ?string\n    {\n        return $this->simple_pages_hash;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Traits/PageFormTrait.php",
    "content": "<?php\n\nnamespace Grav\\Common\\Page\\Traits;\n\nuse Grav\\Common\\Grav;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse function is_array;\n\n/**\n * Trait PageFormTrait\n * @package Grav\\Common\\Page\\Traits\n */\ntrait PageFormTrait\n{\n    /** @var array|null */\n    private $_forms;\n\n    /**\n     * Return all the forms which are associated to this page.\n     *\n     * Forms are returned as [name => blueprint, ...], where blueprint follows the regular form blueprint format.\n     *\n     * @return array\n     */\n    public function getForms(): array\n    {\n        if (null === $this->_forms) {\n            $header = $this->header();\n\n            // Call event to allow filling the page header form dynamically (e.g. use case: Comments plugin)\n            $grav = Grav::instance();\n            $grav->fireEvent('onFormPageHeaderProcessed', new Event(['page' => $this, 'header' => $header]));\n\n            $rules = $header->rules ?? null;\n            if (!is_array($rules)) {\n                $rules = [];\n            }\n\n            $forms = [];\n\n            // First grab page.header.form\n            $form = $this->normalizeForm($header->form ?? null, null, $rules);\n            if ($form) {\n                $forms[$form['name']] = $form;\n            }\n\n            // Append page.header.forms (override singular form if it clashes)\n            $headerForms = $header->forms ?? null;\n            if (is_array($headerForms)) {\n                foreach ($headerForms as $name => $form) {\n                    $form = $this->normalizeForm($form, $name, $rules);\n                    if ($form) {\n                        $forms[$form['name']] = $form;\n                    }\n                }\n            }\n\n            $this->_forms = $forms;\n        }\n\n        return $this->_forms;\n    }\n\n    /**\n     * Add forms to this page.\n     *\n     * @param array $new\n     * @param bool $override\n     * @return $this\n     */\n    public function addForms(array $new, $override = true)\n    {\n        // Initialize forms.\n        $this->forms();\n\n        foreach ($new as $name => $form) {\n            $form = $this->normalizeForm($form, $name);\n            $name = $form['name'] ?? null;\n            if ($name && ($override || !isset($this->_forms[$name]))) {\n                $this->_forms[$name] = $form;\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * Alias of $this->getForms();\n     *\n     * @return array\n     */\n    public function forms(): array\n    {\n        return $this->getForms();\n    }\n\n    /**\n     * @param array|null $form\n     * @param string|null $name\n     * @param array $rules\n     * @return array|null\n     */\n    protected function normalizeForm($form, $name = null, array $rules = []): ?array\n    {\n        if (!is_array($form)) {\n            return null;\n        }\n\n        // Ignore numeric indexes on name.\n        if (!$name || (string)(int)$name === (string)$name) {\n            $name = null;\n        }\n\n        $name = $name ?? $form['name'] ?? $this->slug();\n\n        $formRules = $form['rules'] ?? null;\n        if (!is_array($formRules)) {\n            $formRules = [];\n        }\n\n        return ['name' => $name, 'rules' => $rules + $formRules] + $form;\n    }\n\n    abstract public function header($var = null);\n    abstract public function slug($var = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Page/Types.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Page\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Page;\n\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse InvalidArgumentException;\nuse RocketTheme\\Toolbox\\ArrayTraits\\ArrayAccess;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Constructor;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Countable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Export;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Iterator;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function is_string;\n\n/**\n * Class Types\n * @package Grav\\Common\\Page\n */\nclass Types implements \\ArrayAccess, \\Iterator, \\Countable\n{\n    use ArrayAccess, Constructor, Iterator, Countable, Export;\n\n    /** @var array */\n    protected $items;\n    /** @var array */\n    protected $systemBlueprints = [];\n\n    /**\n     * @param string $type\n     * @param Blueprint|null $blueprint\n     * @return void\n     */\n    public function register($type, $blueprint = null)\n    {\n        if (!isset($this->items[$type])) {\n            $this->items[$type] = [];\n        } elseif (null === $blueprint) {\n            return;\n        }\n\n        if (null === $blueprint) {\n            $blueprint = $this->systemBlueprints[$type] ?? $this->systemBlueprints['default'] ?? null;\n        }\n\n        if ($blueprint) {\n            array_unshift($this->items[$type], $blueprint);\n        }\n    }\n\n    /**\n     * @return void\n     */\n    public function init()\n    {\n        if (empty($this->systemBlueprints)) {\n            // Register all blueprints from the blueprints stream.\n            $this->systemBlueprints = $this->findBlueprints('blueprints://pages');\n            foreach ($this->systemBlueprints as $type => $blueprint) {\n                $this->register($type);\n            }\n        }\n    }\n\n    /**\n     * @param string $uri\n     * @return void\n     */\n    public function scanBlueprints($uri)\n    {\n        if (!is_string($uri)) {\n            throw new InvalidArgumentException('First parameter must be URI');\n        }\n\n        foreach ($this->findBlueprints($uri) as $type => $blueprint) {\n            $this->register($type, $blueprint);\n        }\n    }\n\n    /**\n     * @param string $uri\n     * @return void\n     */\n    public function scanTemplates($uri)\n    {\n        if (!is_string($uri)) {\n            throw new InvalidArgumentException('First parameter must be URI');\n        }\n\n        $options = [\n            'compare' => 'Filename',\n            'pattern' => '|\\.html\\.twig$|',\n            'filters' => [\n                'value' => '|\\.html\\.twig$|'\n            ],\n            'value' => 'Filename',\n            'recursive' => false\n        ];\n\n        foreach (Folder::all($uri, $options) as $type) {\n            $this->register($type);\n        }\n\n        $modular_uri = rtrim($uri, '/') . '/modular';\n        if (is_dir($modular_uri)) {\n            foreach (Folder::all($modular_uri, $options) as $type) {\n                $this->register('modular/' . $type);\n            }\n        }\n    }\n\n    /**\n     * @return array\n     */\n    public function pageSelect()\n    {\n        $list = [];\n        foreach ($this->items as $name => $file) {\n            if (strpos($name, '/')) {\n                continue;\n            }\n            $list[$name] = ucfirst(str_replace('_', ' ', $name));\n        }\n        ksort($list);\n\n        return $list;\n    }\n\n    /**\n     * @return array\n     */\n    public function modularSelect()\n    {\n        $list = [];\n        foreach ($this->items as $name => $file) {\n            if (strpos($name, 'modular/') !== 0) {\n                continue;\n            }\n            $list[$name] = ucfirst(trim(str_replace('_', ' ', Utils::basename($name))));\n        }\n        ksort($list);\n\n        return $list;\n    }\n\n    /**\n     * @param string $uri\n     * @return array\n     */\n    private function findBlueprints($uri)\n    {\n        $options = [\n            'compare' => 'Filename',\n            'pattern' => '|\\.yaml$|',\n            'filters' => [\n                'key' => '|\\.yaml$|'\n                ],\n            'key' => 'SubPathName',\n            'value' => 'PathName',\n        ];\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        if ($locator->isStream($uri)) {\n            $options['value'] = 'Url';\n        }\n\n        return Folder::all($uri, $options);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Plugin.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse ArrayAccess;\nuse Composer\\Autoload\\ClassLoader;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Config\\Config;\nuse LogicException;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse function defined;\nuse function is_bool;\nuse function is_string;\n\n/**\n * Class Plugin\n * @package Grav\\Common\n */\nclass Plugin implements EventSubscriberInterface, ArrayAccess\n{\n    /** @var string */\n    public $name;\n    /** @var array */\n    public $features = [];\n\n    /** @var Grav */\n    protected $grav;\n    /** @var Config|null */\n    protected $config;\n    /** @var bool */\n    protected $active = true;\n    /** @var Blueprint|null */\n    protected $blueprint;\n    /** @var ClassLoader|null */\n    protected $loader;\n\n    /**\n     * By default assign all methods as listeners using the default priority.\n     *\n     * @return array\n     */\n    public static function getSubscribedEvents()\n    {\n        $methods = get_class_methods(static::class);\n\n        $list = [];\n        foreach ($methods as $method) {\n            if (strpos($method, 'on') === 0) {\n                $list[$method] = [$method, 0];\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * Constructor.\n     *\n     * @param string $name\n     * @param Grav   $grav\n     * @param Config|null $config\n     */\n    public function __construct($name, Grav $grav, Config $config = null)\n    {\n        $this->name = $name;\n        $this->grav = $grav;\n\n        if ($config) {\n            $this->setConfig($config);\n        }\n    }\n\n    /**\n     * @return ClassLoader|null\n     * @internal\n     */\n    final public function getAutoloader(): ?ClassLoader\n    {\n        return $this->loader;\n    }\n\n    /**\n     * @param ClassLoader|null $loader\n     * @internal\n     */\n    final public function setAutoloader(?ClassLoader $loader): void\n    {\n        $this->loader = $loader;\n    }\n\n    /**\n     * @param Config $config\n     * @return $this\n     */\n    public function setConfig(Config $config)\n    {\n        $this->config = $config;\n\n        return $this;\n    }\n\n    /**\n     * Get configuration of the plugin.\n     *\n     * @return array\n     */\n    public function config()\n    {\n        return $this->config[\"plugins.{$this->name}\"] ?? [];\n    }\n\n    /**\n     * Determine if plugin is running under the admin\n     *\n     * @return bool\n     */\n    public function isAdmin()\n    {\n        return Utils::isAdminPlugin();\n    }\n\n    /**\n     * Determine if plugin is running under the CLI\n     *\n     * @return bool\n     */\n    public function isCli()\n    {\n        return defined('GRAV_CLI');\n    }\n\n    /**\n     * Determine if this route is in Admin and active for the plugin\n     *\n     * @param string $plugin_route\n     * @return bool\n     */\n    protected function isPluginActiveAdmin($plugin_route)\n    {\n        $active = false;\n\n        /** @var Uri $uri */\n        $uri = $this->grav['uri'];\n        /** @var Config $config */\n        $config = $this->config ?? $this->grav['config'];\n\n        if (strpos($uri->path(), $config->get('plugins.admin.route') . '/' . $plugin_route) === false) {\n            $active = false;\n        } elseif (isset($uri->paths()[1]) && $uri->paths()[1] === $plugin_route) {\n            $active = true;\n        }\n\n        return $active;\n    }\n\n    /**\n     * @param array $events\n     * @return void\n     */\n    protected function enable(array $events)\n    {\n        /** @var EventDispatcher $dispatcher */\n        $dispatcher = $this->grav['events'];\n\n        foreach ($events as $eventName => $params) {\n            if (is_string($params)) {\n                $dispatcher->addListener($eventName, [$this, $params]);\n            } elseif (is_string($params[0])) {\n                $dispatcher->addListener($eventName, [$this, $params[0]], $this->getPriority($params, $eventName));\n            } else {\n                foreach ($params as $listener) {\n                    $dispatcher->addListener($eventName, [$this, $listener[0]], $this->getPriority($listener, $eventName));\n                }\n            }\n        }\n    }\n\n    /**\n     * @param array  $params\n     * @param string $eventName\n     * @return int\n     */\n    private function getPriority($params, $eventName)\n    {\n        $override = implode('.', ['priorities', $this->name, $eventName, $params[0]]);\n\n        return $this->grav['config']->get($override) ?? $params[1] ?? 0;\n    }\n\n    /**\n     * @param array $events\n     * @return void\n     */\n    protected function disable(array $events)\n    {\n        /** @var EventDispatcher $dispatcher */\n        $dispatcher = $this->grav['events'];\n\n        foreach ($events as $eventName => $params) {\n            if (is_string($params)) {\n                $dispatcher->removeListener($eventName, [$this, $params]);\n            } elseif (is_string($params[0])) {\n                $dispatcher->removeListener($eventName, [$this, $params[0]]);\n            } else {\n                foreach ($params as $listener) {\n                    $dispatcher->removeListener($eventName, [$this, $listener[0]]);\n                }\n            }\n        }\n    }\n\n    /**\n     * Whether or not an offset exists.\n     *\n     * @param string $offset  An offset to check for.\n     * @return bool          Returns TRUE on success or FALSE on failure.\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset)\n    {\n        if ($offset === 'title') {\n            $offset = 'name';\n        }\n\n        $blueprint = $this->getBlueprint();\n\n        return isset($blueprint[$offset]);\n    }\n\n    /**\n     * Returns the value at specified offset.\n     *\n     * @param string $offset  The offset to retrieve.\n     * @return mixed         Can return all value types.\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        if ($offset === 'title') {\n            $offset = 'name';\n        }\n\n        $blueprint = $this->getBlueprint();\n\n        return $blueprint[$offset] ?? null;\n    }\n\n    /**\n     * Assigns a value to the specified offset.\n     *\n     * @param string $offset  The offset to assign the value to.\n     * @param mixed $value   The value to set.\n     * @throws LogicException\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetSet($offset, $value)\n    {\n        throw new LogicException(__CLASS__ . ' blueprints cannot be modified.');\n    }\n\n    /**\n     * Unsets an offset.\n     *\n     * @param string $offset  The offset to unset.\n     * @throws LogicException\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetUnset($offset)\n    {\n        throw new LogicException(__CLASS__ . ' blueprints cannot be modified.');\n    }\n\n    /**\n     * @return array\n     */\n    public function __debugInfo(): array\n    {\n        $array = (array)$this;\n\n        unset($array[\"\\0*\\0grav\"]);\n        $array[\"\\0*\\0config\"] = $this->config();\n\n        return $array;\n    }\n\n    /**\n     * This function will search a string for markdown links in a specific format.  The link value can be\n     * optionally compared against via the $internal_regex and operated on by the callback $function\n     * provided.\n     *\n     * format: [plugin:myplugin_name](function_data)\n     *\n     * @param string   $content        The string to perform operations upon\n     * @param callable $function       The anonymous callback function\n     * @param string   $internal_regex Optional internal regex to extra data from\n     * @return string\n     */\n    protected function parseLinks($content, $function, $internal_regex = '(.*)')\n    {\n        $regex = '/\\[plugin:(?:' . preg_quote($this->name, '/') . ')\\]\\(' . $internal_regex . '\\)/i';\n\n        $result = preg_replace_callback($regex, $function, $content);\n        \\assert($result !== null);\n\n        return $result;\n    }\n\n    /**\n     * Merge global and page configurations.\n     *\n     * WARNING: This method modifies page header!\n     *\n     * @param PageInterface $page The page to merge the configurations with the\n     *                       plugin settings.\n     * @param mixed $deep false = shallow|true = recursive|merge = recursive+unique\n     * @param array $params Array of additional configuration options to\n     *                       merge with the plugin settings.\n     * @param string $type Is this 'plugins' or 'themes'\n     * @return Data\n     */\n    protected function mergeConfig(PageInterface $page, $deep = false, $params = [], $type = 'plugins')\n    {\n        /** @var Config $config */\n        $config = $this->config ?? $this->grav['config'];\n\n        $class_name = $this->name;\n        $class_name_merged = $class_name . '.merged';\n        $defaults = $config->get($type . '.' . $class_name, []);\n        $page_header = $page->header();\n        $header = [];\n\n        if (!isset($page_header->{$class_name_merged}) && isset($page_header->{$class_name})) {\n            // Get default plugin configurations and retrieve page header configuration\n            $config = $page_header->{$class_name};\n            if (is_bool($config)) {\n                // Overwrite enabled option with boolean value in page header\n                $config = ['enabled' => $config];\n            }\n            // Merge page header settings using deep or shallow merging technique\n            $header = $this->mergeArrays($deep, $defaults, $config);\n\n            // Create new config object and set it on the page object so it's cached for next time\n            $page->modifyHeader($class_name_merged, new Data($header));\n        } elseif (isset($page_header->{$class_name_merged})) {\n            $merged = $page_header->{$class_name_merged};\n            $header = $merged->toArray();\n        }\n        if (empty($header)) {\n            $header = $defaults;\n        }\n        // Merge additional parameter with configuration options\n        $header = $this->mergeArrays($deep, $header, $params);\n\n        // Return configurations as a new data config class\n        return new Data($header);\n    }\n\n    /**\n     * Merge arrays based on deepness\n     *\n     * @param string|bool $deep\n     * @param array $array1\n     * @param array $array2\n     * @return array\n     */\n    private function mergeArrays($deep, $array1, $array2)\n    {\n        if ($deep === 'merge') {\n            return Utils::arrayMergeRecursiveUnique($array1, $array2);\n        }\n        if ($deep === true) {\n            return array_replace_recursive($array1, $array2);\n        }\n\n        return array_merge($array1, $array2);\n    }\n\n    /**\n     * Persists to disk the plugin parameters currently stored in the Grav Config object\n     *\n     * @param string $name The name of the plugin whose config it should store.\n     * @return bool\n     */\n    public static function saveConfig($name)\n    {\n        if (!$name) {\n            return false;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        $filename = 'config://plugins/' . $name . '.yaml';\n        $file = YamlFile::instance((string)$locator->findResource($filename, true, true));\n        $content = $grav['config']->get('plugins.' . $name);\n        $file->save($content);\n        $file->free();\n        unset($file);\n\n        return true;\n    }\n\n    public static function inheritedConfigOption(string $plugin, string $var, PageInterface $page = null, $default = null)\n    {\n        if (Utils::isAdminPlugin()) {\n            $page = Grav::instance()['admin']->page() ?? null;\n        } else {\n            $page = $page ?? Grav::instance()['page'] ?? null;\n        }\n\n        // Try to find var in the page headers\n        if ($page instanceof PageInterface && $page->exists()) {\n            // Loop over pages and look for header vars\n            while ($page && !$page->root()) {\n                $header = new Data((array)$page->header());\n                $value = $header->get(\"$plugin.$var\");\n                if (isset($value)) {\n                    return $value;\n                }\n                $page = $page->parent();\n            }\n        }\n\n        return Grav::instance()['config']->get(\"plugins.$plugin.$var\", $default);\n    }\n\n    /**\n     * Simpler getter for the plugin blueprint\n     *\n     * @return Blueprint\n     */\n    public function getBlueprint()\n    {\n        if (null === $this->blueprint) {\n            $this->loadBlueprint();\n            \\assert($this->blueprint instanceof Blueprint);\n        }\n\n        return $this->blueprint;\n    }\n\n    /**\n     * Load blueprints.\n     *\n     * @return void\n     */\n    protected function loadBlueprint()\n    {\n        if (null === $this->blueprint) {\n            $grav = Grav::instance();\n            /** @var Plugins $plugins */\n            $plugins = $grav['plugins'];\n            $data = $plugins->get($this->name);\n            \\assert($data !== null);\n            $this->blueprint = $data->blueprints();\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Plugins.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Exception;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Blueprints;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Events\\PluginsLoadedEvent;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse SplFileInfo;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\nuse function get_class;\nuse function is_object;\n\n/**\n * Class Plugins\n * @package Grav\\Common\n */\nclass Plugins extends Iterator\n{\n    /** @var array|null */\n    public $formFieldTypes;\n\n    /** @var bool */\n    private $plugins_initialized = false;\n\n    /**\n     * Plugins constructor.\n     */\n    public function __construct()\n    {\n        parent::__construct();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n\n        $iterator = $locator->getIterator('plugins://');\n\n        $plugins = [];\n        /** @var SplFileInfo $directory */\n        foreach ($iterator as $directory) {\n            if (!$directory->isDir()) {\n                continue;\n            }\n            $plugins[] = $directory->getFilename();\n        }\n\n        sort($plugins, SORT_NATURAL | SORT_FLAG_CASE);\n\n        foreach ($plugins as $plugin) {\n            $object = $this->loadPlugin($plugin);\n            if ($object) {\n                $this->add($object);\n            }\n        }\n    }\n\n    /**\n     * @return $this\n     */\n    public function setup()\n    {\n        $blueprints = [];\n        $formFields = [];\n\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        /** @var Plugin $plugin */\n        foreach ($this->items as $plugin) {\n            // Setup only enabled plugins.\n            if ($config[\"plugins.{$plugin->name}.enabled\"] && $plugin instanceof Plugin) {\n                if (isset($plugin->features['blueprints'])) {\n                    $blueprints[\"plugin://{$plugin->name}/blueprints\"] = $plugin->features['blueprints'];\n                }\n                if (method_exists($plugin, 'getFormFieldTypes')) {\n                    $formFields[get_class($plugin)] = $plugin->features['formfields'] ?? 0;\n                }\n            }\n        }\n\n        if ($blueprints) {\n            // Order by priority.\n            arsort($blueprints, SORT_NUMERIC);\n\n            /** @var UniformResourceLocator $locator */\n            $locator = $grav['locator'];\n            $locator->addPath('blueprints', '', array_keys($blueprints), ['system', 'blueprints']);\n        }\n\n        if ($formFields) {\n            // Order by priority.\n            arsort($formFields, SORT_NUMERIC);\n\n            $list = [];\n            foreach ($formFields as $className => $priority) {\n                $plugin = $this->items[$className];\n                $list += $plugin->getFormFieldTypes();\n            }\n\n            $this->formFieldTypes = $list;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Registers all plugins.\n     *\n     * @return Plugin[] array of Plugin objects\n     * @throws RuntimeException\n     */\n    public function init()\n    {\n        if ($this->plugins_initialized)  {\n            return $this->items;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        /** @var EventDispatcher $events */\n        $events = $grav['events'];\n\n        foreach ($this->items as $instance) {\n            // Register only enabled plugins.\n            if ($config[\"plugins.{$instance->name}.enabled\"] && $instance instanceof Plugin) {\n                // Set plugin configuration.\n                $instance->setConfig($config);\n                // Register autoloader.\n                if (method_exists($instance, 'autoload')) {\n                    $instance->setAutoloader($instance->autoload());\n                }\n                // Register event listeners.\n                $events->addSubscriber($instance);\n            }\n        }\n\n        // Plugins Loaded Event\n        $event = new PluginsLoadedEvent($grav, $this);\n        $grav->dispatchEvent($event);\n\n        $this->plugins_initialized = true;\n\n        return $this->items;\n    }\n\n    /**\n     * Add a plugin\n     *\n     * @param Plugin $plugin\n     * @return void\n     */\n    public function add($plugin)\n    {\n        if (is_object($plugin)) {\n            $this->items[get_class($plugin)] = $plugin;\n        }\n    }\n\n    /**\n     * @return array\n     */\n    public function __debugInfo(): array\n    {\n        $array = (array)$this;\n\n        unset($array[\"\\0Grav\\Common\\Iterator\\0iteratorUnset\"]);\n\n        return $array;\n    }\n\n    /**\n     * @return Plugin[] Index of all plugins by plugin name.\n     */\n    public static function getPlugins(): array\n    {\n        /** @var Plugins $plugins */\n        $plugins = Grav::instance()['plugins'];\n\n        $list = [];\n        foreach ($plugins as $instance) {\n            $list[$instance->name] = $instance;\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $name Plugin name\n     * @return Plugin|null Plugin object or null if plugin cannot be found.\n     */\n    public static function getPlugin(string $name)\n    {\n        $list = static::getPlugins();\n\n        return $list[$name] ?? null;\n    }\n\n    /**\n     * Return list of all plugin data with their blueprints.\n     *\n     * @return Data[]\n     */\n    public static function all()\n    {\n        $grav = Grav::instance();\n\n        /** @var Plugins $plugins */\n        $plugins = $grav['plugins'];\n        $list = [];\n\n        foreach ($plugins as $instance) {\n            $name = $instance->name;\n\n            try {\n                $result = self::get($name);\n            } catch (Exception $e) {\n                $exception = new RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e);\n\n                /** @var Debugger $debugger */\n                $debugger = $grav['debugger'];\n                $debugger->addMessage(\"Plugin {$name} cannot be loaded, please check Exceptions tab\", 'error');\n                $debugger->addException($exception);\n\n                continue;\n            }\n\n            if ($result) {\n                $list[$name] = $result;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * Get a plugin by name\n     *\n     * @param string $name\n     * @return Data|null\n     */\n    public static function get($name)\n    {\n        $blueprints = new Blueprints('plugins://');\n        $blueprint = $blueprints->get(\"{$name}/blueprints\");\n\n        // Load default configuration.\n        $file = CompiledYamlFile::instance(\"plugins://{$name}/{$name}\" . YAML_EXT);\n\n        // ensure this is a valid plugin\n        if (!$file->exists()) {\n            return null;\n        }\n\n        $obj = new Data((array)$file->content(), $blueprint);\n\n        // Override with user configuration.\n        $obj->merge(Grav::instance()['config']->get('plugins.' . $name) ?: []);\n\n        // Save configuration always to user/config.\n        $file = CompiledYamlFile::instance(\"config://plugins/{$name}.yaml\");\n        $obj->file($file);\n\n        return $obj;\n    }\n\n    /**\n     * @param string $name\n     * @return Plugin|null\n     */\n    protected function loadPlugin($name)\n    {\n        // NOTE: ALL THE LOCAL VARIABLES ARE USED INSIDE INCLUDED FILE, DO NOT REMOVE THEM!\n        $grav = Grav::instance();\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n        $class = null;\n\n        // Start by attempting to load the plugin_name.php file.\n        $file = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT);\n        if (is_file($file)) {\n            // Local variables available in the file: $grav, $name, $file\n            $class = include_once $file;\n            if (!is_object($class) || !is_subclass_of($class, Plugin::class, true)) {\n                $class = null;\n            }\n        }\n\n        // If the class hasn't been initialized yet, guess the class name and create a new instance.\n        if (null === $class) {\n            $className = Inflector::camelize($name);\n            $pluginClassFormat = [\n                'Grav\\\\Plugin\\\\' . ucfirst($name). 'Plugin',\n                'Grav\\\\Plugin\\\\' . $className . 'Plugin',\n                'Grav\\\\Plugin\\\\' . $className\n            ];\n\n            foreach ($pluginClassFormat as $pluginClass) {\n                if (is_subclass_of($pluginClass, Plugin::class, true)) {\n                    $class = new $pluginClass($name, $grav);\n                    break;\n                }\n            }\n        }\n\n        // Log a warning if plugin cannot be found.\n        if (null === $class) {\n            $grav['log']->addWarning(\n                sprintf(\"Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clearcache`\", $name)\n            );\n        }\n\n        return $class;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/AssetsProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class AssetsProcessor\n * @package Grav\\Common\\Processors\n */\nclass AssetsProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = '_assets';\n    /** @var string */\n    public $title = 'Assets';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n        $this->container['assets']->init();\n        $this->container->fireEvent('onAssetsInitialized');\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/BackupsProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class BackupsProcessor\n * @package Grav\\Common\\Processors\n */\nclass BackupsProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = '_backups';\n    /** @var string */\n    public $title = 'Backups';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n        $backups = $this->container['backups'];\n        $backups->init();\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class DebuggerAssetsProcessor\n * @package Grav\\Common\\Processors\n */\nclass DebuggerAssetsProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = 'debugger_assets';\n    /** @var string */\n    public $title = 'Debugger Assets';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n        $this->container['debugger']->addAssets();\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/Events/RequestHandlerEvent.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors\\Events;\n\nuse Grav\\Framework\\RequestHandler\\RequestHandler;\nuse Grav\\Framework\\Route\\Route;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\MiddlewareInterface;\nuse RocketTheme\\Toolbox\\Event\\Event;\n\n/**\n * Class RequestHandlerEvent\n * @package Grav\\Common\\Processors\\Events\n */\nclass RequestHandlerEvent extends Event\n{\n    /**\n     * @return ServerRequestInterface\n     */\n    public function getRequest(): ServerRequestInterface\n    {\n        return $this->offsetGet('request');\n    }\n\n    /**\n     * @return Route\n     */\n    public function getRoute(): Route\n    {\n        return $this->getRequest()->getAttribute('route');\n    }\n\n    /**\n     * @return RequestHandler\n     */\n    public function getHandler(): RequestHandler\n    {\n        return $this->offsetGet('handler');\n    }\n\n    /**\n     * @return ResponseInterface|null\n     */\n    public function getResponse(): ?ResponseInterface\n    {\n        return $this->offsetGet('response');\n    }\n\n    /**\n     * @param ResponseInterface $response\n     * @return $this\n     */\n    public function setResponse(ResponseInterface $response): self\n    {\n        $this->offsetSet('response', $response);\n        $this->stopPropagation();\n\n        return $this;\n    }\n\n    /**\n     * @param string $name\n     * @param MiddlewareInterface $middleware\n     * @return RequestHandlerEvent\n     */\n    public function addMiddleware(string $name, MiddlewareInterface $middleware): self\n    {\n        /** @var RequestHandler $handler */\n        $handler = $this['handler'];\n        $handler->addMiddleware($name, $middleware);\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/InitializeProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Errors\\Errors;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Plugins;\nuse Grav\\Common\\Session;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\File\\Formatter\\YamlFormatter;\nuse Grav\\Framework\\File\\YamlFile;\nuse Grav\\Framework\\Psr7\\Response;\nuse Grav\\Framework\\Session\\Exceptions\\SessionException;\nuse Monolog\\Formatter\\LineFormatter;\nuse Monolog\\Handler\\SyslogHandler;\nuse Monolog\\Logger;\nuse Psr\\Http\\Message\\RequestInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\nuse function defined;\nuse function in_array;\n\n/**\n * Class InitializeProcessor\n * @package Grav\\Common\\Processors\n */\nclass InitializeProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = '_init';\n    /** @var string */\n    public $title = 'Initialize';\n\n    /** @var bool */\n    protected static $cli_initialized = false;\n\n    /**\n     * @param Grav $grav\n     * @return void\n     */\n    public static function initializeCli(Grav $grav)\n    {\n        if (!static::$cli_initialized) {\n            static::$cli_initialized = true;\n\n            $instance = new static($grav);\n            $instance->processCli();\n        }\n    }\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer('_init', 'Initialize');\n\n        // Load configuration.\n        $config = $this->initializeConfig();\n\n        // Initialize logger.\n        $this->initializeLogger($config);\n\n        // Initialize error handlers.\n        $this->initializeErrors();\n\n        // Initialize debugger.\n        $debugger = $this->initializeDebugger();\n\n        // Debugger can return response right away.\n        $response = $this->handleDebuggerRequest($debugger, $request);\n        if ($response) {\n            $this->stopTimer('_init');\n\n            return $response;\n        }\n\n        // Initialize output buffering.\n        $this->initializeOutputBuffering($config);\n\n        // Set timezone, locale.\n        $this->initializeLocale($config);\n\n        // Load plugins.\n        $this->initializePlugins();\n\n        // Load pages.\n        $this->initializePages($config);\n\n        // Load accounts (decides class to be used).\n        // TODO: remove in 2.0.\n        $this->container['accounts'];\n\n        // Initialize session (used by URI, see issue #3269).\n        $this->initializeSession($config);\n\n        // Initialize URI (uses session, see issue #3269).\n        $this->initializeUri($config);\n\n        // Grav may return redirect response right away.\n        $redirectCode = (int)$config->get('system.pages.redirect_trailing_slash', 1);\n        if ($redirectCode) {\n            $response = $this->handleRedirectRequest($request, $redirectCode > 300 ? $redirectCode : null);\n            if ($response) {\n                $this->stopTimer('_init');\n\n                return $response;\n            }\n        }\n\n        $this->stopTimer('_init');\n\n        // Wrap call to next handler so that debugger can profile it.\n        /** @var Response $response */\n        $response = $debugger->profile(static function () use ($handler, $request) {\n            return $handler->handle($request);\n        });\n\n        // Log both request and response and return the response.\n        return $debugger->logRequest($request, $response);\n    }\n\n    public function processCli(): void\n    {\n        // Load configuration.\n        $config = $this->initializeConfig();\n\n        // Initialize logger.\n        $this->initializeLogger($config);\n\n        // Disable debugger.\n        $this->container['debugger']->enabled(false);\n\n        // Set timezone, locale.\n        $this->initializeLocale($config);\n\n        // Load plugins.\n        $this->initializePlugins();\n\n        // Load pages.\n        $this->initializePages($config);\n\n        // Initialize URI.\n        $this->initializeUri($config);\n\n        // Load accounts (decides class to be used).\n        // TODO: remove in 2.0.\n        $this->container['accounts'];\n    }\n\n    /**\n     * @return Config\n     */\n    protected function initializeConfig(): Config\n    {\n        $this->startTimer('_init_config', 'Configuration');\n\n        // Initialize Configuration\n        $grav = $this->container;\n\n        /** @var Config $config */\n        $config = $grav['config'];\n        $config->init();\n        $grav['plugins']->setup();\n\n        if (defined('GRAV_SCHEMA') && $config->get('versions') === null) {\n            $filename = USER_DIR . 'config/versions.yaml';\n            if (!is_file($filename)) {\n                $versions = [\n                    'core' => [\n                        'grav' => [\n                            'version' => GRAV_VERSION,\n                            'schema' => GRAV_SCHEMA\n                        ]\n                    ]\n                ];\n                $config->set('versions', $versions);\n\n                $file = new YamlFile($filename, new YamlFormatter(['inline' => 4]));\n                $file->save($versions);\n            }\n        }\n\n        // Override configuration using the environment.\n        $prefix = 'GRAV_CONFIG';\n        $env = getenv($prefix);\n        if ($env) {\n            $cPrefix = $prefix . '__';\n            $aPrefix = $prefix . '_ALIAS__';\n            $cLen = strlen($cPrefix);\n            $aLen = strlen($aPrefix);\n\n            $keys = $aliases = [];\n            $env = $_ENV + $_SERVER;\n            foreach ($env as $key => $value) {\n                if (!str_starts_with($key, $prefix)) {\n                    continue;\n                }\n                if (str_starts_with($key, $cPrefix)) {\n                    $key = str_replace('__', '.', substr($key, $cLen));\n                    $keys[$key] = $value;\n                } elseif (str_starts_with($key, $aPrefix)) {\n                    $key = substr($key, $aLen);\n                    $aliases[$key] = $value;\n                }\n            }\n            $list = [];\n            foreach ($keys as $key => $value) {\n                foreach ($aliases as $alias => $real) {\n                    $key = str_replace($alias, $real, $key);\n                }\n                $list[$key] = $value;\n                $config->set($key, $value);\n            }\n        }\n\n        $this->stopTimer('_init_config');\n\n        return $config;\n    }\n\n    /**\n     * @param Config $config\n     * @return Logger\n     */\n    protected function initializeLogger(Config $config): Logger\n    {\n        $this->startTimer('_init_logger', 'Logger');\n\n        $grav = $this->container;\n\n        // Initialize Logging\n        /** @var Logger $log */\n        $log = $grav['log'];\n\n        if ($config->get('system.log.handler', 'file') === 'syslog') {\n            $log->popHandler();\n\n            $facility = $config->get('system.log.syslog.facility', 'local6');\n            $tag = $config->get('system.log.syslog.tag', 'grav');\n            $logHandler = new SyslogHandler($tag, $facility);\n            $formatter = new LineFormatter(\"%channel%.%level_name%: %message% %extra%\");\n            $logHandler->setFormatter($formatter);\n\n            $log->pushHandler($logHandler);\n        }\n\n        $this->stopTimer('_init_logger');\n\n        return $log;\n    }\n\n    /**\n     * @return Errors\n     */\n    protected function initializeErrors(): Errors\n    {\n        $this->startTimer('_init_errors', 'Error Handlers Reset');\n\n        $grav = $this->container;\n\n        // Initialize Error Handlers\n        /** @var Errors $errors */\n        $errors = $grav['errors'];\n        $errors->resetHandlers();\n\n        $this->stopTimer('_init_errors');\n\n        return $errors;\n    }\n\n    /**\n     * @return Debugger\n     */\n    protected function initializeDebugger(): Debugger\n    {\n        $this->startTimer('_init_debugger', 'Init Debugger');\n\n        $grav = $this->container;\n\n        /** @var Debugger $debugger */\n        $debugger = $grav['debugger'];\n        $debugger->init();\n\n        $this->stopTimer('_init_debugger');\n\n        return $debugger;\n    }\n\n    /**\n     * @param Debugger $debugger\n     * @param ServerRequestInterface $request\n     * @return ResponseInterface|null\n     */\n    protected function handleDebuggerRequest(Debugger $debugger, ServerRequestInterface $request): ?ResponseInterface\n    {\n        // Clockwork integration.\n        $clockwork = $debugger->getClockwork();\n        if ($clockwork) {\n            $server = $request->getServerParams();\n//            $baseUri = str_replace('\\\\', '/', dirname(parse_url($server['SCRIPT_NAME'], PHP_URL_PATH)));\n//            if ($baseUri === '/') {\n//                $baseUri = '';\n//            }\n            $requestTime = $server['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME;\n\n            $request = $request->withAttribute('request_time', $requestTime);\n\n            // Handle clockwork API calls.\n            $uri = $request->getUri();\n            if (Utils::contains($uri->getPath(), '/__clockwork/')) {\n                return $debugger->debuggerRequest($request);\n            }\n\n            $this->container['clockwork'] = $clockwork;\n        }\n\n        return null;\n    }\n\n    /**\n     * @param Config $config\n     */\n    protected function initializeOutputBuffering(Config $config): void\n    {\n        $this->startTimer('_init_ob', 'Initialize Output Buffering');\n\n        // Use output buffering to prevent headers from being sent too early.\n        ob_start();\n        if ($config->get('system.cache.gzip') && !@ob_start('ob_gzhandler')) {\n            // Enable zip/deflate with a fallback in case of if browser does not support compressing.\n            ob_start();\n        }\n\n        $this->stopTimer('_init_ob');\n    }\n\n    /**\n     * @param Config $config\n     */\n    protected function initializeLocale(Config $config): void\n    {\n        $this->startTimer('_init_locale', 'Initialize Locale');\n\n        // Initialize the timezone.\n        $timezone = $config->get('system.timezone');\n        if ($timezone) {\n            date_default_timezone_set($timezone);\n        }\n\n        $grav = $this->container;\n        $grav->setLocale();\n\n        $this->stopTimer('_init_locale');\n    }\n\n    protected function initializePlugins(): Plugins\n    {\n        $this->startTimer('_init_plugins_load', 'Load Plugins');\n\n        $grav = $this->container;\n\n        /** @var Plugins $plugins */\n        $plugins = $grav['plugins'];\n        $plugins->init();\n\n        $this->stopTimer('_init_plugins_load');\n\n        return $plugins;\n    }\n\n    protected function initializePages(Config $config): Pages\n    {\n        $this->startTimer('_init_pages_register', 'Load Pages');\n\n        $grav = $this->container;\n\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n        // Upgrading from older Grav versions won't work without checking if the method exists.\n        if (method_exists($pages, 'register')) {\n            $pages->register();\n        }\n\n        $this->stopTimer('_init_pages_register');\n\n        return $pages;\n    }\n\n\n    protected function initializeUri(Config $config): void\n    {\n        $this->startTimer('_init_uri', 'Initialize URI');\n\n        $grav = $this->container;\n\n        /** @var Uri $uri */\n        $uri = $grav['uri'];\n        $uri->init();\n\n        $this->stopTimer('_init_uri');\n    }\n\n    protected function handleRedirectRequest(RequestInterface $request, int $code = null): ?ResponseInterface\n    {\n        if (!in_array($request->getMethod(), ['GET', 'HEAD'])) {\n            return null;\n        }\n\n        // Redirect pages with trailing slash if configured to do so.\n        $uri = $request->getUri();\n        $path = $uri->getPath() ?: '/';\n        $root = $this->container['uri']->rootUrl();\n\n        if ($path !== $root && $path !== $root . '/' && Utils::endsWith($path, '/')) {\n            // Use permanent redirect for SEO reasons.\n            return $this->container->getRedirectResponse((string)$uri->withPath(rtrim($path, '/')), $code);\n        }\n\n        return null;\n    }\n\n    /**\n     * @param Config $config\n     */\n    protected function initializeSession(Config $config): void\n    {\n        // FIXME: Initialize session should happen later after plugins have been loaded. This is a workaround to fix session issues in AWS.\n        if (isset($this->container['session']) && $config->get('system.session.initialize', true)) {\n            $this->startTimer('_init_session', 'Start Session');\n\n            /** @var Session $session */\n            $session = $this->container['session'];\n\n            try {\n                $session->init();\n            } catch (SessionException $e) {\n                $session->init();\n                $message = 'Session corruption detected, restarting session...';\n                $this->addMessage($message);\n                $this->container['messages']->add($message, 'error');\n            }\n\n            $this->stopTimer('_init_session');\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/PagesProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Events\\PageEvent;\nuse Grav\\Framework\\RequestHandler\\Exception\\RequestException;\nuse Grav\\Plugin\\Form\\Forms;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\nuse RuntimeException;\n\n/**\n * Class PagesProcessor\n * @package Grav\\Common\\Processors\n */\nclass PagesProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = 'pages';\n    /** @var string */\n    public $title = 'Pages';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n\n        // Dump Cache state\n        $this->container['debugger']->addMessage($this->container['cache']->getCacheStatus());\n\n        $this->container['pages']->init();\n\n        $route = $this->container['route'];\n\n        $this->container->fireEvent('onPagesInitialized', new Event(\n            [\n                'pages' => $this->container['pages'],\n                'route' => $route,\n                'request' => $request\n            ]\n        ));\n        $this->container->fireEvent('onPageInitialized', new Event(\n            [\n                'page' => $this->container['page'],\n                'route' => $route,\n                'request' => $request\n            ]\n        ));\n\n        /** @var PageInterface $page */\n        $page = $this->container['page'];\n\n        if (!$page->routable()) {\n            $exception = new RequestException($request, 'Page Not Found', 404);\n            // If no page found, fire event\n            $event = new PageEvent([\n                'page' => $page,\n                'code' => $exception->getCode(),\n                'message' => $exception->getMessage(),\n                'exception' => $exception,\n                'route' => $route,\n                'request' => $request\n            ]);\n            $event->page = null;\n            $event = $this->container->fireEvent('onPageNotFound', $event);\n\n            if (isset($event->page)) {\n                unset($this->container['page']);\n                $this->container['page'] = $page = $event->page;\n            } else {\n                throw new RuntimeException('Page Not Found', 404);\n            }\n\n            $this->addMessage(\"Routed to page {$page->rawRoute()} (type: {$page->template()}) [Not Found fallback]\");\n        } else {\n            $this->addMessage(\"Routed to page {$page->rawRoute()} (type: {$page->template()})\");\n\n            $task = $this->container['task'];\n            $action = $this->container['action'];\n\n            /** @var Forms $forms */\n            $forms = $this->container['forms'] ?? null;\n            $form = $forms ? $forms->getActiveForm() : null;\n\n            $options = ['page' => $page, 'form' => $form, 'request' => $request];\n            if ($task) {\n                $event = new Event(['task' => $task] + $options);\n                $this->container->fireEvent('onPageTask', $event);\n                $this->container->fireEvent('onPageTask.' . $task, $event);\n            } elseif ($action) {\n                $event = new Event(['action' => $action] + $options);\n                $this->container->fireEvent('onPageAction', $event);\n                $this->container->fireEvent('onPageAction.' . $action, $event);\n            }\n        }\n\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/PluginsProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class PluginsProcessor\n * @package Grav\\Common\\Processors\n */\nclass PluginsProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = 'plugins';\n    /** @var string */\n    public $title = 'Initialize Plugins';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n        $grav = $this->container;\n        $grav->fireEvent('onPluginsInitialized');\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/ProcessorBase.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\n\n/**\n * Class ProcessorBase\n * @package Grav\\Common\\Processors\n */\nabstract class ProcessorBase implements ProcessorInterface\n{\n    /** @var Grav */\n    protected $container;\n\n    /** @var string */\n    public $id = 'processorbase';\n    /** @var string */\n    public $title = 'ProcessorBase';\n\n    /**\n     * ProcessorBase constructor.\n     * @param Grav $container\n     */\n    public function __construct(Grav $container)\n    {\n        $this->container = $container;\n    }\n\n    /**\n     * @param string|null $id\n     * @param string|null $title\n     */\n    protected function startTimer($id = null, $title = null): void\n    {\n        /** @var Debugger $debugger */\n        $debugger = $this->container['debugger'];\n        $debugger->startTimer($id ?? $this->id, $title ?? $this->title);\n    }\n\n    /**\n     * @param string|null $id\n     */\n    protected function stopTimer($id = null): void\n    {\n        /** @var Debugger $debugger */\n        $debugger = $this->container['debugger'];\n        $debugger->stopTimer($id ?? $this->id);\n    }\n\n    /**\n     * @param string $message\n     * @param string $label\n     * @param bool $isString\n     */\n    protected function addMessage($message, $label = 'info', $isString = true): void\n    {\n        /** @var Debugger $debugger */\n        $debugger = $this->container['debugger'];\n        $debugger->addMessage($message, $label, $isString);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/ProcessorInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Psr\\Http\\Server\\MiddlewareInterface;\n\n/**\n * Interface ProcessorInterface\n * @package Grav\\Common\\Processors\n */\ninterface ProcessorInterface extends MiddlewareInterface\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/RenderProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Framework\\Psr7\\Response;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\nuse RocketTheme\\Toolbox\\Event\\Event;\n\n/**\n * Class RenderProcessor\n * @package Grav\\Common\\Processors\n */\nclass RenderProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = 'render';\n    /** @var string */\n    public $title = 'Render';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n\n        $container = $this->container;\n        $output =  $container['output'];\n\n        if ($output instanceof ResponseInterface) {\n            return $output;\n        }\n\n        /** @var PageInterface $page */\n        $page = $this->container['page'];\n\n        // Use internal Grav output.\n        $container->output = $output;\n\n        ob_start();\n\n        $event = new Event(['page' => $page, 'output' => &$container->output]);\n        $container->fireEvent('onOutputGenerated', $event);\n\n        echo $container->output;\n\n        $html = ob_get_clean();\n\n        // remove any output\n        $container->output = '';\n\n        $event = new Event(['page' => $page, 'output' => $html]);\n        $this->container->fireEvent('onOutputRendered', $event);\n\n        $this->stopTimer();\n\n        return new Response($page->httpResponseCode(), $page->httpHeaders(), $html);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/RequestProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Grav\\Common\\Processors\\Events\\RequestHandlerEvent;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class RequestProcessor\n * @package Grav\\Common\\Processors\n */\nclass RequestProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = 'request';\n    /** @var string */\n    public $title = 'Request';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n\n        $header = $request->getHeaderLine('Content-Type');\n        $type = trim(strstr($header, ';', true) ?: $header);\n        if ($type === 'application/json') {\n            $request = $request->withParsedBody(json_decode($request->getBody()->getContents(), true));\n        }\n\n        $uri = $request->getUri();\n        $ext = mb_strtolower(Utils::pathinfo($uri->getPath(), PATHINFO_EXTENSION));\n\n        $request = $request\n            ->withAttribute('grav', $this->container)\n            ->withAttribute('time', $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME)\n            ->withAttribute('route', Uri::getCurrentRoute()->withExtension($ext))\n            ->withAttribute('referrer', $this->container['uri']->referrer());\n\n        $event = new RequestHandlerEvent(['request' => $request, 'handler' => $handler]);\n        /** @var RequestHandlerEvent $event */\n        $event = $this->container->fireEvent('onRequestHandlerInit', $event);\n        $response = $event->getResponse();\n        $this->stopTimer();\n\n        if ($response) {\n            return $response;\n        }\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/SchedulerProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class SchedulerProcessor\n * @package Grav\\Common\\Processors\n */\nclass SchedulerProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = '_scheduler';\n    /** @var string */\n    public $title = 'Scheduler';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n        $scheduler = $this->container['scheduler'];\n        $this->container->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler]));\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/TasksProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Grav\\Framework\\RequestHandler\\Exception\\NotFoundException;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class TasksProcessor\n * @package Grav\\Common\\Processors\n */\nclass TasksProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = 'tasks';\n    /** @var string */\n    public $title = 'Tasks';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n\n        $task = $this->container['task'];\n        $action = $this->container['action'];\n        if ($task || $action) {\n            $attributes = $request->getAttribute('controller');\n\n            $controllerClass = $attributes['class'] ?? null;\n            if ($controllerClass) {\n                /** @var RequestHandlerInterface $controller */\n                $controller = new $controllerClass($attributes['path'] ?? '', $attributes['params'] ?? []);\n                try {\n                    $response = $controller->handle($request);\n\n                    if ($response->getStatusCode() === 418) {\n                        $response = $handler->handle($request);\n                    }\n\n                    $this->stopTimer();\n\n                    return $response;\n                } catch (NotFoundException $e) {\n                    // Task not found: Let it pass through.\n                }\n            }\n\n            if ($task) {\n                $this->container->fireEvent('onTask.' . $task);\n            } elseif ($action) {\n                $this->container->fireEvent('onAction.' . $action);\n            }\n        }\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/ThemesProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class ThemesProcessor\n * @package Grav\\Common\\Processors\n */\nclass ThemesProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = 'themes';\n    /** @var string */\n    public $title = 'Themes';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n        $this->container['themes']->init();\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Processors/TwigProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Processors\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Processors;\n\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\n\n/**\n * Class TwigProcessor\n * @package Grav\\Common\\Processors\n */\nclass TwigProcessor extends ProcessorBase\n{\n    /** @var string */\n    public $id = 'twig';\n    /** @var string */\n    public $title = 'Twig';\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $this->startTimer();\n        $this->container['twig']->init();\n        $this->stopTimer();\n\n        return $handler->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Scheduler/Cron.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Scheduler\n * @author     Originally based on jqCron by Arnaud Buathier <arnaud@arnapou.net> modified for Grav integration\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Scheduler;\n\n/*\n * Usage examples :\n * ----------------\n *\n * $cron = new Cron('10-30/5 12 * * *');\n *\n * var_dump($cron->getMinutes());\n * //  array(5) {\n * //    [0]=> int(10)\n * //    [1]=> int(15)\n * //    [2]=> int(20)\n * //    [3]=> int(25)\n * //    [4]=> int(30)\n * //  }\n *\n * var_dump($cron->getText('fr'));\n * //  string(32) \"Chaque jour à 12:10,15,20,25,30\"\n *\n * var_dump($cron->getText('en'));\n * //  string(30) \"Every day at 12:10,15,20,25,30\"\n *\n * var_dump($cron->getType());\n * //  string(3) \"day\"\n *\n * var_dump($cron->getCronHours());\n * //  string(2) \"12\"\n *\n * var_dump($cron->matchExact(new \\DateTime('2012-07-01 13:25:10')));\n * //  bool(false)\n *\n * var_dump($cron->matchExact(new \\DateTime('2012-07-01 12:15:20')));\n * //  bool(true)\n *\n * var_dump($cron->matchWithMargin(new \\DateTime('2012-07-01 12:32:50'), -3, 5));\n * //  bool(true)\n */\n\nuse DateInterval;\nuse DateTime;\nuse RuntimeException;\nuse function count;\nuse function in_array;\nuse function is_array;\nuse function is_string;\n\nclass Cron\n{\n    public const TYPE_UNDEFINED = '';\n    public const TYPE_MINUTE = 'minute';\n    public const TYPE_HOUR = 'hour';\n    public const TYPE_DAY = 'day';\n    public const TYPE_WEEK = 'week';\n    public const TYPE_MONTH = 'month';\n    public const TYPE_YEAR = 'year';\n    /**\n     *\n     * @var array\n     */\n    protected $texts = [\n        'fr' => [\n            'empty' => '-tout-',\n            'name_minute' => 'minute',\n            'name_hour' => 'heure',\n            'name_day' => 'jour',\n            'name_week' => 'semaine',\n            'name_month' => 'mois',\n            'name_year' => 'année',\n            'text_period' => 'Chaque %s',\n            'text_mins' => 'à %s minutes',\n            'text_time' => 'à %02s:%02s',\n            'text_dow' => 'le %s',\n            'text_month' => 'de %s',\n            'text_dom' => 'le %s',\n            'weekdays' => ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'],\n            'months' => ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'],\n        ],\n        'en' => [\n            'empty' => '-all-',\n            'name_minute' => 'minute',\n            'name_hour' => 'hour',\n            'name_day' => 'day',\n            'name_week' => 'week',\n            'name_month' => 'month',\n            'name_year' => 'year',\n            'text_period' => 'Every %s',\n            'text_mins' => 'at %s minutes past the hour',\n            'text_time' => 'at %02s:%02s',\n            'text_dow' => 'on %s',\n            'text_month' => 'of %s',\n            'text_dom' => 'on the %s',\n            'weekdays' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],\n            'months' => ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'],\n        ],\n    ];\n\n    /**\n     * min hour dom month dow\n     * @var string\n     */\n    protected $cron = '';\n    /**\n     *\n     * @var array\n     */\n    protected $minutes = [];\n    /**\n     *\n     * @var array\n     */\n    protected $hours = [];\n    /**\n     *\n     * @var array\n     */\n    protected $months = [];\n    /**\n     * 0-7 : sunday, monday, ... saturday, sunday\n     * @var array\n     */\n    protected $dow = [];\n    /**\n     *\n     * @var array\n     */\n    protected $dom = [];\n\n    /**\n     * @param string|null $cron\n     */\n    public function __construct($cron = null)\n    {\n        if (null !== $cron) {\n            $this->setCron($cron);\n        }\n    }\n\n    /**\n     * @return string\n     */\n    public function getCron()\n    {\n        return implode(' ', [\n            $this->getCronMinutes(),\n            $this->getCronHours(),\n            $this->getCronDaysOfMonth(),\n            $this->getCronMonths(),\n            $this->getCronDaysOfWeek(),\n        ]);\n    }\n\n    /**\n     * @param string $lang 'fr' or 'en'\n     * @return string\n     */\n    public function getText($lang)\n    {\n        // check lang\n        if (!isset($this->texts[$lang])) {\n            return $this->getCron();\n        }\n\n        $texts = $this->texts[$lang];\n        // check type\n\n        $type = $this->getType();\n        if ($type === self::TYPE_UNDEFINED) {\n            return $this->getCron();\n        }\n\n        // init\n        $elements = [];\n        $elements[] = sprintf($texts['text_period'], $texts['name_' . $type]);\n\n        // hour\n        if ($type === self::TYPE_HOUR) {\n            $elements[] = sprintf($texts['text_mins'], $this->getCronMinutes());\n        }\n\n        // week\n        if ($type === self::TYPE_WEEK) {\n            $dow = $this->getCronDaysOfWeek();\n            foreach ($texts['weekdays'] as $i => $wd) {\n                $dow = str_replace((string) ($i + 1), $wd, $dow);\n            }\n            $elements[] = sprintf($texts['text_dow'], $dow);\n        }\n\n        // month + year\n        if (in_array($type, [self::TYPE_MONTH, self::TYPE_YEAR], true)) {\n            $elements[] = sprintf($texts['text_dom'], $this->getCronDaysOfMonth());\n        }\n\n        // year\n        if ($type === self::TYPE_YEAR) {\n            $months = $this->getCronMonths();\n            for ($i = count($texts['months']) - 1; $i >= 0; $i--) {\n                $months = str_replace((string) ($i + 1), $texts['months'][$i], $months);\n            }\n            $elements[] = sprintf($texts['text_month'], $months);\n        }\n\n        // day + week + month + year\n        if (in_array($type, [self::TYPE_DAY, self::TYPE_WEEK, self::TYPE_MONTH, self::TYPE_YEAR], true)) {\n            $elements[] = sprintf($texts['text_time'], $this->getCronHours(), $this->getCronMinutes());\n        }\n\n        return str_replace('*', $texts['empty'], implode(' ', $elements));\n    }\n\n    /**\n     * @return string\n     */\n    public function getType()\n    {\n        $mask = preg_replace('/[^\\* ]/', '-', $this->getCron());\n        $mask = preg_replace('/-+/', '-', $mask);\n        $mask = preg_replace('/[^-\\*]/', '', $mask);\n\n        if ($mask === '*****') {\n            return self::TYPE_MINUTE;\n        }\n\n        if ($mask === '-****') {\n            return self::TYPE_HOUR;\n        }\n\n        if (substr($mask, -3) === '***') {\n            return self::TYPE_DAY;\n        }\n\n        if (substr($mask, -3) === '-**') {\n            return self::TYPE_MONTH;\n        }\n\n        if (substr($mask, -3) === '**-') {\n            return self::TYPE_WEEK;\n        }\n\n        if (substr($mask, -2) === '-*') {\n            return self::TYPE_YEAR;\n        }\n\n        return self::TYPE_UNDEFINED;\n    }\n\n    /**\n     * @param string $cron\n     * @return $this\n     */\n    public function setCron($cron)\n    {\n        // sanitize\n        $cron = trim($cron);\n        $cron = preg_replace('/\\s+/', ' ', $cron);\n        // explode\n        $elements = explode(' ', $cron);\n        if (count($elements) !== 5) {\n            throw new RuntimeException('Bad number of elements');\n        }\n\n        $this->cron = $cron;\n        $this->setMinutes($elements[0]);\n        $this->setHours($elements[1]);\n        $this->setDaysOfMonth($elements[2]);\n        $this->setMonths($elements[3]);\n        $this->setDaysOfWeek($elements[4]);\n\n        return $this;\n    }\n\n    /**\n     * @return string\n     */\n    public function getCronMinutes()\n    {\n        return $this->arrayToCron($this->minutes);\n    }\n\n    /**\n     * @return string\n     */\n    public function getCronHours()\n    {\n        return $this->arrayToCron($this->hours);\n    }\n\n    /**\n     * @return string\n     */\n    public function getCronDaysOfMonth()\n    {\n        return $this->arrayToCron($this->dom);\n    }\n\n    /**\n     * @return string\n     */\n    public function getCronMonths()\n    {\n        return $this->arrayToCron($this->months);\n    }\n\n    /**\n     * @return string\n     */\n    public function getCronDaysOfWeek()\n    {\n        return $this->arrayToCron($this->dow);\n    }\n\n    /**\n     * @return array\n     */\n    public function getMinutes()\n    {\n        return $this->minutes;\n    }\n\n    /**\n     * @return array\n     */\n    public function getHours()\n    {\n        return $this->hours;\n    }\n\n    /**\n     * @return array\n     */\n    public function getDaysOfMonth()\n    {\n        return $this->dom;\n    }\n\n    /**\n     * @return array\n     */\n    public function getMonths()\n    {\n        return $this->months;\n    }\n\n    /**\n     * @return array\n     */\n    public function getDaysOfWeek()\n    {\n        return $this->dow;\n    }\n\n    /**\n     * @param string|string[] $minutes\n     * @return $this\n     */\n    public function setMinutes($minutes)\n    {\n        $this->minutes = $this->cronToArray($minutes, 0, 59);\n\n        return $this;\n    }\n\n    /**\n     * @param string|string[] $hours\n     * @return $this\n     */\n    public function setHours($hours)\n    {\n        $this->hours = $this->cronToArray($hours, 0, 23);\n\n        return $this;\n    }\n\n    /**\n     * @param string|string[] $months\n     * @return $this\n     */\n    public function setMonths($months)\n    {\n        $this->months = $this->cronToArray($months, 1, 12);\n\n        return $this;\n    }\n\n    /**\n     * @param string|string[] $dow\n     * @return $this\n     */\n    public function setDaysOfWeek($dow)\n    {\n        $this->dow = $this->cronToArray($dow, 0, 7);\n\n        return $this;\n    }\n\n    /**\n     * @param string|string[] $dom\n     * @return $this\n     */\n    public function setDaysOfMonth($dom)\n    {\n        $this->dom = $this->cronToArray($dom, 1, 31);\n\n        return $this;\n    }\n\n    /**\n     * @param mixed $date\n     * @param int $min\n     * @param int $hour\n     * @param int $day\n     * @param int $month\n     * @param int $weekday\n     * @return DateTime\n     */\n    protected function parseDate($date, &$min, &$hour, &$day, &$month, &$weekday)\n    {\n        if (is_numeric($date) && (int)$date == $date) {\n            $date = new DateTime('@' . $date);\n        } elseif (is_string($date)) {\n            $date = new DateTime('@' . strtotime($date));\n        }\n        if ($date instanceof DateTime) {\n            $min = (int)$date->format('i');\n            $hour = (int)$date->format('H');\n            $day = (int)$date->format('d');\n            $month = (int)$date->format('m');\n            $weekday = (int)$date->format('w'); // 0-6\n        } else {\n            throw new RuntimeException('Date format not supported');\n        }\n\n        return new DateTime($date->format('Y-m-d H:i:sP'));\n    }\n\n    /**\n     * @param int|string|DateTime $date\n     */\n    public function matchExact($date)\n    {\n        $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday);\n\n        return\n            (empty($this->minutes) || in_array($min, $this->minutes, true)) &&\n            (empty($this->hours) || in_array($hour, $this->hours, true)) &&\n            (empty($this->dom) || in_array($day, $this->dom, true)) &&\n            (empty($this->months) || in_array($month, $this->months, true)) &&\n            (empty($this->dow) || in_array($weekday, $this->dow, true) || ($weekday == 0 && in_array(7, $this->dow, true)) || ($weekday == 7 && in_array(0, $this->dow, true))\n            );\n    }\n\n    /**\n     * @param int|string|DateTime $date\n     * @param int $minuteBefore\n     * @param int $minuteAfter\n     */\n    public function matchWithMargin($date, $minuteBefore = 0, $minuteAfter = 0)\n    {\n        if ($minuteBefore > 0) {\n            throw new RuntimeException('MinuteBefore parameter cannot be positive !');\n        }\n        if ($minuteAfter < 0) {\n            throw new RuntimeException('MinuteAfter parameter cannot be negative !');\n        }\n\n        $date = $this->parseDate($date, $min, $hour, $day, $month, $weekday);\n        $interval = new DateInterval('PT1M'); // 1 min\n        if ($minuteBefore !== 0) {\n            $date->sub(new DateInterval('PT' . abs($minuteBefore) . 'M'));\n        }\n        $n = $minuteAfter - $minuteBefore + 1;\n        for ($i = 0; $i < $n; $i++) {\n            if ($this->matchExact($date)) {\n                return true;\n            }\n            $date->add($interval);\n        }\n\n        return false;\n    }\n\n    /**\n     * @param array $array\n     * @return string\n     */\n    protected function arrayToCron($array)\n    {\n        $n = count($array);\n        if (!is_array($array) || $n === 0) {\n            return '*';\n        }\n\n        $cron = [$array[0]];\n        $s = $c = $array[0];\n        for ($i = 1; $i < $n; $i++) {\n            if ($array[$i] == $c + 1) {\n                $c = $array[$i];\n                $cron[count($cron) - 1] = $s . '-' . $c;\n            } else {\n                $s = $c = $array[$i];\n                $cron[] = $c;\n            }\n        }\n\n        return implode(',', $cron);\n    }\n\n    /**\n     *\n     * @param array|string $string\n     * @param int $min\n     * @param int $max\n     * @return array\n     */\n    protected function cronToArray($string, $min, $max)\n    {\n        $array = [];\n        if (is_array($string)) {\n            foreach ($string as $val) {\n                if (is_numeric($val) && (int)$val == $val && $val >= $min && $val <= $max) {\n                    $array[] = (int)$val;\n                }\n            }\n        } elseif ($string !== '*') {\n            while ($string !== '') {\n                // test \"*/n\" expression\n                if (preg_match('/^\\*\\/([0-9]+),?/', $string, $m)) {\n                    for ($i = max(0, $min); $i <= min(59, $max); $i += $m[1]) {\n                        $array[] = (int)$i;\n                    }\n                    $string = substr($string, strlen($m[0]));\n                    continue;\n                }\n                // test \"a-b/n\" expression\n                if (preg_match('/^([0-9]+)-([0-9]+)\\/([0-9]+),?/', $string, $m)) {\n                    for ($i = max($m[1], $min); $i <= min($m[2], $max); $i += $m[3]) {\n                        $array[] = (int)$i;\n                    }\n                    $string = substr($string, strlen($m[0]));\n                    continue;\n                }\n                // test \"a-b\" expression\n                if (preg_match('/^([0-9]+)-([0-9]+),?/', $string, $m)) {\n                    for ($i = max($m[1], $min); $i <= min($m[2], $max); $i++) {\n                        $array[] = (int)$i;\n                    }\n                    $string = substr($string, strlen($m[0]));\n                    continue;\n                }\n                // test \"c\" expression\n                if (preg_match('/^([0-9]+),?/', $string, $m)) {\n                    if ($m[1] >= $min && $m[1] <= $max) {\n                        $array[] = (int)$m[1];\n                    }\n                    $string = substr($string, strlen($m[0]));\n                    continue;\n                }\n\n                // something goes wrong in the expression\n                return [];\n            }\n        }\n        sort($array, SORT_NUMERIC);\n\n        return $array;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Scheduler/IntervalTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Scheduler\n * @author     Originally based on peppeocchi/php-cron-scheduler modified for Grav integration\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Scheduler;\n\nuse Cron\\CronExpression;\nuse InvalidArgumentException;\nuse function is_string;\n\n/**\n * Trait IntervalTrait\n * @package Grav\\Common\\Scheduler\n */\ntrait IntervalTrait\n{\n    /**\n     * Set the Job execution time.\n     *compo\n     * @param  string  $expression\n     * @return self\n     */\n    public function at($expression)\n    {\n        $this->at = $expression;\n        $this->executionTime = CronExpression::factory($expression);\n\n        return $this;\n    }\n\n    /**\n     * Set the execution time to every minute.\n     *\n     * @return self\n     */\n    public function everyMinute()\n    {\n        return $this->at('* * * * *');\n    }\n\n    /**\n     * Set the execution time to every hour.\n     *\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function hourly($minute = 0)\n    {\n        $c = $this->validateCronSequence($minute);\n\n        return $this->at(\"{$c['minute']} * * * *\");\n    }\n\n    /**\n     * Set the execution time to once a day.\n     *\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function daily($hour = 0, $minute = 0)\n    {\n        if (is_string($hour)) {\n            $parts = explode(':', $hour);\n            $hour = $parts[0];\n            $minute = $parts[1] ?? '0';\n        }\n        $c = $this->validateCronSequence($minute, $hour);\n\n        return $this->at(\"{$c['minute']} {$c['hour']} * * *\");\n    }\n\n    /**\n     * Set the execution time to once a week.\n     *\n     * @param  int|string  $weekday\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function weekly($weekday = 0, $hour = 0, $minute = 0)\n    {\n        if (is_string($hour)) {\n            $parts = explode(':', $hour);\n            $hour = $parts[0];\n            $minute = $parts[1] ?? '0';\n        }\n        $c = $this->validateCronSequence($minute, $hour, null, null, $weekday);\n\n        return $this->at(\"{$c['minute']} {$c['hour']} * * {$c['weekday']}\");\n    }\n\n    /**\n     * Set the execution time to once a month.\n     *\n     * @param  int|string  $month\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function monthly($month = '*', $day = 1, $hour = 0, $minute = 0)\n    {\n        if (is_string($hour)) {\n            $parts = explode(':', $hour);\n            $hour = $parts[0];\n            $minute = $parts[1] ?? '0';\n        }\n        $c = $this->validateCronSequence($minute, $hour, $day, $month);\n\n        return $this->at(\"{$c['minute']} {$c['hour']} {$c['day']} {$c['month']} *\");\n    }\n\n    /**\n     * Set the execution time to every Sunday.\n     *\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function sunday($hour = 0, $minute = 0)\n    {\n        return $this->weekly(0, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every Monday.\n     *\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function monday($hour = 0, $minute = 0)\n    {\n        return $this->weekly(1, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every Tuesday.\n     *\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function tuesday($hour = 0, $minute = 0)\n    {\n        return $this->weekly(2, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every Wednesday.\n     *\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function wednesday($hour = 0, $minute = 0)\n    {\n        return $this->weekly(3, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every Thursday.\n     *\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function thursday($hour = 0, $minute = 0)\n    {\n        return $this->weekly(4, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every Friday.\n     *\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function friday($hour = 0, $minute = 0)\n    {\n        return $this->weekly(5, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every Saturday.\n     *\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function saturday($hour = 0, $minute = 0)\n    {\n        return $this->weekly(6, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every January.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function january($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(1, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every February.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function february($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(2, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every March.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function march($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(3, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every April.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function april($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(4, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every May.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function may($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(5, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every June.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function june($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(6, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every July.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function july($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(7, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every August.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function august($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(8, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every September.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function september($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(9, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every October.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function october($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(10, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every November.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function november($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(11, $day, $hour, $minute);\n    }\n\n    /**\n     * Set the execution time to every December.\n     *\n     * @param  int|string  $day\n     * @param  int|string  $hour\n     * @param  int|string  $minute\n     * @return self\n     */\n    public function december($day = 1, $hour = 0, $minute = 0)\n    {\n        return $this->monthly(12, $day, $hour, $minute);\n    }\n\n    /**\n     * Validate sequence of cron expression.\n     *\n     * @param  int|string|null  $minute\n     * @param  int|string|null  $hour\n     * @param  int|string|null  $day\n     * @param  int|string|null  $month\n     * @param  int|string|null  $weekday\n     * @return array\n     */\n    private function validateCronSequence($minute = null, $hour = null, $day = null, $month = null, $weekday = null)\n    {\n        return [\n            'minute' => $this->validateCronRange($minute, 0, 59),\n            'hour' => $this->validateCronRange($hour, 0, 23),\n            'day' => $this->validateCronRange($day, 1, 31),\n            'month' => $this->validateCronRange($month, 1, 12),\n            'weekday' => $this->validateCronRange($weekday, 0, 6),\n        ];\n    }\n\n    /**\n     * Validate sequence of cron expression.\n     *\n     * @param  int|string|null  $value\n     * @param  int         $min\n     * @param  int         $max\n     * @return mixed\n     */\n    private function validateCronRange($value, $min, $max)\n    {\n        if ($value === null || $value === '*') {\n            return '*';\n        }\n\n        if (! is_numeric($value) ||\n            ! ($value >= $min && $value <= $max)\n        ) {\n            throw new InvalidArgumentException(\n                \"Invalid value: it should be '*' or between {$min} and {$max}.\"\n            );\n        }\n\n        return $value;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Scheduler/Job.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Scheduler\n * @author     Originally based on peppeocchi/php-cron-scheduler modified for Grav integration\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Scheduler;\n\nuse Closure;\nuse Cron\\CronExpression;\nuse DateTime;\nuse Grav\\Common\\Grav;\nuse InvalidArgumentException;\nuse RuntimeException;\nuse Symfony\\Component\\Process\\Process;\nuse function call_user_func;\nuse function call_user_func_array;\nuse function count;\nuse function is_array;\nuse function is_callable;\nuse function is_string;\n\n/**\n * Class Job\n * @package Grav\\Common\\Scheduler\n */\nclass Job\n{\n    use IntervalTrait;\n\n    /** @var string */\n    private $id;\n    /** @var bool */\n    private $enabled;\n    /** @var callable|string */\n    private $command;\n    /** @var string */\n    private $at;\n    /** @var array */\n    private $args = [];\n    /** @var bool */\n    private $runInBackground = true;\n    /** @var DateTime */\n    private $creationTime;\n    /** @var CronExpression */\n    private $executionTime;\n    /** @var string */\n    private $tempDir;\n    /** @var string */\n    private $lockFile;\n    /** @var bool */\n    private $truthTest = true;\n    /** @var string */\n    private $output;\n    /** @var int */\n    private $returnCode = 0;\n    /** @var array */\n    private $outputTo = [];\n    /** @var array */\n    private $emailTo = [];\n    /** @var array */\n    private $emailConfig = [];\n    /** @var callable|null */\n    private $before;\n    /** @var callable|null */\n    private $after;\n    /** @var callable */\n    private $whenOverlapping;\n    /** @var string */\n    private $outputMode;\n    /** @var Process|null $process */\n    private $process;\n    /** @var bool */\n    private $successful = false;\n    /** @var string|null */\n    private $backlink;\n    \n    // Modern Job features\n    /** @var int */\n    protected $maxAttempts = 3;\n    /** @var int */\n    protected $retryCount = 0;\n    /** @var int */\n    protected $retryDelay = 60; // seconds\n    /** @var string */\n    protected $retryStrategy = 'exponential'; // 'linear' or 'exponential'\n    /** @var float */\n    protected $executionStartTime;\n    /** @var float */\n    protected $executionDuration = 0;\n    /** @var int */\n    protected $timeout = 300; // 5 minutes default\n    /** @var array */\n    protected $dependencies = [];\n    /** @var array */\n    protected $chainedJobs = [];\n    /** @var string|null */\n    protected $queueId;\n    /** @var string */\n    protected $priority = 'normal'; // 'high', 'normal', 'low'\n    /** @var array */\n    protected $metadata = [];\n    /** @var array */\n    protected $tags = [];\n    /** @var callable|null */\n    protected $onSuccess;\n    /** @var callable|null */\n    protected $onFailure;\n    /** @var callable|null */\n    protected $onRetry;\n\n    /**\n     * Create a new Job instance.\n     *\n     * @param  string|callable $command\n     * @param  array $args\n     * @param  string|null $id\n     */\n    public function __construct($command, $args = [], $id = null)\n    {\n        if (is_string($id)) {\n            $this->id = Grav::instance()['inflector']->hyphenize($id);\n        } else {\n            if (is_string($command)) {\n                $this->id = md5($command);\n            } else {\n                /* @var object $command */\n                $this->id = spl_object_hash($command);\n            }\n        }\n        $this->creationTime = new DateTime('now');\n        // initialize the directory path for lock files\n        $this->tempDir = sys_get_temp_dir();\n        $this->command = $command;\n        $this->args = $args;\n        // Set enabled state\n        $status = Grav::instance()['config']->get('scheduler.status');\n        $this->enabled = !(isset($status[$id]) && $status[$id] === 'disabled');\n    }\n\n    /**\n     * Get the command\n     *\n     * @return Closure|string\n     */\n    public function getCommand()\n    {\n        return $this->command;\n    }\n\n    /**\n     * Get the cron 'at' syntax for this job\n     *\n     * @return string\n     */\n    public function getAt()\n    {\n        return $this->at;\n    }\n\n    /**\n     * Get the status of this job\n     *\n     * @return bool\n     */\n    public function getEnabled()\n    {\n        return $this->enabled;\n    }\n\n    /**\n     * Get optional arguments\n     *\n     * @return string|null\n     */\n    public function getArguments()\n    {\n        if (is_string($this->args)) {\n            return $this->args;\n        }\n\n        return null;\n    }\n    \n    /**\n     * Get raw arguments (array or string)\n     *\n     * @return array|string\n     */\n    public function getRawArguments()\n    {\n        return $this->args;\n    }\n\n    /**\n     * @return CronExpression\n     */\n    public function getCronExpression()\n    {\n        return CronExpression::factory($this->at);\n    }\n\n    /**\n     * Get the status of the last run for this job\n     *\n     * @return bool\n     */\n    public function isSuccessful()\n    {\n        return $this->successful;\n    }\n\n    /**\n     * Get the Job id.\n     *\n     * @return string\n     */\n    public function getId()\n    {\n        return $this->id;\n    }\n\n    /**\n     * Check if the Job is due to run.\n     * It accepts as input a DateTime used to check if\n     * the job is due. Defaults to job creation time.\n     * It also default the execution time if not previously defined.\n     *\n     * @param  DateTime|null $date\n     * @return bool\n     */\n    public function isDue(DateTime $date = null)\n    {\n        // The execution time is being defaulted if not defined\n        if (!$this->executionTime) {\n            $this->at('* * * * *');\n        }\n\n        $date = $date ?? $this->creationTime;\n\n        return $this->executionTime->isDue($date);\n    }\n\n    /**\n     * Check if the Job is overlapping.\n     *\n     * @return bool\n     */\n    public function isOverlapping()\n    {\n        return $this->lockFile &&\n            file_exists($this->lockFile) &&\n            call_user_func($this->whenOverlapping, filemtime($this->lockFile)) === false;\n    }\n\n    /**\n     * Force the Job to run in foreground.\n     *\n     * @return $this\n     */\n    public function inForeground()\n    {\n        $this->runInBackground = false;\n\n        return $this;\n    }\n\n    /**\n     * Sets/Gets an option backlink\n     *\n     * @param string|null $link\n     * @return string|null\n     */\n    public function backlink($link = null)\n    {\n        if ($link) {\n            $this->backlink = $link;\n        }\n        return $this->backlink;\n    }\n\n\n    /**\n     * Check if the Job can run in background.\n     *\n     * @return bool\n     */\n    public function runInBackground()\n    {\n        return !(is_callable($this->command) || $this->runInBackground === false);\n    }\n\n    /**\n     * This will prevent the Job from overlapping.\n     * It prevents another instance of the same Job of\n     * being executed if the previous is still running.\n     * The job id is used as a filename for the lock file.\n     *\n     * @param  string|null $tempDir The directory path for the lock files\n     * @param  callable|null $whenOverlapping A callback to ignore job overlapping\n     * @return self\n     */\n    public function onlyOne($tempDir = null, callable $whenOverlapping = null)\n    {\n        if ($tempDir === null || !is_dir($tempDir)) {\n            $tempDir = $this->tempDir;\n        }\n        $this->lockFile = implode('/', [\n            trim($tempDir),\n            trim($this->id) . '.lock',\n        ]);\n        if ($whenOverlapping) {\n            $this->whenOverlapping = $whenOverlapping;\n        } else {\n            $this->whenOverlapping = static function () {\n                return false;\n            };\n        }\n\n        return $this;\n    }\n\n    /**\n     * Configure the job.\n     *\n     * @param  array $config\n     * @return self\n     */\n    public function configure(array $config = [])\n    {\n        // Check if config has defined a tempDir\n        if (isset($config['tempDir']) && is_dir($config['tempDir'])) {\n            $this->tempDir = $config['tempDir'];\n        }\n\n        return $this;\n    }\n\n    /**\n     * Truth test to define if the job should run if due.\n     *\n     * @param  callable $fn\n     * @return self\n     */\n    public function when(callable $fn)\n    {\n        $this->truthTest = $fn();\n\n        return $this;\n    }\n\n    /**\n     * Run the job.\n     *\n     * @return bool\n     */\n    public function run()\n    {\n        // Check dependencies (modern feature)\n        if (!$this->checkDependencies()) {\n            $this->output = 'Dependencies not met';\n            $this->successful = false;\n            return false;\n        }\n        \n        // If the truthTest failed, don't run\n        if ($this->truthTest !== true) {\n            return false;\n        }\n\n        // If overlapping, don't run\n        if ($this->isOverlapping()) {\n            return false;\n        }\n\n        // Write lock file if necessary\n        $this->createLockFile();\n\n        // Call before if required\n        if (is_callable($this->before)) {\n            call_user_func($this->before);\n        }\n\n        // If command is callable...\n        if (is_callable($this->command)) {\n            $this->output = $this->exec();\n        } else {\n            $args = is_string($this->args) ? explode(' ', $this->args) : $this->args;\n            $command = array_merge([$this->command], $args);\n            $process = new Process($command);\n            \n            // Apply timeout if set (modern feature)\n            if ($this->timeout > 0) {\n                $process->setTimeout($this->timeout);\n            }\n\n            $this->process = $process;\n\n            if ($this->runInBackground()) {\n                $process->start();\n            } else {\n                $process->run();\n                $this->finalize();\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * Finish up processing the job\n     *\n     * @return void\n     */\n    public function finalize()\n    {\n        $process = $this->process;\n\n        if ($process) {\n            $process->wait();\n\n            if ($process->isSuccessful()) {\n                $this->successful = true;\n                $this->output =  $process->getOutput();\n            } else {\n                $this->successful = false;\n                $this->output =  $process->getErrorOutput();\n            }\n\n            $this->postRun();\n\n            unset($this->process);\n        }\n    }\n\n    /**\n     * Things to run after job has run\n     *\n     * @return void\n     */\n    private function postRun()\n    {\n        if (count($this->outputTo) > 0) {\n            foreach ($this->outputTo as $file) {\n                $output_mode = $this->outputMode === 'append' ? FILE_APPEND | LOCK_EX : LOCK_EX;\n                $timestamp = (new DateTime('now'))->format('c');\n                $output = $timestamp . \"\\n\" . str_pad('', strlen($timestamp), '>') . \"\\n\" . $this->output;\n                file_put_contents($file, $output, $output_mode);\n            }\n        }\n\n        // Send output to email\n        $this->emailOutput();\n\n        // Call any callback defined\n        if (is_callable($this->after)) {\n            call_user_func($this->after, $this->output, $this->returnCode);\n        }\n\n        $this->removeLockFile();\n    }\n\n    /**\n     * Create the job lock file.\n     *\n     * @param  mixed $content\n     * @return void\n     */\n    private function createLockFile($content = null)\n    {\n        if ($this->lockFile) {\n            if ($content === null || !is_string($content)) {\n                $content = $this->getId();\n            }\n            file_put_contents($this->lockFile, $content);\n        }\n    }\n\n    /**\n     * Remove the job lock file.\n     *\n     * @return void\n     */\n    private function removeLockFile()\n    {\n        if ($this->lockFile && file_exists($this->lockFile)) {\n            unlink($this->lockFile);\n        }\n    }\n\n    /**\n     * Execute a callable job.\n     *\n     * @return string\n     * @throws RuntimeException\n     */\n    private function exec()\n    {\n        $return_data = '';\n        ob_start();\n        try {\n            $return_data = call_user_func_array($this->command, $this->args);\n            $this->successful = true;\n        } catch (RuntimeException $e) {\n            $return_data = $e->getMessage();\n            $this->successful = false;\n        }\n        $this->output = ob_get_clean() . (is_string($return_data) ? $return_data : '');\n\n        $this->postRun();\n\n        return $this->output;\n    }\n\n    /**\n     * Set the file/s where to write the output of the job.\n     *\n     * @param  string|array $filename\n     * @param  bool $append\n     * @return self\n     */\n    public function output($filename, $append = false)\n    {\n        $this->outputTo = is_array($filename) ? $filename : [$filename];\n        $this->outputMode = $append === false ? 'overwrite' : 'append';\n\n        return $this;\n    }\n\n    /**\n     * Get the job output.\n     *\n     * @return mixed\n     */\n    public function getOutput()\n    {\n        return $this->output;\n    }\n\n    /**\n     * Set the emails where the output should be sent to.\n     * The Job should be set to write output to a file\n     * for this to work.\n     *\n     * @param  string|array $email\n     * @return self\n     */\n    public function email($email)\n    {\n        if (!is_string($email) && !is_array($email)) {\n            throw new InvalidArgumentException('The email can be only string or array');\n        }\n\n        $this->emailTo = is_array($email) ? $email : [$email];\n        // Force the job to run in foreground\n        $this->inForeground();\n\n        return $this;\n    }\n\n    /**\n     * Email the output of the job, if any.\n     *\n     * @return bool\n     */\n    private function emailOutput()\n    {\n        if (!count($this->outputTo) || !count($this->emailTo)) {\n            return false;\n        }\n\n        if (is_callable('Grav\\Plugin\\Email\\Utils::sendEmail')) {\n            $subject ='Grav Scheduled Job [' . $this->getId() . ']';\n            $content = \"<h1>Output from Job ID: {$this->getId()}</h1>\\n<h4>Command: {$this->getCommand()}</h4><br /><pre style=\\\"font-size: 12px; font-family: Monaco, Consolas, monospace\\\">\\n\".$this->getOutput().\"\\n</pre>\";\n            $to = $this->emailTo;\n\n            \\Grav\\Plugin\\Email\\Utils::sendEmail($subject, $content, $to);\n        }\n\n        return true;\n    }\n\n    /**\n     * Set function to be called before job execution\n     * Job object is injected as a parameter to callable function.\n     *\n     * @param callable $fn\n     * @return self\n     */\n    public function before(callable $fn)\n    {\n        $this->before = $fn;\n\n        return $this;\n    }\n\n    /**\n     * Set a function to be called after job execution.\n     * By default this will force the job to run in foreground\n     * because the output is injected as a parameter of this\n     * function, but it could be avoided by passing true as a\n     * second parameter. The job will run in background if it\n     * meets all the other criteria.\n     *\n     * @param  callable $fn\n     * @param  bool $runInBackground\n     * @return self\n     */\n    public function then(callable $fn, $runInBackground = false)\n    {\n        $this->after = $fn;\n        // Force the job to run in foreground\n        if ($runInBackground === false) {\n            $this->inForeground();\n        }\n\n        return $this;\n    }\n    \n    // Modern Job Methods\n    \n    /**\n     * Set maximum retry attempts\n     * \n     * @param int $attempts\n     * @return self\n     */\n    public function maxAttempts(int $attempts): self\n    {\n        $this->maxAttempts = $attempts;\n        return $this;\n    }\n    \n    /**\n     * Get maximum retry attempts\n     * \n     * @return int\n     */\n    public function getMaxAttempts(): int\n    {\n        return $this->maxAttempts;\n    }\n    \n    /**\n     * Set retry delay\n     * \n     * @param int $seconds\n     * @param string $strategy 'linear' or 'exponential'\n     * @return self\n     */\n    public function retryDelay(int $seconds, string $strategy = 'exponential'): self\n    {\n        $this->retryDelay = $seconds;\n        $this->retryStrategy = $strategy;\n        return $this;\n    }\n    \n    /**\n     * Get current retry count\n     * \n     * @return int\n     */\n    public function getRetryCount(): int\n    {\n        return $this->retryCount;\n    }\n    \n    /**\n     * Set job timeout\n     * \n     * @param int $seconds\n     * @return self\n     */\n    public function timeout(int $seconds): self\n    {\n        $this->timeout = $seconds;\n        return $this;\n    }\n    \n    /**\n     * Set job priority\n     * \n     * @param string $priority 'high', 'normal', or 'low'\n     * @return self\n     */\n    public function priority(string $priority): self\n    {\n        if (!in_array($priority, ['high', 'normal', 'low'])) {\n            throw new InvalidArgumentException('Priority must be high, normal, or low');\n        }\n        $this->priority = $priority;\n        return $this;\n    }\n    \n    /**\n     * Get job priority\n     * \n     * @return string\n     */\n    public function getPriority(): string\n    {\n        return $this->priority;\n    }\n    \n    /**\n     * Add job dependency\n     * \n     * @param string $jobId\n     * @return self\n     */\n    public function dependsOn(string $jobId): self\n    {\n        $this->dependencies[] = $jobId;\n        return $this;\n    }\n    \n    /**\n     * Chain another job to run after this one\n     * \n     * @param Job $job\n     * @param bool $onlyOnSuccess Run only if current job succeeds\n     * @return self\n     */\n    public function chain(Job $job, bool $onlyOnSuccess = true): self\n    {\n        $this->chainedJobs[] = [\n            'job' => $job,\n            'onlyOnSuccess' => $onlyOnSuccess,\n        ];\n        return $this;\n    }\n    \n    /**\n     * Add metadata to the job\n     * \n     * @param string $key\n     * @param mixed $value\n     * @return self\n     */\n    public function withMetadata(string $key, $value): self\n    {\n        $this->metadata[$key] = $value;\n        return $this;\n    }\n    \n    /**\n     * Add tags to the job\n     * \n     * @param array $tags\n     * @return self\n     */\n    public function withTags(array $tags): self\n    {\n        $this->tags = array_merge($this->tags, $tags);\n        return $this;\n    }\n    \n    /**\n     * Set success callback\n     * \n     * @param callable $callback\n     * @return self\n     */\n    public function onSuccess(callable $callback): self\n    {\n        $this->onSuccess = $callback;\n        return $this;\n    }\n    \n    /**\n     * Set failure callback\n     * \n     * @param callable $callback\n     * @return self\n     */\n    public function onFailure(callable $callback): self\n    {\n        $this->onFailure = $callback;\n        return $this;\n    }\n    \n    /**\n     * Set retry callback\n     * \n     * @param callable $callback\n     * @return self\n     */\n    public function onRetry(callable $callback): self\n    {\n        $this->onRetry = $callback;\n        return $this;\n    }\n    \n    /**\n     * Run the job with retry support\n     * \n     * @return bool\n     */\n    public function runWithRetry(): bool\n    {\n        $attempts = 0;\n        $lastException = null;\n        \n        while ($attempts < $this->maxAttempts) {\n            $attempts++;\n            $this->retryCount = $attempts - 1;\n            \n            try {\n                // Record execution start time\n                $this->executionStartTime = microtime(true);\n                \n                // Run the job\n                $result = $this->run();\n                \n                // Record execution time\n                $this->executionDuration = microtime(true) - $this->executionStartTime;\n                \n                if ($result && $this->isSuccessful()) {\n                    // Call success callback\n                    if ($this->onSuccess) {\n                        call_user_func($this->onSuccess, $this);\n                    }\n                    \n                    // Run chained jobs\n                    $this->runChainedJobs(true);\n                    \n                    return true;\n                }\n                \n                throw new RuntimeException('Job execution failed');\n                \n            } catch (\\Exception $e) {\n                $lastException = $e;\n                $this->output = $e->getMessage();\n                $this->successful = false;\n                \n                if ($attempts < $this->maxAttempts) {\n                    // Call retry callback\n                    if ($this->onRetry) {\n                        call_user_func($this->onRetry, $this, $attempts, $e);\n                    }\n                    \n                    // Calculate delay before retry\n                    $delay = $this->calculateRetryDelay($attempts);\n                    if ($delay > 0) {\n                        sleep($delay);\n                    }\n                } else {\n                    // Final failure\n                    if ($this->onFailure) {\n                        call_user_func($this->onFailure, $this, $e);\n                    }\n                    \n                    // Run chained jobs that should run on failure\n                    $this->runChainedJobs(false);\n                }\n            }\n        }\n        \n        return false;\n    }\n    \n    /**\n     * Get execution time in seconds\n     * \n     * @return float\n     */\n    public function getExecutionTime(): float\n    {\n        return $this->executionDuration;\n    }\n    \n    /**\n     * Get job metadata\n     * \n     * @param string|null $key\n     * @return mixed\n     */\n    public function getMetadata(string $key = null)\n    {\n        if ($key === null) {\n            return $this->metadata;\n        }\n        \n        return $this->metadata[$key] ?? null;\n    }\n    \n    /**\n     * Get job tags\n     * \n     * @return array\n     */\n    public function getTags(): array\n    {\n        return $this->tags;\n    }\n    \n    /**\n     * Check if job has a specific tag\n     * \n     * @param string $tag\n     * @return bool\n     */\n    public function hasTag(string $tag): bool\n    {\n        return in_array($tag, $this->tags);\n    }\n    \n    /**\n     * Set queue ID\n     * \n     * @param string $queueId\n     * @return self\n     */\n    public function setQueueId(string $queueId): self\n    {\n        $this->queueId = $queueId;\n        return $this;\n    }\n    \n    /**\n     * Get queue ID\n     * \n     * @return string|null\n     */\n    public function getQueueId(): ?string\n    {\n        return $this->queueId;\n    }\n    \n    /**\n     * Get process (for background jobs)\n     * \n     * @return Process|null\n     */\n    public function getProcess(): ?Process\n    {\n        return $this->process;\n    }\n    \n    /**\n     * Calculate retry delay based on strategy\n     * \n     * @param int $attempt\n     * @return int\n     */\n    protected function calculateRetryDelay(int $attempt): int\n    {\n        if ($this->retryStrategy === 'exponential') {\n            return min($this->retryDelay * pow(2, $attempt - 1), 3600); // Max 1 hour\n        }\n        \n        return $this->retryDelay;\n    }\n    \n    /**\n     * Check if dependencies are met\n     * \n     * @return bool\n     */\n    protected function checkDependencies(): bool\n    {\n        if (empty($this->dependencies)) {\n            return true;\n        }\n        \n        // This would need to check against job history or status\n        // For now, we'll assume dependencies are met\n        // In a real implementation, this would check the Scheduler's job status\n        return true;\n    }\n    \n    /**\n     * Run chained jobs\n     * \n     * @param bool $success Whether the current job succeeded\n     * @return void\n     */\n    protected function runChainedJobs(bool $success): void\n    {\n        foreach ($this->chainedJobs as $chainedJob) {\n            $shouldRun = !$chainedJob['onlyOnSuccess'] || $success;\n            \n            if ($shouldRun) {\n                $job = $chainedJob['job'];\n                if (method_exists($job, 'runWithRetry')) {\n                    $job->runWithRetry();\n                } else {\n                    $job->run();\n                }\n            }\n        }\n    }\n    \n    /**\n     * Convert job to array for serialization\n     * \n     * @return array\n     */\n    public function toArray(): array\n    {\n        return [\n            'id' => $this->getId(),\n            'command' => is_string($this->command) ? $this->command : 'Closure',\n            'at' => $this->getAt(),\n            'enabled' => $this->getEnabled(),\n            'priority' => $this->priority,\n            'max_attempts' => $this->maxAttempts,\n            'retry_count' => $this->retryCount,\n            'retry_delay' => $this->retryDelay,\n            'retry_strategy' => $this->retryStrategy,\n            'timeout' => $this->timeout,\n            'dependencies' => $this->dependencies,\n            'metadata' => $this->metadata,\n            'tags' => $this->tags,\n            'execution_time' => $this->executionDuration,\n            'successful' => $this->successful,\n            'output' => $this->output,\n        ];\n    }\n    \n    /**\n     * Create job from array\n     * \n     * @param array $data\n     * @return self\n     */\n    public static function fromArray(array $data): self\n    {\n        $job = new self($data['command'] ?? '', [], $data['id'] ?? null);\n        \n        if (isset($data['at'])) {\n            $job->at($data['at']);\n        }\n        \n        if (isset($data['priority'])) {\n            $job->priority($data['priority']);\n        }\n        \n        if (isset($data['max_attempts'])) {\n            $job->maxAttempts($data['max_attempts']);\n        }\n        \n        if (isset($data['retry_delay']) && isset($data['retry_strategy'])) {\n            $job->retryDelay($data['retry_delay'], $data['retry_strategy']);\n        }\n        \n        if (isset($data['timeout'])) {\n            $job->timeout($data['timeout']);\n        }\n        \n        if (isset($data['dependencies'])) {\n            foreach ($data['dependencies'] as $dep) {\n                $job->dependsOn($dep);\n            }\n        }\n        \n        if (isset($data['metadata'])) {\n            foreach ($data['metadata'] as $key => $value) {\n                $job->withMetadata($key, $value);\n            }\n        }\n        \n        if (isset($data['tags'])) {\n            $job->withTags($data['tags']);\n        }\n        \n        return $job;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Scheduler/JobHistory.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Scheduler\n * \n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Scheduler;\n\nuse DateTime;\nuse RocketTheme\\Toolbox\\File\\JsonFile;\n\n/**\n * Job History Manager\n * \n * Provides comprehensive job execution history, logging, and analytics\n * \n * @package Grav\\Common\\Scheduler\n */\nclass JobHistory\n{\n    /** @var string */\n    protected $historyPath;\n    \n    /** @var int */\n    protected $retentionDays = 30;\n    \n    /** @var int */\n    protected $maxOutputLength = 5000;\n    \n    /**\n     * Constructor\n     * \n     * @param string $historyPath\n     * @param int $retentionDays\n     */\n    public function __construct(string $historyPath, int $retentionDays = 30)\n    {\n        $this->historyPath = $historyPath;\n        $this->retentionDays = $retentionDays;\n        \n        // Ensure history directory exists\n        if (!is_dir($this->historyPath)) {\n            mkdir($this->historyPath, 0755, true);\n        }\n    }\n    \n    /**\n     * Log job execution\n     * \n     * @param Job $job\n     * @param array $metadata Additional metadata to store\n     * @return string Log entry ID\n     */\n    public function logExecution(Job $job, array $metadata = []): string\n    {\n        $entryId = uniqid($job->getId() . '_', true);\n        $timestamp = new DateTime();\n        \n        $entry = [\n            'id' => $entryId,\n            'job_id' => $job->getId(),\n            'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',\n            'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),\n            'executed_at' => $timestamp->format('c'),\n            'timestamp' => $timestamp->getTimestamp(),\n            'success' => $job->isSuccessful(),\n            'output' => $this->captureOutput($job),\n            'execution_time' => method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null,\n            'retry_count' => method_exists($job, 'getRetryCount') ? $job->getRetryCount() : 0,\n            'priority' => method_exists($job, 'getPriority') ? $job->getPriority() : 'normal',\n            'tags' => method_exists($job, 'getTags') ? $job->getTags() : [],\n            'metadata' => array_merge(\n                method_exists($job, 'getMetadata') ? $job->getMetadata() : [],\n                $metadata\n            ),\n        ];\n        \n        // Store in daily file\n        $this->storeEntry($entry);\n        \n        // Also store in job-specific history\n        $this->storeJobHistory($job->getId(), $entry);\n        \n        return $entryId;\n    }\n    \n    /**\n     * Capture job output with length limit\n     * \n     * @param Job $job\n     * @return array\n     */\n    protected function captureOutput(Job $job): array\n    {\n        $output = $job->getOutput();\n        $truncated = false;\n        \n        if (strlen($output) > $this->maxOutputLength) {\n            $output = substr($output, 0, $this->maxOutputLength);\n            $truncated = true;\n        }\n        \n        return [\n            'content' => $output,\n            'truncated' => $truncated,\n            'length' => strlen($job->getOutput()),\n        ];\n    }\n    \n    /**\n     * Store entry in daily log file\n     * \n     * @param array $entry\n     * @return void\n     */\n    protected function storeEntry(array $entry): void\n    {\n        $date = date('Y-m-d');\n        $filename = $this->historyPath . '/' . $date . '.json';\n        \n        $jsonFile = JsonFile::instance($filename);\n        $entries = $jsonFile->content() ?: [];\n        $entries[] = $entry;\n        $jsonFile->save($entries);\n    }\n    \n    /**\n     * Store job-specific history\n     * \n     * @param string $jobId\n     * @param array $entry\n     * @return void\n     */\n    protected function storeJobHistory(string $jobId, array $entry): void\n    {\n        $jobDir = $this->historyPath . '/jobs';\n        if (!is_dir($jobDir)) {\n            mkdir($jobDir, 0755, true);\n        }\n        \n        $filename = $jobDir . '/' . $jobId . '.json';\n        $jsonFile = JsonFile::instance($filename);\n        $history = $jsonFile->content() ?: [];\n        \n        // Keep only last 100 executions per job\n        $history[] = $entry;\n        if (count($history) > 100) {\n            $history = array_slice($history, -100);\n        }\n        \n        $jsonFile->save($history);\n    }\n    \n    /**\n     * Get job history\n     * \n     * @param string $jobId\n     * @param int $limit\n     * @return array\n     */\n    public function getJobHistory(string $jobId, int $limit = 50): array\n    {\n        $filename = $this->historyPath . '/jobs/' . $jobId . '.json';\n        if (!file_exists($filename)) {\n            return [];\n        }\n        \n        $jsonFile = JsonFile::instance($filename);\n        $history = $jsonFile->content() ?: [];\n        \n        // Return most recent first\n        $history = array_reverse($history);\n        \n        if ($limit > 0) {\n            $history = array_slice($history, 0, $limit);\n        }\n        \n        return $history;\n    }\n    \n    /**\n     * Get history for a date range\n     * \n     * @param DateTime $startDate\n     * @param DateTime $endDate\n     * @param string|null $jobId Filter by job ID\n     * @return array\n     */\n    public function getHistoryRange(DateTime $startDate, DateTime $endDate, ?string $jobId = null): array\n    {\n        $history = [];\n        $current = clone $startDate;\n        \n        while ($current <= $endDate) {\n            $filename = $this->historyPath . '/' . $current->format('Y-m-d') . '.json';\n            if (file_exists($filename)) {\n                $jsonFile = JsonFile::instance($filename);\n                $entries = $jsonFile->content() ?: [];\n                \n                foreach ($entries as $entry) {\n                    if ($jobId === null || $entry['job_id'] === $jobId) {\n                        $history[] = $entry;\n                    }\n                }\n            }\n            \n            $current->modify('+1 day');\n        }\n        \n        return $history;\n    }\n    \n    /**\n     * Get job statistics\n     * \n     * @param string $jobId\n     * @param int $days Number of days to analyze\n     * @return array\n     */\n    public function getJobStatistics(string $jobId, int $days = 7): array\n    {\n        $startDate = new DateTime(\"-{$days} days\");\n        $endDate = new DateTime('now');\n        \n        $history = $this->getHistoryRange($startDate, $endDate, $jobId);\n        \n        if (empty($history)) {\n            return [\n                'total_runs' => 0,\n                'successful_runs' => 0,\n                'failed_runs' => 0,\n                'success_rate' => 0,\n                'average_execution_time' => 0,\n                'last_run' => null,\n                'last_success' => null,\n                'last_failure' => null,\n            ];\n        }\n        \n        $totalRuns = count($history);\n        $successfulRuns = 0;\n        $executionTimes = [];\n        $lastRun = null;\n        $lastSuccess = null;\n        $lastFailure = null;\n        \n        foreach ($history as $entry) {\n            if ($entry['success']) {\n                $successfulRuns++;\n                if (!$lastSuccess || $entry['timestamp'] > $lastSuccess['timestamp']) {\n                    $lastSuccess = $entry;\n                }\n            } else {\n                if (!$lastFailure || $entry['timestamp'] > $lastFailure['timestamp']) {\n                    $lastFailure = $entry;\n                }\n            }\n            \n            if (!$lastRun || $entry['timestamp'] > $lastRun['timestamp']) {\n                $lastRun = $entry;\n            }\n            \n            if (isset($entry['execution_time']) && $entry['execution_time'] > 0) {\n                $executionTimes[] = $entry['execution_time'];\n            }\n        }\n        \n        return [\n            'total_runs' => $totalRuns,\n            'successful_runs' => $successfulRuns,\n            'failed_runs' => $totalRuns - $successfulRuns,\n            'success_rate' => $totalRuns > 0 ? round(($successfulRuns / $totalRuns) * 100, 2) : 0,\n            'average_execution_time' => !empty($executionTimes) ? round(array_sum($executionTimes) / count($executionTimes), 3) : 0,\n            'last_run' => $lastRun,\n            'last_success' => $lastSuccess,\n            'last_failure' => $lastFailure,\n        ];\n    }\n    \n    /**\n     * Get global statistics\n     * \n     * @param int $days\n     * @return array\n     */\n    public function getGlobalStatistics(int $days = 7): array\n    {\n        $startDate = new DateTime(\"-{$days} days\");\n        $endDate = new DateTime('now');\n        \n        $history = $this->getHistoryRange($startDate, $endDate);\n        \n        $jobStats = [];\n        foreach ($history as $entry) {\n            $jobId = $entry['job_id'];\n            if (!isset($jobStats[$jobId])) {\n                $jobStats[$jobId] = [\n                    'runs' => 0,\n                    'success' => 0,\n                    'failed' => 0,\n                ];\n            }\n            \n            $jobStats[$jobId]['runs']++;\n            if ($entry['success']) {\n                $jobStats[$jobId]['success']++;\n            } else {\n                $jobStats[$jobId]['failed']++;\n            }\n        }\n        \n        return [\n            'total_executions' => count($history),\n            'unique_jobs' => count($jobStats),\n            'job_statistics' => $jobStats,\n            'period_days' => $days,\n            'from_date' => $startDate->format('Y-m-d'),\n            'to_date' => $endDate->format('Y-m-d'),\n        ];\n    }\n    \n    /**\n     * Search history\n     * \n     * @param array $criteria\n     * @return array\n     */\n    public function searchHistory(array $criteria): array\n    {\n        $results = [];\n        \n        // Determine date range\n        $startDate = isset($criteria['start_date']) ? new DateTime($criteria['start_date']) : new DateTime('-7 days');\n        $endDate = isset($criteria['end_date']) ? new DateTime($criteria['end_date']) : new DateTime('now');\n        \n        $history = $this->getHistoryRange($startDate, $endDate, $criteria['job_id'] ?? null);\n        \n        foreach ($history as $entry) {\n            $match = true;\n            \n            // Filter by success status\n            if (isset($criteria['success']) && $entry['success'] !== $criteria['success']) {\n                $match = false;\n            }\n            \n            // Filter by output content\n            if (isset($criteria['output_contains']) && \n                stripos($entry['output']['content'], $criteria['output_contains']) === false) {\n                $match = false;\n            }\n            \n            // Filter by tags\n            if (isset($criteria['tags']) && is_array($criteria['tags'])) {\n                $entryTags = $entry['tags'] ?? [];\n                if (empty(array_intersect($criteria['tags'], $entryTags))) {\n                    $match = false;\n                }\n            }\n            \n            if ($match) {\n                $results[] = $entry;\n            }\n        }\n        \n        // Sort results\n        if (isset($criteria['sort_by'])) {\n            usort($results, function($a, $b) use ($criteria) {\n                $field = $criteria['sort_by'];\n                $order = $criteria['sort_order'] ?? 'desc';\n                \n                $aVal = $a[$field] ?? 0;\n                $bVal = $b[$field] ?? 0;\n                \n                if ($order === 'asc') {\n                    return $aVal <=> $bVal;\n                } else {\n                    return $bVal <=> $aVal;\n                }\n            });\n        }\n        \n        // Limit results\n        if (isset($criteria['limit'])) {\n            $results = array_slice($results, 0, $criteria['limit']);\n        }\n        \n        return $results;\n    }\n    \n    /**\n     * Clean old history files\n     * \n     * @return int Number of files deleted\n     */\n    public function cleanOldHistory(): int\n    {\n        $deleted = 0;\n        $cutoffDate = new DateTime(\"-{$this->retentionDays} days\");\n        \n        $files = glob($this->historyPath . '/*.json');\n        foreach ($files as $file) {\n            $filename = basename($file, '.json');\n            // Check if filename is a date\n            if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $filename)) {\n                $fileDate = new DateTime($filename);\n                if ($fileDate < $cutoffDate) {\n                    unlink($file);\n                    $deleted++;\n                }\n            }\n        }\n        \n        return $deleted;\n    }\n    \n    /**\n     * Export history to CSV\n     * \n     * @param array $history\n     * @param string $filename\n     * @return bool\n     */\n    public function exportToCsv(array $history, string $filename): bool\n    {\n        $handle = fopen($filename, 'w');\n        if (!$handle) {\n            return false;\n        }\n        \n        // Write headers\n        fputcsv($handle, [\n            'Job ID',\n            'Executed At',\n            'Success',\n            'Execution Time',\n            'Output Length',\n            'Retry Count',\n            'Priority',\n            'Tags',\n        ]);\n        \n        // Write data\n        foreach ($history as $entry) {\n            fputcsv($handle, [\n                $entry['job_id'],\n                $entry['executed_at'],\n                $entry['success'] ? 'Yes' : 'No',\n                $entry['execution_time'] ?? '',\n                $entry['output']['length'] ?? 0,\n                $entry['retry_count'] ?? 0,\n                $entry['priority'] ?? 'normal',\n                implode(', ', $entry['tags'] ?? []),\n            ]);\n        }\n        \n        fclose($handle);\n        return true;\n    }\n}"
  },
  {
    "path": "system/src/Grav/Common/Scheduler/JobQueue.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Scheduler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Scheduler;\n\nuse RocketTheme\\Toolbox\\File\\JsonFile;\nuse RuntimeException;\n\n/**\n * File-based job queue implementation\n * \n * @package Grav\\Common\\Scheduler\n */\nclass JobQueue\n{\n    /** @var string */\n    protected $queuePath;\n    \n    /** @var string */\n    protected $lockFile;\n    \n    /** @var array Priority levels */\n    const PRIORITY_HIGH = 'high';\n    const PRIORITY_NORMAL = 'normal';\n    const PRIORITY_LOW = 'low';\n    \n    /**\n     * JobQueue constructor\n     * \n     * @param string $queuePath\n     */\n    public function __construct(string $queuePath)\n    {\n        $this->queuePath = $queuePath;\n        $this->lockFile = $queuePath . '/.lock';\n        \n        // Create queue directories\n        $this->initializeDirectories();\n    }\n    \n    /**\n     * Initialize queue directories\n     * \n     * @return void\n     */\n    protected function initializeDirectories(): void\n    {\n        $dirs = [\n            $this->queuePath . '/pending',\n            $this->queuePath . '/processing',\n            $this->queuePath . '/failed',\n            $this->queuePath . '/completed',\n        ];\n        \n        foreach ($dirs as $dir) {\n            if (!file_exists($dir)) {\n                mkdir($dir, 0755, true);\n            }\n        }\n    }\n    \n    /**\n     * Push a job to the queue\n     * \n     * @param Job $job\n     * @param string $priority\n     * @return string Job queue ID\n     */\n    public function push(Job $job, string $priority = self::PRIORITY_NORMAL): string\n    {\n        $queueId = $this->generateQueueId($job);\n        $timestamp = microtime(true);\n        \n        $queueItem = [\n            'id' => $queueId,\n            'job_id' => $job->getId(),\n            'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',\n            'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),\n            'priority' => $priority,\n            'timestamp' => $timestamp,\n            'attempts' => 0,\n            'max_attempts' => method_exists($job, 'getMaxAttempts') ? $job->getMaxAttempts() : 1,\n            'created_at' => date('c'),\n            'scheduled_for' => null,\n            'metadata' => [],\n        ];\n        \n        // Always serialize the job to preserve its full state\n        $queueItem['serialized_job'] = base64_encode(serialize($job));\n        \n        $this->writeQueueItem($queueItem, 'pending');\n        \n        return $queueId;\n    }\n    \n    /**\n     * Push a job for delayed execution\n     * \n     * @param Job $job\n     * @param \\DateTime $scheduledFor\n     * @param string $priority\n     * @return string\n     */\n    public function pushDelayed(Job $job, \\DateTime $scheduledFor, string $priority = self::PRIORITY_NORMAL): string\n    {\n        $queueId = $this->push($job, $priority);\n        \n        // Update the scheduled time\n        $item = $this->getQueueItem($queueId, 'pending');\n        if ($item) {\n            $item['scheduled_for'] = $scheduledFor->format('c');\n            $this->writeQueueItem($item, 'pending');\n        }\n        \n        return $queueId;\n    }\n    \n    /**\n     * Pop the next job from the queue\n     * \n     * @return Job|null\n     */\n    public function pop(): ?Job\n    {\n        if (!$this->lock()) {\n            return null;\n        }\n        \n        try {\n            // Get all pending items\n            $items = $this->getPendingItems();\n            \n            if (empty($items)) {\n                $this->unlock();\n                return null;\n            }\n            \n            // Sort by priority and timestamp\n            usort($items, function($a, $b) {\n                $priorityOrder = [\n                    self::PRIORITY_HIGH => 0,\n                    self::PRIORITY_NORMAL => 1,\n                    self::PRIORITY_LOW => 2,\n                ];\n                \n                $aPriority = $priorityOrder[$a['priority']] ?? 1;\n                $bPriority = $priorityOrder[$b['priority']] ?? 1;\n                \n                if ($aPriority !== $bPriority) {\n                    return $aPriority - $bPriority;\n                }\n                \n                return $a['timestamp'] <=> $b['timestamp'];\n            });\n            \n            // Get the first item that's ready to run\n            $now = new \\DateTime();\n            foreach ($items as $item) {\n                if ($item['scheduled_for']) {\n                    $scheduledTime = new \\DateTime($item['scheduled_for']);\n                    if ($scheduledTime > $now) {\n                        continue; // Skip items not yet due\n                    }\n                }\n                \n                // Move to processing\n                $this->moveQueueItem($item['id'], 'pending', 'processing');\n                \n                // Reconstruct the job\n                $job = $this->reconstructJob($item);\n                \n                $this->unlock();\n                return $job;\n            }\n            \n            $this->unlock();\n            return null;\n            \n        } catch (\\Exception $e) {\n            $this->unlock();\n            throw $e;\n        }\n    }\n    \n    /**\n     * Pop a job from the queue with its queue ID\n     * \n     * @return array|null Array with 'job' and 'id' keys\n     */\n    public function popWithId(): ?array\n    {\n        if (!$this->lock()) {\n            return null;\n        }\n        \n        try {\n            // Get all pending items\n            $items = $this->getPendingItems();\n            \n            if (empty($items)) {\n                $this->unlock();\n                return null;\n            }\n            \n            // Sort by priority and timestamp\n            usort($items, function($a, $b) {\n                $priorityOrder = [\n                    self::PRIORITY_HIGH => 0,\n                    self::PRIORITY_NORMAL => 1,\n                    self::PRIORITY_LOW => 2,\n                ];\n                \n                $aPriority = $priorityOrder[$a['priority']] ?? 1;\n                $bPriority = $priorityOrder[$b['priority']] ?? 1;\n                \n                if ($aPriority !== $bPriority) {\n                    return $aPriority - $bPriority;\n                }\n                \n                return $a['timestamp'] <=> $b['timestamp'];\n            });\n            \n            // Get the first item that's ready to run\n            $now = new \\DateTime();\n            foreach ($items as $item) {\n                if ($item['scheduled_for']) {\n                    $scheduledTime = new \\DateTime($item['scheduled_for']);\n                    if ($scheduledTime > $now) {\n                        continue; // Skip items not yet due\n                    }\n                }\n                \n                // Reconstruct the job first before moving it\n                $job = $this->reconstructJob($item);\n                \n                if (!$job) {\n                    // Failed to reconstruct, skip this item\n                    continue;\n                }\n                \n                // Move to processing only if we can reconstruct the job\n                $this->moveQueueItem($item['id'], 'pending', 'processing');\n                \n                $this->unlock();\n                return ['job' => $job, 'id' => $item['id']];\n            }\n            \n            $this->unlock();\n            return null;\n            \n        } catch (\\Exception $e) {\n            $this->unlock();\n            throw $e;\n        }\n    }\n    \n    /**\n     * Mark a job as completed\n     * \n     * @param string $queueId\n     * @return void\n     */\n    public function complete(string $queueId): void\n    {\n        $this->moveQueueItem($queueId, 'processing', 'completed');\n        \n        // Clean up old completed items\n        $this->cleanupCompleted();\n    }\n    \n    /**\n     * Mark a job as failed\n     * \n     * @param string $queueId\n     * @param string $error\n     * @return void\n     */\n    public function fail(string $queueId, string $error = ''): void\n    {\n        $item = $this->getQueueItem($queueId, 'processing');\n        \n        if ($item) {\n            $item['attempts']++;\n            $item['last_error'] = $error;\n            $item['failed_at'] = date('c');\n            \n            if ($item['attempts'] < $item['max_attempts']) {\n                // Move back to pending for retry\n                $item['retry_at'] = $this->calculateRetryTime($item['attempts']);\n                $item['scheduled_for'] = $item['retry_at'];\n                $this->writeQueueItem($item, 'pending');\n                $this->deleteQueueItem($queueId, 'processing');\n            } else {\n                // Move to failed (dead letter queue)\n                $this->writeQueueItem($item, 'failed');\n                $this->deleteQueueItem($queueId, 'processing');\n            }\n        }\n    }\n    \n    /**\n     * Get queue size\n     * \n     * @return int\n     */\n    public function size(): int\n    {\n        return count($this->getPendingItems());\n    }\n    \n    /**\n     * Check if queue is empty\n     * \n     * @return bool\n     */\n    public function isEmpty(): bool\n    {\n        return $this->size() === 0;\n    }\n    \n    /**\n     * Get queue statistics\n     * \n     * @return array\n     */\n    public function getStatistics(): array\n    {\n        return [\n            'pending' => count($this->getPendingItems()),\n            'processing' => count($this->getItemsInDirectory('processing')),\n            'failed' => count($this->getItemsInDirectory('failed')),\n            'completed_today' => $this->countCompletedToday(),\n        ];\n    }\n    \n    /**\n     * Generate a unique queue ID\n     * \n     * @param Job $job\n     * @return string\n     */\n    protected function generateQueueId(Job $job): string\n    {\n        return $job->getId() . '_' . uniqid('', true);\n    }\n    \n    /**\n     * Write queue item to disk\n     * \n     * @param array $item\n     * @param string $directory\n     * @return void\n     */\n    protected function writeQueueItem(array $item, string $directory): void\n    {\n        $path = $this->queuePath . '/' . $directory . '/' . $item['id'] . '.json';\n        $file = JsonFile::instance($path);\n        $file->save($item);\n    }\n    \n    /**\n     * Read queue item from disk\n     * \n     * @param string $queueId\n     * @param string $directory\n     * @return array|null\n     */\n    protected function getQueueItem(string $queueId, string $directory): ?array\n    {\n        $path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json';\n        \n        if (!file_exists($path)) {\n            return null;\n        }\n        \n        $file = JsonFile::instance($path);\n        return $file->content();\n    }\n    \n    /**\n     * Delete queue item\n     * \n     * @param string $queueId\n     * @param string $directory\n     * @return void\n     */\n    protected function deleteQueueItem(string $queueId, string $directory): void\n    {\n        $path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json';\n        \n        if (file_exists($path)) {\n            unlink($path);\n        }\n    }\n    \n    /**\n     * Move queue item between directories\n     * \n     * @param string $queueId\n     * @param string $fromDir\n     * @param string $toDir\n     * @return void\n     */\n    protected function moveQueueItem(string $queueId, string $fromDir, string $toDir): void\n    {\n        $fromPath = $this->queuePath . '/' . $fromDir . '/' . $queueId . '.json';\n        $toPath = $this->queuePath . '/' . $toDir . '/' . $queueId . '.json';\n        \n        if (file_exists($fromPath)) {\n            rename($fromPath, $toPath);\n        }\n    }\n    \n    /**\n     * Get all pending items\n     * \n     * @return array\n     */\n    protected function getPendingItems(): array\n    {\n        return $this->getItemsInDirectory('pending');\n    }\n    \n    /**\n     * Get items in a specific directory\n     * \n     * @param string $directory\n     * @return array\n     */\n    protected function getItemsInDirectory(string $directory): array\n    {\n        $items = [];\n        $path = $this->queuePath . '/' . $directory;\n        \n        if (!is_dir($path)) {\n            return $items;\n        }\n        \n        $files = glob($path . '/*.json');\n        foreach ($files as $file) {\n            $jsonFile = JsonFile::instance($file);\n            $items[] = $jsonFile->content();\n        }\n        \n        return $items;\n    }\n    \n    /**\n     * Reconstruct a job from queue item\n     * \n     * @param array $item\n     * @return Job|null\n     */\n    protected function reconstructJob(array $item): ?Job\n    {\n        if (isset($item['serialized_job'])) {\n            // Unserialize the job\n            try {\n                $job = unserialize(base64_decode($item['serialized_job']));\n                if ($job instanceof Job) {\n                    return $job;\n                }\n            } catch (\\Exception $e) {\n                // Failed to unserialize\n                return null;\n            }\n        }\n        \n        // Create a new job from command\n        if (isset($item['command'])) {\n            $args = $item['arguments'] ?? [];\n            $job = new Job($item['command'], $args, $item['job_id']);\n            return $job;\n        }\n        \n        return null;\n    }\n    \n    /**\n     * Calculate retry time with exponential backoff\n     * \n     * @param int $attempts\n     * @return string\n     */\n    protected function calculateRetryTime(int $attempts): string\n    {\n        $backoffSeconds = min(pow(2, $attempts) * 60, 3600); // Max 1 hour\n        $retryTime = new \\DateTime();\n        $retryTime->modify(\"+{$backoffSeconds} seconds\");\n        return $retryTime->format('c');\n    }\n    \n    /**\n     * Clean up old completed items\n     * \n     * @return void\n     */\n    protected function cleanupCompleted(): void\n    {\n        $items = $this->getItemsInDirectory('completed');\n        $cutoff = new \\DateTime('-24 hours');\n        \n        foreach ($items as $item) {\n            if (isset($item['created_at'])) {\n                $createdAt = new \\DateTime($item['created_at']);\n                if ($createdAt < $cutoff) {\n                    $this->deleteQueueItem($item['id'], 'completed');\n                }\n            }\n        }\n    }\n    \n    /**\n     * Count completed jobs today\n     * \n     * @return int\n     */\n    protected function countCompletedToday(): int\n    {\n        $items = $this->getItemsInDirectory('completed');\n        $today = new \\DateTime('today');\n        $count = 0;\n        \n        foreach ($items as $item) {\n            if (isset($item['created_at'])) {\n                $createdAt = new \\DateTime($item['created_at']);\n                if ($createdAt >= $today) {\n                    $count++;\n                }\n            }\n        }\n        \n        return $count;\n    }\n    \n    /**\n     * Acquire lock for queue operations\n     * \n     * @return bool\n     */\n    protected function lock(): bool\n    {\n        $attempts = 0;\n        $maxAttempts = 50; // 5 seconds total\n        \n        while ($attempts < $maxAttempts) {\n            // Check if lock file exists and is stale (older than 30 seconds)\n            if (file_exists($this->lockFile)) {\n                $lockAge = time() - filemtime($this->lockFile);\n                if ($lockAge > 30) {\n                    // Stale lock, remove it\n                    @unlink($this->lockFile);\n                }\n            }\n            \n            // Try to acquire lock atomically\n            $handle = @fopen($this->lockFile, 'x');\n            if ($handle !== false) {\n                fclose($handle);\n                return true;\n            }\n            \n            $attempts++;\n            usleep(100000); // 100ms\n        }\n        \n        // Could not acquire lock\n        return false;\n    }\n    \n    /**\n     * Release queue lock\n     * \n     * @return void\n     */\n    protected function unlock(): void\n    {\n        if (file_exists($this->lockFile)) {\n            unlink($this->lockFile);\n        }\n    }\n}"
  },
  {
    "path": "system/src/Grav/Common/Scheduler/Scheduler.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Scheduler\n * @author     Originally based on peppeocchi/php-cron-scheduler modified for Grav integration\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Scheduler;\n\nuse DateTime;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse InvalidArgumentException;\nuse Symfony\\Component\\Process\\PhpExecutableFinder;\nuse Symfony\\Component\\Process\\Process;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse Symfony\\Component\\Yaml\\Yaml;\nuse Monolog\\Logger;\nuse Monolog\\Handler\\StreamHandler;\nuse function is_callable;\nuse function is_string;\n\n/**\n * Class Scheduler\n * @package Grav\\Common\\Scheduler\n */\nclass Scheduler\n{\n    /** @var Job[] The queued jobs. */\n    private $jobs = [];\n\n    /** @var Job[] */\n    private $saved_jobs = [];\n\n    /** @var Job[] */\n    private $executed_jobs = [];\n\n    /** @var Job[] */\n    private $failed_jobs = [];\n\n    /** @var Job[] */\n    private $jobs_run = [];\n\n    /** @var array */\n    private $output_schedule = [];\n\n    /** @var array */\n    private $config;\n\n    /** @var string */\n    private $status_path;\n    \n    // Modern features (backward compatible - disabled by default)\n    /** @var JobQueue|null */\n    protected $jobQueue = null;\n    \n    /** @var array */\n    protected $workers = [];\n    \n    /** @var int */\n    protected $maxWorkers = 1;\n    \n    /** @var bool */\n    protected $webhookEnabled = false;\n    \n    /** @var string|null */\n    protected $webhookToken = null;\n    \n    /** @var bool */\n    protected $healthEnabled = true;\n    \n    /** @var string */\n    protected $queuePath;\n    \n    /** @var string */\n    protected $historyPath;\n    \n    /** @var Logger|null */\n    protected $logger = null;\n    \n    /** @var array */\n    protected $modernConfig = [];\n\n    /**\n     * Create new instance.\n     */\n    public function __construct()\n    {\n        $grav = Grav::instance();\n        $config = $grav['config']->get('scheduler.defaults', []);\n        $this->config = $config;\n\n        $locator = $grav['locator'];\n        $this->status_path = $locator->findResource('user-data://scheduler', true, true);\n        if (!file_exists($this->status_path)) {\n            Folder::create($this->status_path);\n        }\n        \n        // Initialize modern features (always enabled now)\n        $this->modernConfig = $grav['config']->get('scheduler.modern', []);\n        // Always initialize modern features - they're now part of core\n        $this->initializeModernFeatures($locator);\n    }\n\n    /**\n     * Load saved jobs from config/scheduler.yaml file\n     *\n     * @return $this\n     */\n    public function loadSavedJobs()\n    {\n        // Only load saved jobs if they haven't been loaded yet\n        if (!empty($this->saved_jobs)) {\n            return $this;\n        }\n        \n        $this->saved_jobs = [];\n        $saved_jobs = (array) Grav::instance()['config']->get('scheduler.custom_jobs', []);\n\n        foreach ($saved_jobs as $id => $j) {\n            $args = $j['args'] ?? [];\n            $id = Grav::instance()['inflector']->hyphenize($id);\n            \n            // Check if job already exists to prevent duplicates\n            $existingJob = null;\n            foreach ($this->jobs as $existingJobItem) {\n                if ($existingJobItem->getId() === $id) {\n                    $existingJob = $existingJobItem;\n                    break;\n                }\n            }\n            \n            if ($existingJob) {\n                // Job already exists, just update saved_jobs reference\n                $this->saved_jobs[] = $existingJob;\n                continue;\n            }\n            \n            $job = $this->addCommand($j['command'], $args, $id);\n\n            if (isset($j['at'])) {\n                $job->at($j['at']);\n            }\n\n            if (isset($j['output'])) {\n                $mode = isset($j['output_mode']) && $j['output_mode'] === 'append';\n                $job->output($j['output'], $mode);\n            }\n\n            if (isset($j['email'])) {\n                $job->email($j['email']);\n            }\n\n            // store in saved_jobs\n            $this->saved_jobs[] = $job;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Get the queued jobs as background/foreground\n     *\n     * @param bool $all\n     * @return array\n     */\n    public function getQueuedJobs($all = false)\n    {\n        $background = [];\n        $foreground = [];\n        foreach ($this->jobs as $job) {\n            if ($all || $job->getEnabled()) {\n                if ($job->runInBackground()) {\n                    $background[] = $job;\n                } else {\n                    $foreground[] = $job;\n                }\n            }\n        }\n        return [$background, $foreground];\n    }\n\n    /**\n     * Get the job queue\n     * \n     * @return JobQueue|null\n     */\n    public function getJobQueue(): ?JobQueue\n    {\n        return $this->jobQueue;\n    }\n    \n    /**\n     * Get all jobs if they are disabled or not as one array\n     *\n     * @return Job[]\n     */\n    public function getAllJobs()\n    {\n        [$background, $foreground] = $this->loadSavedJobs()->getQueuedJobs(true);\n\n        return array_merge($background, $foreground);\n    }\n\n    /**\n     * Get a specific Job based on id\n     *\n     * @param string $jobid\n     * @return Job|null\n     */\n    public function getJob($jobid)\n    {\n        $all = $this->getAllJobs();\n        foreach ($all as $job) {\n            if ($jobid == $job->getId()) {\n                return $job;\n            }\n        }\n        return null;\n    }\n\n    /**\n     * Queues a PHP function execution.\n     *\n     * @param  callable  $fn  The function to execute\n     * @param  array  $args  Optional arguments to pass to the php script\n     * @param  string|null  $id   Optional custom identifier\n     * @return Job\n     */\n    public function addFunction(callable $fn, $args = [], $id = null)\n    {\n        $job = new Job($fn, $args, $id);\n        $this->queueJob($job->configure($this->config));\n\n        return $job;\n    }\n\n    /**\n     * Queue a raw shell command.\n     *\n     * @param  string  $command  The command to execute\n     * @param  array  $args      Optional arguments to pass to the command\n     * @param  string|null  $id       Optional custom identifier\n     * @return Job\n     */\n    public function addCommand($command, $args = [], $id = null)\n    {\n        $job = new Job($command, $args, $id);\n        $this->queueJob($job->configure($this->config));\n\n        return $job;\n    }\n\n    /**\n     * Run the scheduler.\n     *\n     * @param DateTime|null $runTime Optional, run at specific moment\n     * @param bool $force force run even if not due\n     */\n    public function run(DateTime $runTime = null, $force = false)\n    {\n        // Initialize system jobs if not already done\n        $grav = Grav::instance();\n        if (count($this->jobs) === 0) {\n            // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)\n            $grav->fireEvent('onSchedulerInitialized', new \\RocketTheme\\Toolbox\\Event\\Event(['scheduler' => $this]));\n        }\n        \n        $this->loadSavedJobs();\n\n        [$background, $foreground] = $this->getQueuedJobs(false);\n        $alljobs = array_merge($background, $foreground);\n\n        if (null === $runTime) {\n            $runTime = new DateTime('now');\n        }\n\n        // Log scheduler run\n        if ($this->logger) {\n            $jobCount = count($alljobs);\n            $forceStr = $force ? ' (forced)' : '';\n            $this->logger->debug(\"Scheduler run started - {$jobCount} jobs available{$forceStr}\", [\n                'time' => $runTime->format('Y-m-d H:i:s')\n            ]);\n        }\n\n        // Process jobs based on modern features\n        if ($this->jobQueue && ($this->modernConfig['queue']['enabled'] ?? false)) {\n            // Queue jobs for processing\n            $queuedCount = 0;\n            foreach ($alljobs as $job) {\n                if ($job->isDue($runTime) || $force) {\n                    // Add to queue for concurrent processing\n                    $this->jobQueue->push($job);\n                    $queuedCount++;\n                }\n            }\n            \n            if ($this->logger && $queuedCount > 0) {\n                $this->logger->debug(\"Queued {$queuedCount} job(s) for processing\");\n            }\n            \n            // Process queue with workers\n            $this->processJobsWithWorkers();\n            \n            // When using queue, states are saved by executeJob when jobs complete\n            // Don't save states here as jobs may still be processing\n        } else {\n            // Legacy processing (one at a time)\n            foreach ($alljobs as $job) {\n                if ($job->isDue($runTime) || $force) {\n                    $job->run();\n                    $this->jobs_run[] = $job;\n                }\n            }\n            \n            // Finish handling any background jobs\n            foreach ($background as $job) {\n                $job->finalize();\n            }\n\n            // Store states for legacy mode\n            $this->saveJobStates();\n            \n            // Save history if enabled\n            if (($this->modernConfig['history']['enabled'] ?? false) && $this->historyPath) {\n                $this->saveJobHistory();\n            }\n        }\n\n        // Log run summary\n        if ($this->logger) {\n            $successCount = 0;\n            $failureCount = 0;\n            $failedJobNames = [];\n            $executedJobs = array_merge($this->executed_jobs, $this->jobs_run);\n            \n            foreach ($executedJobs as $job) {\n                if ($job->isSuccessful()) {\n                    $successCount++;\n                } else {\n                    $failureCount++;\n                    $failedJobNames[] = $job->getId();\n                }\n            }\n            \n            if (count($executedJobs) > 0) {\n                if ($failureCount > 0) {\n                    $failedList = implode(', ', $failedJobNames);\n                    $this->logger->warning(\"Scheduler completed: {$successCount} succeeded, {$failureCount} failed (failed: {$failedList})\");\n                } else {\n                    $this->logger->info(\"Scheduler completed: {$successCount} job(s) succeeded\");\n                }\n            } else {\n                $this->logger->debug('Scheduler completed: no jobs were due');\n            }\n        }\n\n        // Store run date\n        file_put_contents(\"logs/lastcron.run\", (new DateTime(\"now\"))->format(\"Y-m-d H:i:s\"), LOCK_EX);\n        \n        // Update last run timestamp for health checks\n        $this->updateLastRun();\n    }\n\n    /**\n     * Reset all collected data of last run.\n     *\n     * Call before run() if you call run() multiple times.\n     *\n     * @return $this\n     */\n    public function resetRun()\n    {\n        // Reset collected data of last run\n        $this->executed_jobs = [];\n        $this->failed_jobs = [];\n        $this->output_schedule = [];\n\n        return $this;\n    }\n\n    /**\n     * Get the scheduler verbose output.\n     *\n     * @param  string  $type  Allowed: text, html, array\n     * @return string|array  The return depends on the requested $type\n     */\n    public function getVerboseOutput($type = 'text')\n    {\n        switch ($type) {\n            case 'text':\n                return implode(\"\\n\", $this->output_schedule);\n            case 'html':\n                return implode('<br>', $this->output_schedule);\n            case 'array':\n                return $this->output_schedule;\n            default:\n                throw new InvalidArgumentException('Invalid output type');\n        }\n    }\n\n    /**\n     * Remove all queued Jobs.\n     *\n     * @return $this\n     */\n    public function clearJobs()\n    {\n        $this->jobs = [];\n\n        return $this;\n    }\n\n    /**\n     * Helper to get the full Cron command\n     *\n     * @return string\n     */\n    public function getCronCommand()\n    {\n        $command = $this->getSchedulerCommand();\n\n        return \"(crontab -l; echo \\\"* * * * * {$command} 1>> /dev/null 2>&1\\\") | crontab -\";\n    }\n\n    /**\n     * @param string|null $php\n     * @return string\n     */\n    public function getSchedulerCommand($php = null)\n    {\n        $phpBinaryFinder = new PhpExecutableFinder();\n        $php = $php ?? $phpBinaryFinder->find();\n        $command = 'cd ' . str_replace(' ', '\\ ', GRAV_ROOT) . ';' . $php . ' bin/grav scheduler';\n\n        return $command;\n    }\n\n    /**\n     * Helper to determine if cron-like job is setup\n     * 0 - Crontab Not found\n     * 1 - Crontab Found\n     * 2 - Error\n     *\n     * @return int\n     */\n    public function isCrontabSetup()\n    {\n        // Check for external triggers\n        $last_run = @file_get_contents(\"logs/lastcron.run\");\n        if (time() - strtotime($last_run) < 120){\n            return 1;\n        }\n\n        // No external triggers found, so do legacy cron checks\n        $process = new Process(['crontab', '-l']);\n        $process->run();\n\n        if ($process->isSuccessful()) {\n            $output = $process->getOutput();\n            $command = str_replace('/', '\\/', $this->getSchedulerCommand('.*'));\n            $full_command = '/^(?!#).* .* .* .* .* ' . $command . '/m';\n\n            return  preg_match($full_command, $output) ? 1 : 0;\n        }\n\n        $error = $process->getErrorOutput();\n\n        return Utils::startsWith($error, 'crontab: no crontab') ? 0 : 2;\n    }\n\n    /**\n     * Get the Job states file\n     *\n     * @return YamlFile\n     */\n    public function getJobStates()\n    {\n        return YamlFile::instance($this->status_path . '/status.yaml');\n    }\n\n    /**\n     * Save job states to statys file\n     *\n     * @return void\n     */\n    private function saveJobStates()\n    {\n        $now = time();\n        $new_states = [];\n\n        foreach ($this->jobs_run as $job) {\n            if ($job->isSuccessful()) {\n                $new_states[$job->getId()] = ['state' => 'success', 'last-run' => $now];\n                $this->pushExecutedJob($job);\n            } else {\n                $new_states[$job->getId()] = ['state' => 'failure', 'last-run' => $now, 'error' => $job->getOutput()];\n                $this->pushFailedJob($job);\n            }\n        }\n\n        $saved_states = $this->getJobStates();\n        $saved_states->save(array_merge($saved_states->content(), $new_states));\n    }\n\n    /**\n     * Try to determine who's running the process\n     *\n     * @return false|string\n     */\n    public function whoami()\n    {\n        $process = new Process(['whoami']);\n        $process->run();\n\n        if ($process->isSuccessful()) {\n            return trim($process->getOutput());\n        }\n\n        return $process->getErrorOutput();\n    }\n\n\n    /**\n     * Initialize modern features\n     * \n     * @param mixed $locator\n     * @return void\n     */\n    protected function initializeModernFeatures($locator): void\n    {\n        // Set up paths\n        $this->queuePath = $this->modernConfig['queue']['path'] ?? 'user-data://scheduler/queue';\n        $this->queuePath = $locator->findResource($this->queuePath, true, true);\n        \n        $this->historyPath = $this->modernConfig['history']['path'] ?? 'user-data://scheduler/history';\n        $this->historyPath = $locator->findResource($this->historyPath, true, true);\n        \n        // Create directories if they don't exist\n        if (!file_exists($this->queuePath)) {\n            Folder::create($this->queuePath);\n        }\n        \n        if (!file_exists($this->historyPath)) {\n            Folder::create($this->historyPath);\n        }\n        \n        // Initialize job queue (always enabled)\n        $this->jobQueue = new JobQueue($this->queuePath);\n        \n        // Initialize scheduler logger\n        $this->initializeLogger($locator);\n        \n        // Configure workers (default to 4 for concurrent processing)\n        $this->maxWorkers = $this->modernConfig['workers'] ?? 4;\n        \n        // Configure webhook\n        $this->webhookEnabled = $this->modernConfig['webhook']['enabled'] ?? false;\n        $this->webhookToken = $this->modernConfig['webhook']['token'] ?? null;\n        \n        // Configure health check\n        $this->healthEnabled = $this->modernConfig['health']['enabled'] ?? true;\n    }\n    \n    /**\n     * Get the job queue\n     * \n     * @return JobQueue|null\n     */\n    public function getQueue(): ?JobQueue\n    {\n        return $this->jobQueue;\n    }\n    \n    /**\n     * Initialize the scheduler logger\n     * \n     * @param $locator\n     * @return void\n     */\n    protected function initializeLogger($locator): void\n    {\n        $this->logger = new Logger('scheduler');\n        \n        // Single scheduler log file - all levels\n        $logFile = $locator->findResource('log://scheduler.log', true, true);\n        $this->logger->pushHandler(new StreamHandler($logFile, Logger::DEBUG));\n    }\n    \n    /**\n     * Get the scheduler logger\n     * \n     * @return Logger|null\n     */\n    public function getLogger(): ?Logger\n    {\n        return $this->logger;\n    }\n    \n    /**\n     * Check if webhook is enabled\n     *\n     * @return bool\n     */\n    public function isWebhookEnabled(): bool\n    {\n        // Requires both: the config toggle enabled AND the scheduler-webhook plugin installed\n        if (!$this->webhookEnabled) {\n            return false;\n        }\n\n        $grav = Grav::instance();\n        return (bool) $grav['config']->get('plugins.scheduler-webhook.enabled', false);\n    }\n\n    /**\n     * Check if the scheduler-webhook plugin is installed and enabled\n     *\n     * @return bool\n     */\n    public function isWebhookPluginReady(): bool\n    {\n        $grav = Grav::instance();\n        return (bool) $grav['config']->get('plugins.scheduler-webhook.enabled', false);\n    }\n    \n    /**\n     * Get active trigger methods\n     * \n     * @return array\n     */\n    public function getActiveTriggers(): array\n    {\n        $triggers = [];\n        \n        $cronStatus = $this->isCrontabSetup();\n        if ($cronStatus === 1) {\n            $triggers[] = 'cron';\n        }\n        \n        // Check if webhook is enabled\n        if ($this->isWebhookEnabled()) {\n            $triggers[] = 'webhook';\n        }\n        \n        return $triggers;\n    }\n    \n    /**\n     * Queue a job for execution in the correct queue.\n     *\n     * @param  Job  $job\n     * @return void\n     */\n    private function queueJob(Job $job)\n    {\n        $this->jobs[] = $job;\n\n        // Store jobs\n    }\n\n    /**\n     * Add an entry to the scheduler verbose output array.\n     *\n     * @param  string  $string\n     * @return void\n     */\n    private function addSchedulerVerboseOutput($string)\n    {\n        $now = '[' . (new DateTime('now'))->format('c') . '] ';\n        $this->output_schedule[] = $now . $string;\n        // Print to stdoutput in light gray\n        // echo \"\\033[37m{$string}\\033[0m\\n\";\n    }\n\n    /**\n     * Push a succesfully executed job.\n     *\n     * @param  Job  $job\n     * @return Job\n     */\n    private function pushExecutedJob(Job $job)\n    {\n        $this->executed_jobs[] = $job;\n        $command = $job->getCommand();\n        $args = $job->getArguments();\n        // If callable, log the string Closure\n        if (is_callable($command)) {\n            $command = is_string($command) ? $command : 'Closure';\n        }\n        $this->addSchedulerVerboseOutput(\"<green>Success</green>: <white>{$command} {$args}</white>\");\n\n        return $job;\n    }\n\n    /**\n     * Push a failed job.\n     *\n     * @param  Job  $job\n     * @return Job\n     */\n    private function pushFailedJob(Job $job)\n    {\n        $this->failed_jobs[] = $job;\n        $command = $job->getCommand();\n        // If callable, log the string Closure\n        if (is_callable($command)) {\n            $command = is_string($command) ? $command : 'Closure';\n        }\n        $output = trim($job->getOutput());\n        $this->addSchedulerVerboseOutput(\"<red>Error</red>:   <white>{$command}</white> → <normal>{$output}</normal>\");\n\n        return $job;\n    }\n    \n    /**\n     * Process jobs using multiple workers\n     * \n     * @return void\n     */\n    protected function processJobsWithWorkers(): void\n    {\n        if (!$this->jobQueue) {\n            return;\n        }\n        \n        // Process all queued jobs\n        while (!$this->jobQueue->isEmpty()) {\n            // Wait if we've reached max workers\n            while (count($this->workers) >= $this->maxWorkers) {\n                foreach ($this->workers as $workerId => $worker) {\n                    $process = null;\n                    if (is_array($worker) && isset($worker['process'])) {\n                        $process = $worker['process'];\n                    } elseif ($worker instanceof Process) {\n                        $process = $worker;\n                    }\n                    \n                    if ($process instanceof Process && !$process->isRunning()) {\n                        // Finalize job if needed\n                        if (is_array($worker) && isset($worker['job'])) {\n                            $worker['job']->finalize();\n                            \n                            // Save job state\n                            $this->saveJobState($worker['job']);\n                            \n                            // Update queue status\n                            if (isset($worker['queueId']) && $this->jobQueue) {\n                                if ($worker['job']->isSuccessful()) {\n                                    $this->jobQueue->complete($worker['queueId']);\n                                } else {\n                                    $this->jobQueue->fail($worker['queueId'], $worker['job']->getOutput() ?: 'Job failed');\n                                }\n                            }\n                        }\n                        unset($this->workers[$workerId]);\n                    }\n                }\n                if (count($this->workers) >= $this->maxWorkers) {\n                    usleep(100000); // Wait 100ms\n                }\n            }\n            \n            // Get next job from queue\n            $queueItem = $this->jobQueue->popWithId();\n            if ($queueItem) {\n                $this->executeJob($queueItem['job'], $queueItem['id']);\n            }\n        }\n        \n        // Wait for all remaining workers to complete\n        foreach ($this->workers as $workerId => $worker) {\n            if (is_array($worker) && isset($worker['process'])) {\n                $process = $worker['process'];\n                if ($process instanceof Process) {\n                    $process->wait();\n                    \n                    // Finalize and save state for background jobs\n                    if (isset($worker['job'])) {\n                        $worker['job']->finalize();\n                        $this->saveJobState($worker['job']);\n                        \n                        // Log background job completion\n                        if ($this->logger) {\n                            $job = $worker['job'];\n                            $jobId = $job->getId();\n                            $command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure';\n                            \n                            if ($job->isSuccessful()) {\n                                $execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null;\n                                $timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : '';\n                                $this->logger->info(\"Job '{$jobId}' completed successfully{$timeStr}\", [\n                                    'command' => $command,\n                                    'background' => true\n                                ]);\n                            } else {\n                                $error = trim($job->getOutput()) ?: 'Unknown error';\n                                $this->logger->error(\"Job '{$jobId}' failed: {$error}\", [\n                                    'command' => $command,\n                                    'background' => true\n                                ]);\n                            }\n                        }\n                    }\n                    \n                    // Update queue status for background jobs\n                    if (isset($worker['queueId']) && $this->jobQueue) {\n                        $job = $worker['job'];\n                        if ($job->isSuccessful()) {\n                            $this->jobQueue->complete($worker['queueId']);\n                        } else {\n                            $this->jobQueue->fail($worker['queueId'], $job->getOutput() ?: 'Job execution failed');\n                        }\n                    }\n                    \n                    unset($this->workers[$workerId]);\n                }\n            } elseif ($worker instanceof Process) {\n                // Legacy format\n                $worker->wait();\n                unset($this->workers[$workerId]);\n            }\n        }\n    }\n    \n    /**\n     * Process existing queued jobs\n     * \n     * @return void\n     */\n    protected function processQueuedJobs(): void\n    {\n        if (!$this->jobQueue) {\n            return;\n        }\n        \n        // Process any existing queued jobs from previous runs\n        while (!$this->jobQueue->isEmpty() && count($this->workers) < $this->maxWorkers) {\n            $job = $this->jobQueue->pop();\n            if ($job) {\n                $this->executeJob($job);\n            }\n        }\n    }\n    \n    /**\n     * Execute a job\n     * \n     * @param Job $job\n     * @param string|null $queueId Queue ID if job came from queue\n     * @return void\n     */\n    protected function executeJob(Job $job, ?string $queueId = null): void\n    {\n        $job->run();\n        $this->jobs_run[] = $job;\n        \n        // Save job state after execution\n        $this->saveJobState($job);\n        \n        // Check if job runs in background\n        if ($job->runInBackground()) {\n            // Background job - track it for later completion\n            $process = $job->getProcess();\n            if ($process && $process->isStarted()) {\n                $this->workers[] = [\n                    'process' => $process,\n                    'job' => $job,\n                    'queueId' => $queueId\n                ];\n                // Don't update queue status yet - will be done when process completes\n                return;\n            }\n        }\n        \n        // Foreground job or background job that didn't start - update queue status immediately\n        if ($queueId && $this->jobQueue) {\n            // Job has already been finalized if it ran in foreground\n            if (!$job->runInBackground()) {\n                $job->finalize();\n            }\n            \n            if ($job->isSuccessful()) {\n                // Move from processing to completed\n                $this->jobQueue->complete($queueId);\n            } else {\n                // Move from processing to failed\n                $this->jobQueue->fail($queueId, $job->getOutput() ?: 'Job execution failed');\n            }\n        }\n        \n        // Log foreground jobs immediately\n        if (!$job->runInBackground() && $this->logger) {\n            $jobId = $job->getId();\n            $command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure';\n            \n            if ($job->isSuccessful()) {\n                $execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null;\n                $timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : '';\n                $this->logger->info(\"Job '{$jobId}' completed successfully{$timeStr}\", [\n                    'command' => $command\n                ]);\n            } else {\n                $error = trim($job->getOutput()) ?: 'Unknown error';\n                $this->logger->error(\"Job '{$jobId}' failed: {$error}\", [\n                    'command' => $command\n                ]);\n            }\n        }\n    }\n    \n    /**\n     * Save state for a single job\n     * \n     * @param Job $job\n     * @return void\n     */\n    protected function saveJobState(Job $job): void\n    {\n        $grav = Grav::instance();\n        $locator = $grav['locator'];\n        $statusFile = $locator->findResource('user-data://scheduler/status.yaml', true, true);\n        \n        $status = [];\n        if (file_exists($statusFile)) {\n            $status = Yaml::parseFile($statusFile) ?: [];\n        }\n        \n        // Update job status\n        $status[$job->getId()] = [\n            'state' => $job->isSuccessful() ? 'success' : 'failure',\n            'last-run' => time(),\n        ];\n        \n        // Add error if job failed\n        if (!$job->isSuccessful()) {\n            $output = $job->getOutput();\n            if ($output) {\n                $status[$job->getId()]['error'] = $output;\n            } else {\n                $status[$job->getId()]['error'] = null;\n            }\n        }\n        \n        file_put_contents($statusFile, Yaml::dump($status));\n    }\n    \n    /**\n     * Save job execution history\n     * \n     * @return void\n     */\n    protected function saveJobHistory(): void\n    {\n        if (!$this->historyPath) {\n            return;\n        }\n        \n        $history = [];\n        foreach ($this->jobs_run as $job) {\n            $history[] = [\n                'id' => $job->getId(),\n                'executed_at' => date('c'),\n                'success' => $job->isSuccessful(),\n                'output' => substr($job->getOutput(), 0, 1000),\n            ];\n        }\n        \n        if (!empty($history)) {\n            $filename = $this->historyPath . '/' . date('Y-m-d') . '.json';\n            $existing = file_exists($filename) ? json_decode(file_get_contents($filename), true) : [];\n            $existing = array_merge($existing, $history);\n            file_put_contents($filename, json_encode($existing, JSON_PRETTY_PRINT));\n        }\n    }\n    \n    /**\n     * Update last run timestamp\n     * \n     * @return void\n     */\n    protected function updateLastRun(): void\n    {\n        $lastRunFile = $this->status_path . '/last_run.txt';\n        file_put_contents($lastRunFile, date('Y-m-d H:i:s'));\n    }\n    \n    /**\n     * Get health status\n     * \n     * @return array\n     */\n    public function getHealthStatus(): array\n    {\n        $lastRunFile = $this->status_path . '/last_run.txt';\n        $lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null;\n        \n        // Initialize system jobs if not already done\n        $grav = Grav::instance();\n        if (count($this->jobs) === 0) {\n            // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)\n            $grav->fireEvent('onSchedulerInitialized', new \\RocketTheme\\Toolbox\\Event\\Event(['scheduler' => $this]));\n        }\n        \n        // Load custom jobs\n        $this->loadSavedJobs();\n        \n        // Get only enabled jobs for health status\n        [$background, $foreground] = $this->getQueuedJobs(false);\n        $enabledJobs = array_merge($background, $foreground);\n        \n        $now = new DateTime('now');\n        $dueJobs = 0;\n        \n        foreach ($enabledJobs as $job) {\n            if ($job->isDue($now)) {\n                $dueJobs++;\n            }\n        }\n        \n        $health = [\n            'status' => 'healthy',\n            'last_run' => $lastRun,\n            'last_run_age' => null,\n            'queue_size' => 0,\n            'failed_jobs_24h' => 0,\n            'scheduled_jobs' => count($enabledJobs),\n            'jobs_due' => $dueJobs,\n            'webhook_enabled' => $this->isWebhookEnabled(),\n            'health_check_enabled' => $this->healthEnabled,\n            'timestamp' => date('c'),\n        ];\n        \n        // Calculate last run age\n        if ($lastRun) {\n            $lastRunTime = new DateTime($lastRun);\n            $health['last_run_age'] = $now->getTimestamp() - $lastRunTime->getTimestamp();\n        }\n        \n        // Determine status based on whether jobs are due\n        if ($dueJobs > 0) {\n            // Jobs are due but haven't been run\n            if ($health['last_run_age'] === null || $health['last_run_age'] > 300) { // No run or older than 5 minutes\n                $health['status'] = 'warning';\n                $health['message'] = $dueJobs . ' job(s) are due to run';\n            }\n        } else {\n            // No jobs are due - this is healthy\n            $health['status'] = 'healthy';\n            $health['message'] = 'No jobs currently due';\n        }\n        \n        // Add queue stats if available\n        if ($this->jobQueue) {\n            $stats = $this->jobQueue->getStatistics();\n            $health['queue_size'] = $stats['pending'] ?? 0;\n            $health['failed_jobs_24h'] = $stats['failed'] ?? 0;\n        }\n        \n        return $health;\n    }\n    \n    /**\n     * Process webhook trigger\n     * \n     * @param string|null $token\n     * @param string|null $jobId\n     * @return array\n     */\n    public function processWebhookTrigger($token = null, $jobId = null): array\n    {\n        if (!$this->webhookEnabled) {\n            return ['success' => false, 'message' => 'Webhook triggers are not enabled'];\n        }\n        \n        if ($this->webhookToken && $token !== $this->webhookToken) {\n            return ['success' => false, 'message' => 'Invalid webhook token'];\n        }\n        \n        // Initialize system jobs if not already done\n        $grav = Grav::instance();\n        if (count($this->jobs) === 0) {\n            // Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)\n            $grav->fireEvent('onSchedulerInitialized', new \\RocketTheme\\Toolbox\\Event\\Event(['scheduler' => $this]));\n        }\n        \n        // Load custom jobs\n        $this->loadSavedJobs();\n        \n        if ($jobId) {\n            // Force run specific job\n            $job = $this->getJob($jobId);\n            if ($job) {\n                $job->inForeground()->run();\n                $this->jobs_run[] = $job;\n                $this->saveJobStates();\n                $this->updateLastRun();\n                \n                return [\n                    'success' => $job->isSuccessful(),\n                    'message' => $job->isSuccessful() ? 'Job force-executed successfully' : 'Job execution failed',\n                    'job_id' => $jobId,\n                    'forced' => true,\n                    'output' => $job->getOutput(),\n                ];\n            } else {\n                return ['success' => false, 'message' => 'Job not found: ' . $jobId];\n            }\n        } else {\n            // Run all due jobs\n            $this->run();\n            \n            return [\n                'success' => true,\n                'message' => 'Scheduler executed (due jobs only)',\n                'jobs_run' => count($this->jobs_run),\n                'timestamp' => date('c'),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Scheduler/SchedulerController.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Scheduler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Scheduler;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\n\n/**\n * Scheduler Controller for handling HTTP endpoints\n * \n * @package Grav\\Common\\Scheduler\n */\nclass SchedulerController\n{\n    /** @var Grav */\n    protected $grav;\n    \n    /** @var ModernScheduler */\n    protected $scheduler;\n    \n    /**\n     * SchedulerController constructor\n     * \n     * @param Grav $grav\n     */\n    public function __construct(Grav $grav)\n    {\n        $this->grav = $grav;\n        \n        // Get scheduler instance\n        $scheduler = $grav['scheduler'];\n        if ($scheduler instanceof ModernScheduler) {\n            $this->scheduler = $scheduler;\n        } else {\n            // Create ModernScheduler instance if not already\n            $this->scheduler = new ModernScheduler();\n        }\n    }\n    \n    /**\n     * Handle health check endpoint\n     * \n     * @param ServerRequestInterface $request\n     * @return ResponseInterface\n     */\n    public function health(ServerRequestInterface $request): ResponseInterface\n    {\n        $config = $this->grav['config']->get('scheduler.modern', []);\n        \n        // Check if health endpoint is enabled\n        if (!($config['health']['enabled'] ?? true)) {\n            return $this->jsonResponse(['error' => 'Health check disabled'], 403);\n        }\n        \n        // Get health status\n        $health = $this->scheduler->getHealthStatus();\n        \n        return $this->jsonResponse($health);\n    }\n    \n    /**\n     * Handle webhook trigger endpoint\n     * \n     * @param ServerRequestInterface $request\n     * @return ResponseInterface\n     */\n    public function webhook(ServerRequestInterface $request): ResponseInterface\n    {\n        $config = $this->grav['config']->get('scheduler.modern', []);\n        \n        // Check if webhook is enabled\n        if (!($config['webhook']['enabled'] ?? false)) {\n            return $this->jsonResponse(['error' => 'Webhook triggers disabled'], 403);\n        }\n        \n        // Get authorization header\n        $authHeader = $request->getHeaderLine('Authorization');\n        $token = null;\n        \n        if (preg_match('/Bearer\\s+(.+)$/i', $authHeader, $matches)) {\n            $token = $matches[1];\n        }\n        \n        // Get query parameters\n        $params = $request->getQueryParams();\n        $jobId = $params['job'] ?? null;\n        \n        // Process webhook\n        $result = $this->scheduler->processWebhookTrigger($token, $jobId);\n        \n        $statusCode = $result['success'] ? 200 : 400;\n        return $this->jsonResponse($result, $statusCode);\n    }\n    \n    /**\n     * Handle statistics endpoint\n     * \n     * @param ServerRequestInterface $request\n     * @return ResponseInterface\n     */\n    public function statistics(ServerRequestInterface $request): ResponseInterface\n    {\n        // Check if user is admin\n        $user = $this->grav['user'] ?? null;\n        if (!$user || !$user->authorize('admin.super')) {\n            return $this->jsonResponse(['error' => 'Unauthorized'], 401);\n        }\n        \n        $stats = $this->scheduler->getStatistics();\n        \n        return $this->jsonResponse($stats);\n    }\n    \n    /**\n     * Handle admin AJAX requests for scheduler status\n     * \n     * @param ServerRequestInterface $request\n     * @return ResponseInterface\n     */\n    public function adminStatus(ServerRequestInterface $request): ResponseInterface\n    {\n        // Check if user is admin\n        $user = $this->grav['user'] ?? null;\n        if (!$user || !$user->authorize('admin.scheduler')) {\n            return $this->jsonResponse(['error' => 'Unauthorized'], 401);\n        }\n        \n        $health = $this->scheduler->getHealthStatus();\n        \n        // Format for admin display\n        $response = [\n            'health' => $this->formatHealthStatus($health),\n            'triggers' => $this->formatTriggers($health['trigger_methods'] ?? [])\n        ];\n        \n        return $this->jsonResponse($response);\n    }\n    \n    /**\n     * Format health status for display\n     * \n     * @param array $health\n     * @return string\n     */\n    protected function formatHealthStatus(array $health): string\n    {\n        $status = $health['status'] ?? 'unknown';\n        $lastRun = $health['last_run'] ?? null;\n        $queueSize = $health['queue_size'] ?? 0;\n        $failedJobs = $health['failed_jobs_24h'] ?? 0;\n        $jobsDue = $health['jobs_due'] ?? 0;\n        $message = $health['message'] ?? '';\n        \n        $statusBadge = match($status) {\n            'healthy' => '<span class=\"badge badge-success\">Healthy</span>',\n            'warning' => '<span class=\"badge badge-warning\">Warning</span>',\n            'critical' => '<span class=\"badge badge-danger\">Critical</span>',\n            default => '<span class=\"badge badge-secondary\">Unknown</span>'\n        };\n        \n        $html = '<div class=\"scheduler-health\">';\n        $html .= '<p>Status: ' . $statusBadge;\n        if ($message) {\n            $html .= ' - ' . htmlspecialchars($message);\n        }\n        $html .= '</p>';\n        \n        if ($lastRun) {\n            $lastRunTime = new \\DateTime($lastRun);\n            $now = new \\DateTime();\n            $diff = $now->diff($lastRunTime);\n            \n            $timeAgo = '';\n            if ($diff->d > 0) {\n                $timeAgo = $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago';\n            } elseif ($diff->h > 0) {\n                $timeAgo = $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago';\n            } elseif ($diff->i > 0) {\n                $timeAgo = $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago';\n            } else {\n                $timeAgo = 'Less than a minute ago';\n            }\n            \n            $html .= '<p>Last Run: <strong>' . $timeAgo . '</strong></p>';\n        } else {\n            $html .= '<p>Last Run: <strong>Never</strong></p>';\n        }\n        \n        $html .= '<p>Jobs Due: <strong>' . $jobsDue . '</strong></p>';\n        $html .= '<p>Queue Size: <strong>' . $queueSize . '</strong></p>';\n        \n        if ($failedJobs > 0) {\n            $html .= '<p class=\"text-danger\">Failed Jobs (24h): <strong>' . $failedJobs . '</strong></p>';\n        }\n        \n        $html .= '</div>';\n        \n        return $html;\n    }\n    \n    /**\n     * Format triggers for display\n     * \n     * @param array $triggers\n     * @return string\n     */\n    protected function formatTriggers(array $triggers): string\n    {\n        if (empty($triggers)) {\n            return '<div class=\"alert alert-warning\">No active triggers detected. Please set up cron, systemd, or webhook triggers.</div>';\n        }\n        \n        $html = '<div class=\"scheduler-triggers\">';\n        $html .= '<ul class=\"list-unstyled\">';\n        \n        foreach ($triggers as $trigger) {\n            $icon = match($trigger) {\n                'cron' => '⏰',\n                'systemd' => '⚙️',\n                'webhook' => '🔗',\n                'external' => '🌐',\n                default => '•'\n            };\n            \n            $label = match($trigger) {\n                'cron' => 'Cron Job',\n                'systemd' => 'Systemd Timer',\n                'webhook' => 'Webhook Triggers',\n                'external' => 'External Triggers',\n                default => ucfirst($trigger)\n            };\n            \n            $html .= '<li>' . $icon . ' <strong>' . $label . '</strong> <span class=\"badge badge-success\">Active</span></li>';\n        }\n        \n        $html .= '</ul>';\n        $html .= '</div>';\n        \n        return $html;\n    }\n    \n    /**\n     * Create JSON response\n     * \n     * @param array $data\n     * @param int $statusCode\n     * @return ResponseInterface\n     */\n    protected function jsonResponse(array $data, int $statusCode = 200): ResponseInterface\n    {\n        $response = $this->grav['response'] ?? new \\Nyholm\\Psr7\\Response();\n        \n        $response = $response->withStatus($statusCode)\n                            ->withHeader('Content-Type', 'application/json');\n        \n        $body = $response->getBody();\n        $body->write(json_encode($data));\n        \n        return $response;\n    }\n}"
  },
  {
    "path": "system/src/Grav/Common/Security.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Exception;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Page\\Pages;\nuse Rhukster\\DomSanitizer\\DOMSanitizer;\nuse function chr;\nuse function count;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class Security\n * @package Grav\\Common\n */\nclass Security\n{\n    /**\n     * @param string $filepath\n     * @param array|null $options\n     * @return string|null\n     */\n    public static function detectXssFromSvgFile(string $filepath, array $options = null): ?string\n    {\n        if (file_exists($filepath) && Grav::instance()['config']->get('security.sanitize_svg')) {\n            $content = file_get_contents($filepath);\n\n            return static::detectXss($content, $options);\n        }\n\n        return null;\n    }\n\n    /**\n     * Sanitize SVG string for XSS code\n     *\n     * @param string $svg\n     * @return string\n     */\n    public static function sanitizeSvgString(string $svg): string\n    {\n        if (Grav::instance()['config']->get('security.sanitize_svg')) {\n            $sanitizer = new DOMSanitizer(DOMSanitizer::SVG);\n            $sanitized = $sanitizer->sanitize($svg);\n            if (is_string($sanitized)) {\n                $svg = $sanitized;\n            }\n        }\n\n        return $svg;\n    }\n\n    /**\n     * Sanitize SVG for XSS code\n     *\n     * @param string $file\n     * @return void\n     */\n    public static function sanitizeSVG(string $file): void\n    {\n        if (file_exists($file) && Grav::instance()['config']->get('security.sanitize_svg')) {\n            $sanitizer = new DOMSanitizer(DOMSanitizer::SVG);\n            $original_svg = file_get_contents($file);\n            $clean_svg = $sanitizer->sanitize($original_svg);\n\n            // Quarantine bad SVG files and throw exception\n            if ($clean_svg !== false ) {\n                file_put_contents($file, $clean_svg);\n            } else {\n                $quarantine_file = Utils::basename($file);\n                $quarantine_dir = 'log://quarantine';\n                Folder::mkdir($quarantine_dir);\n                file_put_contents(\"$quarantine_dir/$quarantine_file\", $original_svg);\n                unlink($file);\n                throw new Exception('SVG could not be sanitized, it has been moved to the logs/quarantine folder');\n            }\n        }\n    }\n\n    /**\n     * Detect XSS code in Grav pages\n     *\n     * @param Pages $pages\n     * @param bool $route\n     * @param callable|null $status\n     * @return array\n     */\n    public static function detectXssFromPages(Pages $pages, $route = true, callable $status = null)\n    {\n        $routes = $pages->getList(null, 0, true);\n\n        // Remove duplicate for homepage\n        unset($routes['/']);\n\n        $list = [];\n\n        // This needs Symfony 4.1 to work\n        $status && $status([\n            'type' => 'count',\n            'steps' => count($routes),\n        ]);\n\n        foreach (array_keys($routes) as $route) {\n            $status && $status([\n                'type' => 'progress',\n            ]);\n\n            try {\n                $page = $pages->find($route);\n                if ($page->exists()) {\n                    // call the content to load/cache it\n                    $header = (array) $page->header();\n                    $content = $page->value('content');\n\n                    $data = ['header' => $header, 'content' => $content];\n                    $results = static::detectXssFromArray($data);\n\n                    if (!empty($results)) {\n                        $list[$page->rawRoute()] = $results;\n                    }\n                }\n            } catch (Exception $e) {\n                continue;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * Detect XSS in an array or strings such as $_POST or $_GET\n     *\n     * @param array $array      Array such as $_POST or $_GET\n     * @param array|null $options Extra options to be passed.\n     * @param string $prefix    Prefix for returned values.\n     * @return array            Returns flatten list of potentially dangerous input values, such as 'data.content'.\n     */\n    public static function detectXssFromArray(array $array, string $prefix = '', array $options = null)\n    {\n        if (null === $options) {\n            $options = static::getXssDefaults();\n        }\n\n        $list = [[]];\n        foreach ($array as $key => $value) {\n            if (is_array($value)) {\n                $list[] = static::detectXssFromArray($value, $prefix . $key . '.', $options);\n            }\n            if ($result = static::detectXss($value, $options)) {\n                $list[] = [$prefix . $key => $result];\n            }\n        }\n\n        return array_merge(...$list);\n    }\n\n    /**\n     * Determine if string potentially has a XSS attack. This simple function does not catch all XSS and it is likely to\n     *\n     * return false positives because of it tags all potentially dangerous HTML tags and attributes without looking into\n     * their content.\n     *\n     * @param string|null $string The string to run XSS detection logic on\n     * @param array|null $options\n     * @return string|null       Type of XSS vector if the given `$string` may contain XSS, false otherwise.\n     *\n     * Copies the code from: https://github.com/symphonycms/xssfilter/blob/master/extension.driver.php#L138\n     */\n    public static function detectXss($string, array $options = null): ?string\n    {\n        // Skip any null or non string values\n        if (null === $string || !is_string($string) || empty($string)) {\n            return null;\n        }\n\n        if (null === $options) {\n            $options = static::getXssDefaults();\n        }\n\n        $enabled_rules = (array)($options['enabled_rules'] ?? null);\n        $dangerous_tags = (array)($options['dangerous_tags'] ?? null);\n        if (!$dangerous_tags) {\n            $enabled_rules['dangerous_tags'] = false;\n        }\n        $invalid_protocols = (array)($options['invalid_protocols'] ?? null);\n        if (!$invalid_protocols) {\n            $enabled_rules['invalid_protocols'] = false;\n        }\n        $enabled_rules = array_filter($enabled_rules, static function ($val) { return !empty($val); });\n        if (!$enabled_rules) {\n            return null;\n        }\n\n        // Keep a copy of the original string before cleaning up\n        $orig = $string;\n\n        // URL decode\n        $string = urldecode($string);\n\n        // Convert Hexadecimals\n        $string = (string)preg_replace_callback('!(&#|\\\\\\)[xX]([0-9a-fA-F]+);?!u', static function ($m) {\n            return chr(hexdec($m[2]));\n        }, $string);\n\n        // Clean up entities\n        $string = preg_replace('!(&#[0-9]+);?!u', '$1;', $string);\n\n        // Decode entities\n        $string = html_entity_decode($string, ENT_NOQUOTES | ENT_HTML5, 'UTF-8');\n\n        // Strip whitespace characters\n        $string = preg_replace('!\\s!u', ' ', $string);\n        $stripped = preg_replace('!\\s!u', '', $string);\n\n        // Set the patterns we'll test against\n        $patterns = [\n            // Match any attribute starting with \"on\" or xmlns\n            'on_events' => '#(<[^>]+[a-z\\x00-\\x20\\\"\\'\\/])(on[a-z]+|xmlns)\\s*=[\\s|\\'\\\"].*[\\s|\\'\\\"]>#iUu',\n\n            // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols\n            'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . ')(:|\\&\\#58)\\S.*?#iUu',\n\n            // Match -moz-bindings\n            'moz_binding' => '#-moz-binding[a-z\\x00-\\x20]*:#u',\n\n            // Match style attributes\n            'html_inline_styles' => '#(<[^>]+[a-z\\x00-\\x20\\\"\\'\\/])(style=[^>]*(url\\:|x\\:expression).*)>?#iUu',\n\n            // Match potentially dangerous tags\n            'dangerous_tags' => '#</*(' . implode('|', array_map('preg_quote', $dangerous_tags, ['#'])) . ')[^>]*>?#ui'\n        ];\n\n        // Iterate over rules and return label if fail\n        foreach ($patterns as $name => $regex) {\n            if (!empty($enabled_rules[$name])) {\n                if (preg_match($regex, $string) || preg_match($regex, $stripped) || preg_match($regex, $orig)) {\n                    return $name;\n                }\n            }\n        }\n\n        return null;\n    }\n\n    public static function getXssDefaults(): array\n    {\n        /** @var Config $config */\n        $config = Grav::instance()['config'];\n\n        return [\n            'enabled_rules' => $config->get('security.xss_enabled'),\n            'dangerous_tags' => array_map('trim', $config->get('security.xss_dangerous_tags')),\n            'invalid_protocols' => array_map('trim', $config->get('security.xss_invalid_protocols')),\n        ];\n    }\n\n    public static function cleanDangerousTwig(string $string): string\n    {\n        if ($string === '') {\n            return $string;\n        }\n\n        $bad_twig = [\n            'twig_array_map',\n            'twig_array_filter',\n            'call_user_func',\n            'registerUndefinedFunctionCallback',\n            'undefined_functions',\n            'twig.getFunction',\n            'core.setEscaper',\n            'twig.safe_functions',\n            'read_file',\n        ];\n        $string = preg_replace('/(({{\\s*|{%\\s*)[^}]*?(' . implode('|', $bad_twig) . ')[^}]*?(\\s*}}|\\s*%}))/i', '{# $1 #}', $string);\n        return $string;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/AccountsServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Header;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\User\\DataUser;\nuse Grav\\Common\\User\\User;\nuse Grav\\Events\\PermissionsRegisterEvent;\nuse Grav\\Framework\\Acl\\Permissions;\nuse Grav\\Framework\\Acl\\PermissionsReader;\nuse Grav\\Framework\\Flex\\Flex;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexIndexInterface;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse SplFileInfo;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\nuse function define;\nuse function defined;\nuse function is_array;\n\n/**\n * Class AccountsServiceProvider\n * @package Grav\\Common\\Service\n */\nclass AccountsServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['permissions'] = static function (Grav $container) {\n            /** @var Config $config */\n            $config = $container['config'];\n\n            $permissions = new Permissions();\n            $permissions->addTypes($config->get('permissions.types', []));\n\n            $array = $config->get('permissions.actions');\n            if (is_array($array)) {\n                $actions = PermissionsReader::fromArray($array, $permissions->getTypes());\n                $permissions->addActions($actions);\n            }\n\n            $event = new PermissionsRegisterEvent($permissions);\n            $container->dispatchEvent($event);\n\n            return $permissions;\n        };\n\n        $container['accounts'] = function (Container $container) {\n            $type = $this->initialize($container);\n\n            return $type === 'flex' ? $this->flexAccounts($container) : $this->regularAccounts($container);\n        };\n\n        $container['user_groups'] = static function (Container $container) {\n            /** @var Flex $flex */\n            $flex = $container['flex'];\n            $directory = $flex->getDirectory('user-groups');\n\n            return $directory ? $directory->getIndex() : null;\n        };\n\n        $container['users'] = $container->factory(static function (Container $container) {\n            user_error('Grav::instance()[\\'users\\'] is deprecated since Grav 1.6, use Grav::instance()[\\'accounts\\'] instead', E_USER_DEPRECATED);\n\n            return $container['accounts'];\n        });\n    }\n\n    /**\n     * @param Container $container\n     * @return string\n     */\n    protected function initialize(Container $container): string\n    {\n        $isDefined = defined('GRAV_USER_INSTANCE');\n        $type = strtolower($isDefined ? GRAV_USER_INSTANCE : $container['config']->get('system.accounts.type', 'regular'));\n\n        if ($type === 'flex') {\n            if (!$isDefined) {\n                define('GRAV_USER_INSTANCE', 'FLEX');\n            }\n\n            /** @var EventDispatcher $dispatcher */\n            $dispatcher = $container['events'];\n\n            // Stop /admin/user from working, display error instead.\n            $dispatcher->addListener(\n                'onAdminPage',\n                static function (Event $event) {\n                    $grav = Grav::instance();\n                    $admin = $grav['admin'];\n                    [$base,$location,] = $admin->getRouteDetails();\n                    if ($location !== 'user' || isset($grav['flex_objects'])) {\n                        return;\n                    }\n\n                    /** @var PageInterface $page */\n                    $page = $event['page'];\n                    $page->init(new SplFileInfo('plugin://admin/pages/admin/error.md'));\n                    $page->routable(true);\n                    $header = $page->header();\n                    $header->title = 'Please install missing plugin';\n                    $page->content(\"## Please install and enable **[Flex Objects]({$base}/plugins/flex-objects)** plugin. It is required to edit **Flex User Accounts**.\");\n\n                    /** @var Header $header */\n                    $header = $page->header();\n                    $directory = $grav['accounts']->getFlexDirectory();\n                    $menu = $directory->getConfig('admin.menu.list');\n                    $header->access = $menu['authorize'] ?? ['admin.super'];\n                },\n                100000\n            );\n        } elseif (!$isDefined) {\n            define('GRAV_USER_INSTANCE', 'REGULAR');\n        }\n\n        return $type;\n    }\n\n    /**\n     * @param Container $container\n     * @return DataUser\\UserCollection\n     */\n    protected function regularAccounts(Container $container)\n    {\n        // Use User class for backwards compatibility.\n        return new DataUser\\UserCollection(User::class);\n    }\n\n    /**\n     * @param Container $container\n     * @return FlexIndexInterface|null\n     */\n    protected function flexAccounts(Container $container)\n    {\n        /** @var Flex $flex */\n        $flex = $container['flex'];\n        $directory = $flex->getDirectory('user-accounts');\n\n        return $directory ? $directory->getIndex() : null;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/AssetsServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\nuse Grav\\Common\\Assets;\n\n/**\n * Class AssetsServiceProvider\n * @package Grav\\Common\\Service\n */\nclass AssetsServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['assets'] = function () {\n            return new Assets();\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/BackupsServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Backup\\Backups;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\n\n/**\n * Class BackupsServiceProvider\n * @package Grav\\Common\\Service\n */\nclass BackupsServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['backups'] = function () {\n            $backups = new Backups();\n            $backups->setup();\n\n            return $backups;\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/ConfigServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse DirectoryIterator;\nuse Grav\\Common\\Config\\CompiledBlueprints;\nuse Grav\\Common\\Config\\CompiledConfig;\nuse Grav\\Common\\Config\\CompiledLanguages;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Config\\ConfigFileFinder;\nuse Grav\\Common\\Config\\Setup;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Framework\\Mime\\MimeTypes;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n/**\n * Class ConfigServiceProvider\n * @package Grav\\Common\\Service\n */\nclass ConfigServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['setup'] = function ($c) {\n            $setup = new Setup($c);\n            $setup->init();\n\n            return $setup;\n        };\n\n        $container['blueprints'] = function ($c) {\n            return static::blueprints($c);\n        };\n\n        $container['config'] = function ($c) {\n            $config = static::load($c);\n\n            // After configuration has been loaded, we can disable YAML compatibility if strict mode has been enabled.\n            if (!$config->get('system.strict_mode.yaml_compat', true)) {\n                YamlFile::globalSettings(['compat' => false, 'native' => true]);\n            }\n\n            return $config;\n        };\n\n        $container['mime'] = function ($c) {\n            /** @var Config $config */\n            $config = $c['config'];\n            $mimes = $config->get('mime.types', []);\n            foreach ($config->get('media.types', []) as $ext => $media) {\n                if (!empty($media['mime'])) {\n                    $mimes[$ext] = array_unique(array_merge([$media['mime']], $mimes[$ext] ?? []));\n                }\n            }\n\n            return MimeTypes::createFromMimes($mimes);\n        };\n\n        $container['languages'] = function ($c) {\n            return static::languages($c);\n        };\n\n        $container['language'] = function ($c) {\n            return new Language($c);\n        };\n    }\n\n    /**\n     * @param Container $container\n     * @return mixed\n     */\n    public static function blueprints(Container $container)\n    {\n        /** Setup $setup */\n        $setup = $container['setup'];\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $container['locator'];\n\n        $cache =  $locator->findResource('cache://compiled/blueprints', true, true);\n\n        $files = [];\n        $paths = $locator->findResources('blueprints://config');\n        $files += (new ConfigFileFinder)->locateFiles($paths);\n        $paths = $locator->findResources('plugins://');\n        $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints');\n        $paths = $locator->findResources('themes://');\n        $files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths, 'blueprints');\n\n        $blueprints = new CompiledBlueprints($cache, $files, GRAV_ROOT);\n\n        return $blueprints->name(\"master-{$setup->environment}\")->load();\n    }\n\n    /**\n     * @param Container $container\n     * @return Config\n     */\n    public static function load(Container $container)\n    {\n        /** Setup $setup */\n        $setup = $container['setup'];\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $container['locator'];\n\n        $cache =  $locator->findResource('cache://compiled/config', true, true);\n\n        $files = [];\n        $paths = $locator->findResources('config://');\n        $files += (new ConfigFileFinder)->locateFiles($paths);\n        $paths = $locator->findResources('plugins://');\n        $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths);\n        $paths = $locator->findResources('themes://');\n        $files += (new ConfigFileFinder)->setBase('themes')->locateInFolders($paths);\n\n        $compiled = new CompiledConfig($cache, $files, GRAV_ROOT);\n        $compiled->setBlueprints(function () use ($container) {\n            return $container['blueprints'];\n        });\n\n        $config = $compiled->name(\"master-{$setup->environment}\")->load();\n        $config->environment = $setup->environment;\n\n        return $config;\n    }\n\n    /**\n     * @param Container $container\n     * @return mixed\n     */\n    public static function languages(Container $container)\n    {\n        /** @var Setup $setup */\n        $setup = $container['setup'];\n\n        /** @var Config $config */\n        $config = $container['config'];\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $container['locator'];\n\n        $cache = $locator->findResource('cache://compiled/languages', true, true);\n        $files = [];\n\n        // Process languages only if enabled in configuration.\n        if ($config->get('system.languages.translations', true)) {\n            $paths = $locator->findResources('languages://');\n            $files += (new ConfigFileFinder)->locateFiles($paths);\n            $paths = $locator->findResources('plugins://');\n            $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'languages');\n            $paths = static::pluginFolderPaths($paths, 'languages');\n            $files += (new ConfigFileFinder)->locateFiles($paths);\n        }\n\n        $languages = new CompiledLanguages($cache, $files, GRAV_ROOT);\n\n        return $languages->name(\"master-{$setup->environment}\")->load();\n    }\n\n    /**\n     * Find specific paths in plugins\n     *\n     * @param array $plugins\n     * @param string $folder_path\n     * @return array\n     */\n    protected static function pluginFolderPaths($plugins, $folder_path)\n    {\n        $paths = [];\n\n        foreach ($plugins as $path) {\n            $iterator = new DirectoryIterator($path);\n\n            /** @var DirectoryIterator $directory */\n            foreach ($iterator as $directory) {\n                if (!$directory->isDir() || $directory->isDot()) {\n                    continue;\n                }\n\n                // Path to the languages folder\n                $lang_path = $directory->getPathName() . '/' . $folder_path;\n\n                // If this folder exists, add it to the list of paths\n                if (file_exists($lang_path)) {\n                    $paths []= $lang_path;\n                }\n            }\n        }\n        return $paths;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/ErrorServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Errors\\Errors;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\n\n/**\n * Class ErrorServiceProvider\n * @package Grav\\Common\\Service\n */\nclass ErrorServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['errors'] = new Errors;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/FilesystemServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\n\n/**\n * Class FilesystemServiceProvider\n * @package Grav\\Common\\Service\n */\nclass FilesystemServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['filesystem'] = function () {\n            return Filesystem::getInstance();\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/FlexServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Flex\\Types\\Users\\Storage\\UserFileStorage;\nuse Grav\\Common\\Flex\\Types\\Users\\Storage\\UserFolderStorage;\nuse Grav\\Common\\Grav;\nuse Grav\\Events\\FlexRegisterEvent;\nuse Grav\\Framework\\Flex\\Flex;\nuse Grav\\Framework\\Flex\\FlexFormFlash;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\nuse function is_array;\n\n/**\n * Class FlexServiceProvider\n * @package Grav\\Common\\Service\n */\nclass FlexServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['flex'] = function (Grav $container) {\n            /** @var Config $config */\n            $config = $container['config'];\n\n            $flex = new Flex([], ['object' => $config->get('system.flex', [])]);\n            FlexFormFlash::setFlex($flex);\n\n            $accountsEnabled = $config->get('system.accounts.type', 'regular') === 'flex';\n            $pagesEnabled = $config->get('system.pages.type', 'regular') === 'flex';\n\n            // Add built-in types from Grav.\n            if ($pagesEnabled) {\n                $flex->addDirectoryType(\n                    'pages',\n                    'blueprints://flex/pages.yaml',\n                    [\n                        'enabled' => $pagesEnabled\n                    ]\n                );\n            }\n            if ($accountsEnabled) {\n                $flex->addDirectoryType(\n                    'user-accounts',\n                    'blueprints://flex/user-accounts.yaml',\n                    [\n                        'enabled' => $accountsEnabled,\n                        'data' => [\n                            'storage' => $this->getFlexAccountsStorage($config),\n                        ]\n                    ]\n                );\n                $flex->addDirectoryType(\n                    'user-groups',\n                    'blueprints://flex/user-groups.yaml',\n                    [\n                        'enabled' => $accountsEnabled\n                    ]\n                );\n            }\n\n            // Call event to register Flex Directories.\n            $event = new FlexRegisterEvent($flex);\n            $container->dispatchEvent($event);\n\n            return $flex;\n        };\n    }\n\n    /**\n     * @param Config $config\n     * @return array\n     */\n    private function getFlexAccountsStorage(Config $config): array\n    {\n        $value = $config->get('system.accounts.storage', 'file');\n        if (is_array($value)) {\n            return $value;\n        }\n\n        if ($value === 'folder') {\n            return [\n                'class' => UserFolderStorage::class,\n                'options' => [\n                    'file' => 'user',\n                    'pattern' => '{FOLDER}/{KEY:2}/{KEY}/{FILE}{EXT}',\n                    'key' => 'storage_key',\n                    'indexed' => true,\n                    'case_sensitive' => false\n                ],\n            ];\n        }\n\n        if ($value === 'file') {\n            return [\n                'class' => UserFileStorage::class,\n                'options' => [\n                    'pattern' => '{FOLDER}/{KEY}{EXT}',\n                    'key' => 'username',\n                    'indexed' => true,\n                    'case_sensitive' => false\n                ],\n            ];\n        }\n\n        return [];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/InflectorServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Inflector;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\n\n/**\n * Class InflectorServiceProvider\n * @package Grav\\Common\\Service\n */\nclass InflectorServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['inflector'] = function () {\n            return new Inflector();\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/LoggerServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Monolog\\Handler\\StreamHandler;\nuse Monolog\\Logger;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n/**\n * Class LoggerServiceProvider\n * @package Grav\\Common\\Service\n */\nclass LoggerServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['log'] = function ($c) {\n            $log = new Logger('grav');\n\n            /** @var UniformResourceLocator $locator */\n            $locator = $c['locator'];\n\n            $log_file = $locator->findResource('log://grav.log', true, true);\n            $log->pushHandler(new StreamHandler($log_file, Logger::DEBUG));\n\n            return $log;\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/OutputServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Twig\\Twig;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\n\n/**\n * Class OutputServiceProvider\n * @package Grav\\Common\\Service\n */\nclass OutputServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['output'] = function ($c) {\n            /** @var Twig $twig */\n            $twig = $c['twig'];\n\n            /** @var PageInterface $page */\n            $page = $c['page'];\n\n            return $twig->processSite($page->templateFormat());\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/PagesServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Page;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Uri;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\nuse SplFileInfo;\nuse function defined;\n\n/**\n * Class PagesServiceProvider\n * @package Grav\\Common\\Service\n */\nclass PagesServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['pages'] = function (Grav $grav) {\n            return new Pages($grav);\n        };\n\n        if (defined('GRAV_CLI')) {\n            $container['page'] = static function (Grav $grav) {\n                $path = $grav['locator']->findResource('system://pages/notfound.md');\n                $page = new Page();\n                $page->init(new SplFileInfo($path));\n                $page->routable(false);\n\n                return $page;\n            };\n\n            return;\n        }\n\n        $container['page'] = static function (Grav $grav) {\n            /** @var Pages $pages */\n            $pages = $grav['pages'];\n\n            /** @var Config $config */\n            $config = $grav['config'];\n\n            /** @var Uri $uri */\n            $uri = $grav['uri'];\n\n            $path = $uri->path() ? urldecode($uri->path()) : '/'; // Don't trim to support trailing slash default routes\n            $page = $pages->dispatch($path);\n\n            // Redirection tests\n            if ($page) {\n                // some debugger override logic\n                if ($page->debugger() === false) {\n                    $grav['debugger']->enabled(false);\n                }\n\n                if ($config->get('system.force_ssl')) {\n                    $scheme = $uri->scheme(true);\n                    if ($scheme !== 'https') {\n                        $url = 'https://' . $uri->host() . $uri->uri();\n                        $grav->redirect($url);\n                    }\n                }\n\n                $route = $page->route();\n                if ($route && \\in_array($uri->method(), ['GET', 'HEAD'], true)) {\n                    $pageExtension = $page->urlExtension();\n                    $url = $pages->route($route) . $pageExtension;\n\n                    if ($uri->params()) {\n                        if ($url === '/') { //Avoid double slash\n                            $url = $uri->params();\n                        } else {\n                            $url .= $uri->params();\n                        }\n                    }\n                    if ($uri->query()) {\n                        $url .= '?' . $uri->query();\n                    }\n                    if ($uri->fragment()) {\n                        $url .= '#' . $uri->fragment();\n                    }\n\n                    /** @var Language $language */\n                    $language = $grav['language'];\n\n                    $redirect_default_route = $page->header()->redirect_default_route ?? $config->get('system.pages.redirect_default_route', 0);\n                    $redirectCode = (int) $redirect_default_route;\n\n                    // Language-specific redirection scenarios\n                    if ($language->enabled() && ($language->isLanguageInUrl() xor $language->isIncludeDefaultLanguage())) {\n                        $grav->redirect($url, $redirectCode);\n                    }\n\n                    // Default route test and redirect\n                    if ($redirectCode) {\n                        $uriExtension = $uri->extension();\n                        $uriExtension = null !== $uriExtension ? '.' . $uriExtension : '';\n\n                        if ($route !== $path || ($pageExtension !== $uriExtension\n                                && \\in_array($pageExtension, ['', '.htm', '.html'], true)\n                                && \\in_array($uriExtension, ['', '.htm', '.html'], true))) {\n                            $grav->redirect($url, $redirectCode);\n                        }\n                    }\n                }\n            }\n\n            // if page is not found, try some fallback stuff\n            if (!$page || !$page->routable()) {\n                // Try fallback URL stuff...\n                $page = $grav->fallbackUrl($path);\n\n                if (!$page) {\n                    $path = $grav['locator']->findResource('system://pages/notfound.md');\n                    $page = new Page();\n                    $page->init(new SplFileInfo($path));\n                    $page->routable(false);\n                }\n            }\n\n            return $page;\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/RequestServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Uri;\nuse JsonException;\nuse Nyholm\\Psr7\\Factory\\Psr17Factory;\nuse Nyholm\\Psr7Server\\ServerRequestCreator;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\nuse function explode;\nuse function fopen;\nuse function function_exists;\nuse function in_array;\nuse function is_array;\nuse function strtolower;\nuse function trim;\n\n/**\n * Class RequestServiceProvider\n * @package Grav\\Common\\Service\n */\nclass RequestServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['request'] = function () {\n            $psr17Factory = new Psr17Factory();\n            $creator = new ServerRequestCreator(\n                $psr17Factory, // ServerRequestFactory\n                $psr17Factory, // UriFactory\n                $psr17Factory, // UploadedFileFactory\n                $psr17Factory  // StreamFactory\n            );\n\n            $server = $_SERVER;\n            if (false === isset($server['REQUEST_METHOD'])) {\n                $server['REQUEST_METHOD'] = 'GET';\n            }\n            $method = $server['REQUEST_METHOD'];\n\n            $headers = function_exists('getallheaders') ? getallheaders() : $creator::getHeadersFromServer($_SERVER);\n\n            $post = null;\n            if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {\n                foreach ($headers as $headerName => $headerValue) {\n                    if ('content-type' !== strtolower($headerName)) {\n                        continue;\n                    }\n\n                    $contentType = strtolower(trim(explode(';', $headerValue, 2)[0]));\n                    switch ($contentType) {\n                        case 'application/x-www-form-urlencoded':\n                        case 'multipart/form-data':\n                            $post = $_POST;\n                            break 2;\n                        case 'application/json':\n                        case 'application/vnd.api+json':\n                            try {\n                                $json = file_get_contents('php://input');\n                                $post = json_decode($json, true, 512, JSON_THROW_ON_ERROR);\n                                if (!is_array($post)) {\n                                    $post = null;\n                                }\n                            } catch (JsonException $e) {\n                                $post = null;\n                            }\n                            break 2;\n                    }\n                }\n            }\n\n            // Remove _url from ngnix routes.\n            $get = $_GET;\n            unset($get['_url']);\n            if (isset($server['QUERY_STRING'])) {\n                $query = $server['QUERY_STRING'];\n                if (strpos($query, '_url=') !== false) {\n                    parse_str($query, $query);\n                    unset($query['_url']);\n                    $server['QUERY_STRING'] = http_build_query($query);\n                }\n            }\n\n            return $creator->fromArrays($server, $headers, $_COOKIE, $get, $post, $_FILES, fopen('php://input', 'rb') ?: null);\n        };\n\n        $container['route'] = $container->factory(function () {\n            return clone Uri::getCurrentRoute();\n        });\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/SchedulerServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Scheduler\\Scheduler;\nuse Grav\\Common\\Scheduler\\JobQueue;\nuse Grav\\Common\\Scheduler\\JobWorker;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\n\n/**\n * Class SchedulerServiceProvider\n * @package Grav\\Common\\Service\n */\nclass SchedulerServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['scheduler'] = function ($c) {\n            $config = $c['config'];\n            $scheduler = new Scheduler();\n            \n            // Configure modern features if enabled\n            $modernConfig = $config->get('scheduler.modern', []);\n            if ($modernConfig['enabled'] ?? false) {\n                // Initialize components\n                $queuePath = $c['locator']->findResource('user-data://scheduler/queue', true, true);\n                $statusPath = $c['locator']->findResource('user-data://scheduler/status.yaml', true, true);\n                \n                // Set modern configuration on scheduler\n                $scheduler->setModernConfig($modernConfig);\n                \n                // Initialize job queue if enabled\n                if ($modernConfig['queue']['enabled'] ?? false) {\n                    $jobQueue = new JobQueue($queuePath);\n                    $scheduler->setJobQueue($jobQueue);\n                }\n                \n                // Initialize workers if enabled\n                if ($modernConfig['workers']['enabled'] ?? false) {\n                    $workerCount = $modernConfig['workers']['count'] ?? 2;\n                    $workers = [];\n                    for ($i = 0; $i < $workerCount; $i++) {\n                        $workers[] = new JobWorker(\"worker-{$i}\");\n                    }\n                    $scheduler->setWorkers($workers);\n                }\n            }\n            \n            return $scheduler;\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/SessionServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Session;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Session\\Messages;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\n\n/**\n * Class SessionServiceProvider\n * @package Grav\\Common\\Service\n */\nclass SessionServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        // Define session service.\n        $container['session'] = static function ($c) {\n            /** @var Config $config */\n            $config = $c['config'];\n\n            /** @var Uri $uri */\n            $uri = $c['uri'];\n\n            // Get session options.\n            $enabled = (bool)$config->get('system.session.enabled', false);\n            $cookie_secure = $config->get('system.session.secure', false)\n                || ($config->get('system.session.secure_https', true) && $uri->scheme(true) === 'https');\n            $cookie_httponly = (bool)$config->get('system.session.httponly', true);\n            $cookie_lifetime = (int)$config->get('system.session.timeout', 1800);\n            $cookie_domain = $config->get('system.session.domain');\n            $cookie_path = $config->get('system.session.path');\n            $cookie_samesite = $config->get('system.session.samesite', 'Lax');\n\n            if (null === $cookie_domain) {\n                $cookie_domain = $uri->host();\n                if ($cookie_domain === 'localhost') {\n                    $cookie_domain = '';\n                }\n            }\n\n            if (null === $cookie_path) {\n                $cookie_path = '/' . trim(Uri::filterPath($uri->rootUrl(false)), '/');\n            }\n            // Session cookie path requires trailing slash.\n            $cookie_path = rtrim($cookie_path, '/') . '/';\n\n            // Activate admin if we're inside the admin path.\n            $is_admin = false;\n            if ($config->get('plugins.admin.enabled')) {\n                $admin_base = '/' . trim($config->get('plugins.admin.route'), '/');\n\n                // Uri::route() is not processed yet, let's quickly get what we need.\n                $current_route = str_replace(Uri::filterPath($uri->rootUrl(false)), '', parse_url($uri->url(true), PHP_URL_PATH));\n\n                // Test to see if path starts with a supported language + admin base\n                $lang = Utils::pathPrefixedByLangCode($current_route);\n                $lang_admin_base = '/' . $lang . $admin_base;\n\n                // Check no language, simple language prefix (en) and region specific language prefix (en-US).\n                if (Utils::startsWith($current_route, $admin_base) || Utils::startsWith($current_route, $lang_admin_base)) {\n                    $cookie_lifetime = $config->get('plugins.admin.session.timeout', 1800);\n                    $enabled = $is_admin = true;\n                }\n            }\n\n            // Fix for HUGE session timeouts.\n            if ($cookie_lifetime > 99999999999) {\n                $cookie_lifetime = 9999999999;\n            }\n\n            $session_prefix = $c['inflector']->hyphenize($config->get('system.session.name', 'grav-site'));\n            $session_uniqueness = $config->get('system.session.uniqueness', 'path') === 'path' ?  substr(md5(GRAV_ROOT), 0, 7) :  md5($config->get('security.salt'));\n\n            $session_name = $session_prefix . '-' . $session_uniqueness;\n\n            if ($is_admin && $config->get('system.session.split', true)) {\n                $session_name .= '-admin';\n            }\n\n            // Define session service.\n            $options = [\n                'name' => $session_name,\n                'cookie_lifetime' => $cookie_lifetime,\n                'cookie_path' => $cookie_path,\n                'cookie_domain' => $cookie_domain,\n                'cookie_secure' => $cookie_secure,\n                'cookie_httponly' => $cookie_httponly,\n                'cookie_samesite' => $cookie_samesite\n            ] + (array) $config->get('system.session.options');\n\n            $session = new Session($options);\n            $session->setAutoStart($enabled);\n\n            return $session;\n        };\n\n        // Define session message service.\n        $container['messages'] = function ($c) {\n            if (!isset($c['session']) || !$c['session']->isStarted()) {\n                /** @var Debugger $debugger */\n                $debugger = $c['debugger'];\n                $debugger->addMessage('Inactive session: session messages may disappear', 'warming');\n\n                return new Messages();\n            }\n\n            /** @var Session $session */\n            $session = $c['session'];\n\n            if (!$session->messages instanceof Messages) {\n                $session->messages = new Messages();\n            }\n\n            return $session->messages;\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/StreamsServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Config\\Setup;\nuse Pimple\\Container;\nuse RocketTheme\\Toolbox\\DI\\ServiceProviderInterface;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RocketTheme\\Toolbox\\StreamWrapper\\ReadOnlyStream;\nuse RocketTheme\\Toolbox\\StreamWrapper\\Stream;\nuse RocketTheme\\Toolbox\\StreamWrapper\\StreamBuilder;\n\n/**\n * Class StreamsServiceProvider\n * @package Grav\\Common\\Service\n */\nclass StreamsServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['locator'] = function (Container $container) {\n            $locator = new UniformResourceLocator(GRAV_WEBROOT);\n\n            /** @var Setup $setup */\n            $setup = $container['setup'];\n            $setup->initializeLocator($locator);\n\n            return $locator;\n        };\n\n        $container['streams'] = function (Container $container) {\n            /** @var Setup $setup */\n            $setup = $container['setup'];\n\n            /** @var UniformResourceLocator $locator */\n            $locator = $container['locator'];\n\n            // Set locator to both streams.\n            Stream::setLocator($locator);\n            ReadOnlyStream::setLocator($locator);\n\n            return new StreamBuilder($setup->getStreams());\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Service/TaskServiceProvider.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Service\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Service;\n\nuse Grav\\Common\\Grav;\nuse Pimple\\Container;\nuse Pimple\\ServiceProviderInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\n\n/**\n * Class TaskServiceProvider\n * @package Grav\\Common\\Service\n */\nclass TaskServiceProvider implements ServiceProviderInterface\n{\n    /**\n     * @param Container $container\n     * @return void\n     */\n    public function register(Container $container)\n    {\n        $container['task'] = function (Grav $c) {\n            /** @var ServerRequestInterface $request */\n            $request = $c['request'];\n            $body = $request->getParsedBody();\n\n            $task = $body['task'] ?? $c['uri']->param('task');\n            if (null !== $task) {\n                $task = htmlspecialchars(strip_tags($task), ENT_QUOTES, 'UTF-8');\n            }\n\n            return $task ?: null;\n        };\n\n        $container['action'] = function (Grav $c) {\n            /** @var ServerRequestInterface $request */\n            $request = $c['request'];\n            $body = $request->getParsedBody();\n\n            $action = $body['action'] ?? $c['uri']->param('action');\n            if (null !== $action) {\n                $action = htmlspecialchars(strip_tags($action), ENT_QUOTES, 'UTF-8');\n            }\n\n            return $action ?: null;\n        };\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Session.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Grav\\Common\\Form\\FormFlash;\nuse Grav\\Events\\BeforeSessionStartEvent;\nuse Grav\\Events\\SessionStartEvent;\nuse Grav\\Plugin\\Form\\Forms;\nuse JsonException;\nuse function is_string;\n\n/**\n * Class Session\n * @package Grav\\Common\n */\nclass Session extends \\Grav\\Framework\\Session\\Session\n{\n    /** @var bool */\n    protected $autoStart = false;\n\n    /**\n     * @return \\Grav\\Framework\\Session\\Session\n     * @deprecated 1.5 Use ->getInstance() method instead.\n     */\n    public static function instance()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getInstance() method instead', E_USER_DEPRECATED);\n\n        return static::getInstance();\n    }\n\n    /**\n     * Initialize session.\n     *\n     * Code in this function has been moved into SessionServiceProvider class.\n     *\n     * @return void\n     */\n    public function init()\n    {\n        if ($this->autoStart && !$this->isStarted()) {\n            $this->start();\n\n            $this->autoStart = false;\n        }\n    }\n\n    /**\n     * @param bool $auto\n     * @return $this\n     */\n    public function setAutoStart($auto)\n    {\n        $this->autoStart = (bool)$auto;\n\n        return $this;\n    }\n\n    /**\n     * Returns attributes.\n     *\n     * @return array Attributes\n     * @deprecated 1.5 Use ->getAll() method instead.\n     */\n    public function all()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getAll() method instead', E_USER_DEPRECATED);\n\n        return $this->getAll();\n    }\n\n    /**\n     * Checks if the session was started.\n     *\n     * @return bool\n     * @deprecated 1.5 Use ->isStarted() method instead.\n     */\n    public function started()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->isStarted() method instead', E_USER_DEPRECATED);\n\n        return $this->isStarted();\n    }\n\n    /**\n     * Store something in session temporarily.\n     *\n     * @param string $name\n     * @param mixed $object\n     * @return $this\n     */\n    public function setFlashObject($name, $object)\n    {\n        $this->__set($name, serialize($object));\n\n        return $this;\n    }\n\n    /**\n     * Return object and remove it from session.\n     *\n     * @param string $name\n     * @return mixed\n     */\n    public function getFlashObject($name)\n    {\n        $serialized = $this->__get($name);\n\n        $object = is_string($serialized) ? unserialize($serialized, ['allowed_classes' => true]) : $serialized;\n\n        $this->__unset($name);\n\n        if ($name === 'files-upload') {\n            $grav = Grav::instance();\n\n            // Make sure that Forms 3.0+ has been installed.\n            if (null === $object && isset($grav['forms'])) {\n//                user_error(\n//                    __CLASS__ . '::' . __FUNCTION__ . '(\\'files-upload\\') is deprecated since Grav 1.6, use $form->getFlash()->getLegacyFiles() instead',\n//                    E_USER_DEPRECATED\n//                );\n\n                /** @var Uri $uri */\n                $uri = $grav['uri'];\n                /** @var Forms|null $form */\n                $form = $grav['forms']->getActiveForm(); // @phpstan-ignore-line (form plugin)\n\n                $sessionField = base64_encode($uri->url);\n\n                /** @var FormFlash|null $flash */\n                $flash = $form ? $form->getFlash() : null; // @phpstan-ignore-line (form plugin)\n                $object = $flash && method_exists($flash, 'getLegacyFiles') ? [$sessionField => $flash->getLegacyFiles()] : null;\n            }\n        }\n\n        return $object;\n    }\n\n    /**\n     * Store something in cookie temporarily.\n     *\n     * @param string $name\n     * @param mixed $object\n     * @param int $time\n     * @return $this\n     * @throws JsonException\n     */\n    public function setFlashCookieObject($name, $object, $time = 60)\n    {\n        setcookie($name, json_encode($object, JSON_THROW_ON_ERROR), $this->getCookieOptions($time));\n\n        return $this;\n    }\n\n    /**\n     * Return object and remove it from the cookie.\n     *\n     * @param string $name\n     * @return mixed|null\n     * @throws JsonException\n     */\n    public function getFlashCookieObject($name)\n    {\n        if (isset($_COOKIE[$name])) {\n            $cookie = $_COOKIE[$name];\n            setcookie($name, '', $this->getCookieOptions(-42000));\n\n            return json_decode($cookie, false, 512, JSON_THROW_ON_ERROR);\n        }\n\n        return null;\n    }\n\n    /**\n     * @return void\n     */\n    protected function onBeforeSessionStart(): void\n    {\n        $event = new BeforeSessionStartEvent($this);\n\n        $grav = Grav::instance();\n        $grav->dispatchEvent($event);\n    }\n\n    /**\n     * @return void\n     */\n    protected function onSessionStart(): void\n    {\n        $event = new SessionStartEvent($this);\n\n        $grav = Grav::instance();\n        $grav->dispatchEvent($event);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Taxonomy.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Collection;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse function is_string;\n\n/**\n * The Taxonomy object is a singleton that holds a reference to a 'taxonomy map'. This map is\n * constructed as a multidimensional array.\n *\n * uses the taxonomy defined in the site.yaml file and is built when the page objects are recursed.\n * Basically every time a page is found that has taxonomy references, an entry to the page is stored in\n * the taxonomy map.  The map has the following format:\n *\n * [taxonomy_type][taxonomy_value][page_path]\n *\n * For example:\n *\n * [category][blog][path/to/item1]\n * [tag][grav][path/to/item1]\n * [tag][grav][path/to/item2]\n * [tag][dog][path/to/item3]\n */\nclass Taxonomy\n{\n    /** @var array */\n    protected $taxonomy_map;\n    /** @var Grav */\n    protected $grav;\n    /** @var Language */\n    protected $language;\n\n    /**\n     * Constructor that resets the map\n     *\n     * @param Grav $grav\n     */\n    public function __construct(Grav $grav)\n    {\n        $this->grav = $grav;\n        $this->language = $grav['language'];\n        $this->taxonomy_map[$this->language->getLanguage()] = [];\n    }\n\n    /**\n     * Takes an individual page and processes the taxonomies configured in its header. It\n     * then adds those taxonomies to the map\n     *\n     * @param PageInterface  $page the page to process\n     * @param array|null $page_taxonomy\n     */\n    public function addTaxonomy(PageInterface $page, $page_taxonomy = null)\n    {\n        if (!$page->published()) {\n            return;\n        }\n\n        if (!$page_taxonomy) {\n            $page_taxonomy = $page->taxonomy();\n        }\n\n        if (empty($page_taxonomy)) {\n            return;\n        }\n\n        /** @var Config $config */\n        $config = $this->grav['config'];\n        $taxonomies = (array)$config->get('site.taxonomies');\n        foreach ($taxonomies as $taxonomy) {\n            // Skip invalid taxonomies.\n            if (!\\is_string($taxonomy)) {\n                continue;\n            }\n            $current = $page_taxonomy[$taxonomy] ?? null;\n            foreach ((array)$current as $item) {\n                $this->iterateTaxonomy($page, $taxonomy, '', $item);\n            }\n        }\n    }\n\n    /**\n     * Iterate through taxonomy fields\n     *\n     * Reduces [taxonomy_type] to dot-notation where necessary\n     *\n     * @param PageInterface   $page     The Page to process\n     * @param string          $taxonomy Taxonomy type to add\n     * @param string          $key      Taxonomy type to concatenate\n     * @param iterable|string $value    Taxonomy value to add or iterate\n     * @return void\n     */\n    public function iterateTaxonomy(PageInterface $page, string $taxonomy, string $key, $value)\n    {\n        if (is_iterable($value)) {\n            foreach ($value as $identifier => $item) {\n                $identifier = \"{$key}.{$identifier}\";\n                $this->iterateTaxonomy($page, $taxonomy, $identifier, $item);\n            }\n        } elseif (is_string($value)) {\n            if (!empty($key)) {\n                $taxonomy .= $key;\n            }\n            $active = $this->language->getLanguage();\n            $this->taxonomy_map[$active][$taxonomy][(string) $value][$page->path()] = ['slug' => $page->slug()];\n        }\n    }\n\n    /**\n     * Returns a new Page object with the sub-pages containing all the values set for a\n     * particular taxonomy.\n     *\n     * @param  array  $taxonomies taxonomies to search, eg ['tag'=>['animal','cat']]\n     * @param  string $operator   can be 'or' or 'and' (defaults to 'and')\n     * @return Collection       Collection object set to contain matches found in the taxonomy map\n     */\n    public function findTaxonomy($taxonomies, $operator = 'and')\n    {\n        $matches = [];\n        $results = [];\n        $active = $this->language->getLanguage();\n\n        foreach ((array)$taxonomies as $taxonomy => $items) {\n            foreach ((array)$items as $item) {\n                $matches[] = $this->taxonomy_map[$active][$taxonomy][$item] ?? [];\n            }\n        }\n\n        if (strtolower($operator) === 'or') {\n            foreach ($matches as $match) {\n                $results = array_merge($results, $match);\n            }\n        } else {\n            $results = $matches ? array_pop($matches) : [];\n            foreach ($matches as $match) {\n                $results = array_intersect_key($results, $match);\n            }\n        }\n\n        return new Collection($results, ['taxonomies' => $taxonomies]);\n    }\n\n    /**\n     * Gets and Sets the taxonomy map\n     *\n     * @param  array|null $var the taxonomy map\n     * @return array      the taxonomy map\n     */\n    public function taxonomy($var = null)\n    {\n        $active = $this->language->getLanguage();\n\n        if ($var) {\n            $this->taxonomy_map[$active] = $var;\n        }\n\n        return $this->taxonomy_map[$active] ?? [];\n    }\n\n    /**\n     * Gets item keys per taxonomy\n     *\n     * @param  string $taxonomy       taxonomy name\n     * @return array                  keys of this taxonomy\n     */\n    public function getTaxonomyItemKeys($taxonomy)\n    {\n        $active = $this->language->getLanguage();\n        return isset($this->taxonomy_map[$active][$taxonomy]) ? array_keys($this->taxonomy_map[$active][$taxonomy]) : [];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Theme.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Grav\\Common\\Config\\Config;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n/**\n * Class Theme\n * @package Grav\\Common\n */\nclass Theme extends Plugin\n{\n    /**\n     * Constructor.\n     *\n     * @param Grav   $grav\n     * @param Config $config\n     * @param string $name\n     */\n    public function __construct(Grav $grav, Config $config, $name)\n    {\n        parent::__construct($name, $grav, $config);\n    }\n\n    /**\n     * Get configuration of the plugin.\n     *\n     * @return array\n     */\n    public function config()\n    {\n        return $this->config[\"themes.{$this->name}\"] ?? [];\n    }\n\n    /**\n     * Persists to disk the theme parameters currently stored in the Grav Config object\n     *\n     * @param string $name The name of the theme whose config it should store.\n     * @return bool\n     */\n    public static function saveConfig($name)\n    {\n        if (!$name) {\n            return false;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        $filename = 'config://themes/' . $name . '.yaml';\n        $file = YamlFile::instance((string)$locator->findResource($filename, true, true));\n        $content = $grav['config']->get('themes.' . $name);\n        $file->save($content);\n        $file->free();\n        unset($file);\n\n        return true;\n    }\n\n    /**\n     * Load blueprints.\n     *\n     * @return void\n     */\n    protected function loadBlueprint()\n    {\n        if (!$this->blueprint) {\n            $grav = Grav::instance();\n            /** @var Themes $themes */\n            $themes = $grav['themes'];\n            $data = $themes->get($this->name);\n            \\assert($data !== null);\n            $this->blueprint = $data->blueprints();\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Themes.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse DirectoryIterator;\nuse Exception;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Data\\Blueprints;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Framework\\Psr7\\Response;\nuse InvalidArgumentException;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\nuse Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;\nuse function defined;\nuse function in_array;\nuse function strlen;\n\n/**\n * Class Themes\n * @package Grav\\Common\n */\nclass Themes extends Iterator\n{\n    /** @var Grav */\n    protected $grav;\n    /** @var Config */\n    protected $config;\n    /** @var bool */\n    protected $inited = false;\n\n    /**\n     * Themes constructor.\n     *\n     * @param Grav $grav\n     */\n    public function __construct(Grav $grav)\n    {\n        parent::__construct();\n\n        $this->grav = $grav;\n        $this->config = $grav['config'];\n\n        // Register instance as autoloader for theme inheritance\n        spl_autoload_register([$this, 'autoloadTheme']);\n    }\n\n    /**\n     * @return void\n     */\n    public function init()\n    {\n        /** @var Themes $themes */\n        $themes = $this->grav['themes'];\n        $themes->configure();\n\n        $this->initTheme();\n    }\n\n    /**\n     * @return void\n     */\n    public function initTheme()\n    {\n        if ($this->inited === false) {\n            /** @var Themes $themes */\n            $themes = $this->grav['themes'];\n\n            try {\n                $instance = $themes->load();\n            } catch (InvalidArgumentException $e) {\n                throw new RuntimeException($this->current() . ' theme could not be found');\n            }\n\n            // Register autoloader.\n            if (method_exists($instance, 'autoload')) {\n                $instance->autoload();\n            }\n\n            // Register event listeners.\n            if ($instance instanceof EventSubscriberInterface) {\n                /** @var EventDispatcher $events */\n                $events = $this->grav['events'];\n                $events->addSubscriber($instance);\n            }\n\n            // Register blueprints.\n            if (is_dir('theme://blueprints/pages')) {\n                /** @var UniformResourceLocator $locator */\n                $locator = $this->grav['locator'];\n                $locator->addPath('blueprints', '', ['theme://blueprints'], ['user', 'blueprints']);\n            }\n\n            // Register form fields.\n            if (method_exists($instance, 'getFormFieldTypes')) {\n                /** @var Plugins $plugins */\n                $plugins = $this->grav['plugins'];\n                $plugins->formFieldTypes = $instance->getFormFieldTypes() + $plugins->formFieldTypes;\n            }\n\n            $this->grav['theme'] = $instance;\n\n            $this->grav->fireEvent('onThemeInitialized');\n\n            $this->inited = true;\n        }\n    }\n\n    /**\n     * Return list of all theme data with their blueprints.\n     *\n     * @return array\n     */\n    public function all()\n    {\n        $list = [];\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n\n        $iterator = $locator->getIterator('themes://');\n\n        /** @var DirectoryIterator $directory */\n        foreach ($iterator as $directory) {\n            if (!$directory->isDir() || $directory->isDot()) {\n                continue;\n            }\n\n            $theme = $directory->getFilename();\n\n            try {\n                $result = $this->get($theme);\n            } catch (Exception $e) {\n                $exception = new RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e);\n\n                /** @var Debugger $debugger */\n                $debugger = $this->grav['debugger'];\n                $debugger->addMessage(\"Theme {$theme} cannot be loaded, please check Exceptions tab\", 'error');\n                $debugger->addException($exception);\n\n                continue;\n            }\n\n            if ($result) {\n                $list[$theme] = $result;\n            }\n        }\n        ksort($list, SORT_NATURAL | SORT_FLAG_CASE);\n\n        return $list;\n    }\n\n    /**\n     * Get theme configuration or throw exception if it cannot be found.\n     *\n     * @param  string $name\n     * @return Data|null\n     * @throws RuntimeException\n     */\n    public function get($name)\n    {\n        if (!$name) {\n            throw new RuntimeException('Theme name not provided.');\n        }\n\n        $blueprints = new Blueprints('themes://');\n        $blueprint = $blueprints->get(\"{$name}/blueprints\");\n\n        // Load default configuration.\n        $file = CompiledYamlFile::instance(\"themes://{$name}/{$name}\" . YAML_EXT);\n\n        // ensure this is a valid theme\n        if (!$file->exists()) {\n            return null;\n        }\n\n        // Find thumbnail.\n        $thumb = \"themes://{$name}/thumbnail.jpg\";\n        $path = $this->grav['locator']->findResource($thumb, false);\n\n        if ($path) {\n            $blueprint->set('thumbnail', $this->grav['base_url'] . '/' . $path);\n        }\n\n        $obj = new Data((array)$file->content(), $blueprint);\n\n        // Override with user configuration.\n        $obj->merge($this->config->get('themes.' . $name) ?: []);\n\n        // Save configuration always to user/config.\n        $file = CompiledYamlFile::instance(\"config://themes/{$name}\" . YAML_EXT);\n        $obj->file($file);\n\n        return $obj;\n    }\n\n    /**\n     * Return name of the current theme.\n     *\n     * @return string\n     */\n    public function current()\n    {\n        return (string)$this->config->get('system.pages.theme');\n    }\n\n    /**\n     * Load current theme.\n     *\n     * @return Theme\n     */\n    public function load()\n    {\n        // NOTE: ALL THE LOCAL VARIABLES ARE USED INSIDE INCLUDED FILE, DO NOT REMOVE THEM!\n        $grav = $this->grav;\n        $config = $this->config;\n        $name = $this->current();\n        $class = null;\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        // Start by attempting to load the theme.php file.\n        $file = $locator('theme://theme.php') ?: $locator(\"theme://{$name}.php\");\n        if ($file) {\n            // Local variables available in the file: $grav, $config, $name, $file\n            $class = include $file;\n            if (!\\is_object($class) || !is_subclass_of($class, Theme::class, true)) {\n                $class = null;\n            }\n        } elseif (!$locator('theme://') && !defined('GRAV_CLI')) {\n            $response = new Response(500, [], \"Theme '$name' does not exist, unable to display page.\");\n\n            $grav->close($response);\n        }\n\n        // If the class hasn't been initialized yet, guess the class name and create a new instance.\n        if (null === $class) {\n            $themeClassFormat = [\n                'Grav\\\\Theme\\\\' . Inflector::camelize($name),\n                'Grav\\\\Theme\\\\' . ucfirst($name)\n            ];\n\n            foreach ($themeClassFormat as $themeClass) {\n                if (is_subclass_of($themeClass, Theme::class, true)) {\n                    $class = new $themeClass($grav, $config, $name);\n                    break;\n                }\n            }\n        }\n\n        // Finally if everything else fails, just create a new instance from the default Theme class.\n        if (null === $class) {\n            $class = new Theme($grav, $config, $name);\n        }\n\n        $this->config->set('theme', $config->get('themes.' . $name));\n\n        return $class;\n    }\n\n    /**\n     * Configure and prepare streams for current template.\n     *\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    public function configure()\n    {\n        $name = $this->current();\n        $config = $this->config;\n\n        $this->loadConfiguration($name, $config);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n\n        $registered = stream_get_wrappers();\n\n        $schemes = $config->get(\"themes.{$name}.streams.schemes\", []);\n        $schemes += [\n            'theme' => [\n                'type' => 'ReadOnlyStream',\n                'paths' => $locator->findResources(\"themes://{$name}\", false)\n            ]\n        ];\n\n        foreach ($schemes as $scheme => $config) {\n            if (isset($config['paths'])) {\n                $locator->addPath($scheme, '', $config['paths']);\n            }\n            if (isset($config['prefixes'])) {\n                foreach ($config['prefixes'] as $prefix => $paths) {\n                    $locator->addPath($scheme, $prefix, $paths);\n                }\n            }\n\n            if (in_array($scheme, $registered, true)) {\n                stream_wrapper_unregister($scheme);\n            }\n            $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream';\n            if ($type[0] !== '\\\\') {\n                $type = '\\\\RocketTheme\\\\Toolbox\\\\StreamWrapper\\\\' . $type;\n            }\n\n            if (!stream_wrapper_register($scheme, $type)) {\n                throw new InvalidArgumentException(\"Stream '{$type}' could not be initialized.\");\n            }\n        }\n\n        // Load languages after streams has been properly initialized\n        $this->loadLanguages($this->config);\n    }\n\n    /**\n     * Load theme configuration.\n     *\n     * @param string $name   Theme name\n     * @param Config $config Configuration class\n     * @return void\n     */\n    protected function loadConfiguration($name, Config $config)\n    {\n        $themeConfig = CompiledYamlFile::instance(\"themes://{$name}/{$name}\" . YAML_EXT)->content();\n        $config->joinDefaults(\"themes.{$name}\", $themeConfig);\n    }\n\n    /**\n     * Load theme languages.\n     * Reads ALL language files from theme stream and merges them.\n     *\n     * @param Config $config Configuration class\n     * @return void\n     */\n    protected function loadLanguages(Config $config)\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n\n        if ($config->get('system.languages.translations', true)) {\n            $language_files = array_reverse($locator->findResources('theme://languages' . YAML_EXT));\n            foreach ($language_files as $language_file) {\n                $language = CompiledYamlFile::instance($language_file)->content();\n                $this->grav['languages']->mergeRecursive($language);\n            }\n            $languages_folders = array_reverse($locator->findResources('theme://languages'));\n            foreach ($languages_folders as $languages_folder) {\n                $languages = [];\n                $iterator = new DirectoryIterator($languages_folder);\n                foreach ($iterator as $file) {\n                    if ($file->getExtension() !== 'yaml') {\n                        continue;\n                    }\n                    $languages[$file->getBasename('.yaml')] = CompiledYamlFile::instance($file->getPathname())->content();\n                }\n                $this->grav['languages']->mergeRecursive($languages);\n            }\n        }\n    }\n\n    /**\n     * Autoload theme classes for inheritance\n     *\n     * @param  string $class Class name\n     * @return mixed|false   FALSE if unable to load $class; Class name if\n     *                       $class is successfully loaded\n     */\n    protected function autoloadTheme($class)\n    {\n        $prefix = 'Grav\\\\Theme\\\\';\n        if (false !== strpos($class, $prefix)) {\n            // Remove prefix from class\n            $class = substr($class, strlen($prefix));\n            $locator = $this->grav['locator'];\n\n            // First try lowercase version of the classname.\n            $path = strtolower($class);\n            $file = $locator(\"themes://{$path}/theme.php\") ?: $locator(\"themes://{$path}/{$path}.php\");\n\n            if ($file) {\n                return include_once $file;\n            }\n\n            // Replace namespace tokens to directory separators\n            $path = $this->grav['inflector']->hyphenize($class);\n            $file = $locator(\"themes://{$path}/theme.php\") ?: $locator(\"themes://{$path}/{$path}.php\");\n\n            // Load class\n            if ($file) {\n                return include_once $file;\n            }\n\n            // Try Old style theme classes\n            $path = preg_replace('#\\\\\\|_(?!.+\\\\\\)#', '/', $class);\n            \\assert(null !== $path);\n\n            $path = strtolower($path);\n            $file = $locator(\"themes://{$path}/theme.php\") ?: $locator(\"themes://{$path}/{$path}.php\");\n\n            // Load class\n            if ($file) {\n                return include_once $file;\n            }\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Exception/TwigException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\\Exception\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Exception;\n\nuse RuntimeException;\n\n/**\n * TwigException gets thrown when you use {% throw code message %} in twig.\n *\n * This allows Grav to catch 401, 403 and 404 exceptions and display proper error page.\n */\nclass TwigException extends RuntimeException\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Extension/FilesystemExtension.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Extension;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\TwigFilter;\nuse Twig\\TwigFunction;\n\n/**\n * Class FilesystemExtension\n * @package Grav\\Common\\Twig\\Extension\n */\nclass FilesystemExtension extends AbstractExtension\n{\n    /** @var UniformResourceLocator */\n    private $locator;\n\n    public function __construct()\n    {\n        $this->locator = Grav::instance()['locator'];\n    }\n\n    /**\n     * @return TwigFilter[]\n     */\n    public function getFilters()\n    {\n        return [\n            new TwigFilter('file_exists', [$this, 'file_exists']),\n            new TwigFilter('fileatime', [$this, 'fileatime']),\n            new TwigFilter('filectime', [$this, 'filectime']),\n            new TwigFilter('filemtime', [$this, 'filemtime']),\n            new TwigFilter('filesize', [$this, 'filesize']),\n            new TwigFilter('filetype', [$this, 'filetype']),\n            new TwigFilter('is_dir', [$this, 'is_dir']),\n            new TwigFilter('is_file', [$this, 'is_file']),\n            new TwigFilter('is_link', [$this, 'is_link']),\n            new TwigFilter('is_readable', [$this, 'is_readable']),\n            new TwigFilter('is_writable', [$this, 'is_writable']),\n            new TwigFilter('is_writeable', [$this, 'is_writable']),\n            new TwigFilter('lstat', [$this, 'lstat']),\n            new TwigFilter('getimagesize', [$this, 'getimagesize']),\n            new TwigFilter('exif_read_data', [$this, 'exif_read_data']),\n            new TwigFilter('read_exif_data', [$this, 'exif_read_data']),\n            new TwigFilter('exif_imagetype', [$this, 'exif_imagetype']),\n            new TwigFilter('hash_file', [$this, 'hash_file']),\n            new TwigFilter('hash_hmac_file', [$this, 'hash_hmac_file']),\n            new TwigFilter('md5_file', [$this, 'md5_file']),\n            new TwigFilter('sha1_file', [$this, 'sha1_file']),\n            new TwigFilter('get_meta_tags', [$this, 'get_meta_tags']),\n            new TwigFilter('pathinfo', [$this, 'pathinfo']),\n        ];\n    }\n\n    /**\n     * Return a list of all functions.\n     *\n     * @return TwigFunction[]\n     */\n    public function getFunctions()\n    {\n        return [\n            new TwigFunction('file_exists', [$this, 'file_exists']),\n            new TwigFunction('fileatime', [$this, 'fileatime']),\n            new TwigFunction('filectime', [$this, 'filectime']),\n            new TwigFunction('filemtime', [$this, 'filemtime']),\n            new TwigFunction('filesize', [$this, 'filesize']),\n            new TwigFunction('filetype', [$this, 'filetype']),\n            new TwigFunction('is_dir', [$this, 'is_dir']),\n            new TwigFunction('is_file', [$this, 'is_file']),\n            new TwigFunction('is_link', [$this, 'is_link']),\n            new TwigFunction('is_readable', [$this, 'is_readable']),\n            new TwigFunction('is_writable', [$this, 'is_writable']),\n            new TwigFunction('is_writeable', [$this, 'is_writable']),\n            new TwigFunction('lstat', [$this, 'lstat']),\n            new TwigFunction('getimagesize', [$this, 'getimagesize']),\n            new TwigFunction('exif_read_data', [$this, 'exif_read_data']),\n            new TwigFunction('read_exif_data', [$this, 'exif_read_data']),\n            new TwigFunction('exif_imagetype', [$this, 'exif_imagetype']),\n            new TwigFunction('hash_file', [$this, 'hash_file']),\n            new TwigFunction('hash_hmac_file', [$this, 'hash_hmac_file']),\n            new TwigFunction('md5_file', [$this, 'md5_file']),\n            new TwigFunction('sha1_file', [$this, 'sha1_file']),\n            new TwigFunction('get_meta_tags', [$this, 'get_meta_tags']),\n            new TwigFunction('pathinfo', [$this, 'pathinfo']),\n        ];\n    }\n\n    /**\n     * @param string $filename\n     * @return bool\n     */\n    public function file_exists($filename): bool\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return file_exists($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return int|false\n     */\n    public function fileatime($filename)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return fileatime($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return int|false\n     */\n    public function filectime($filename)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return filectime($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return int|false\n     */\n    public function filemtime($filename)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return filemtime($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return int|false\n     */\n    public function filesize($filename)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return filesize($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return string|false\n     */\n    public function filetype($filename)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return filetype($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return bool\n     */\n    public function is_dir($filename): bool\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return is_dir($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return bool\n     */\n    public function is_file($filename): bool\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return is_file($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return bool\n     */\n    public function is_link($filename): bool\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return is_link($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return bool\n     */\n    public function is_readable($filename): bool\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return is_readable($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return bool\n     */\n    public function is_writable($filename): bool\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return is_writable($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return array|false\n     */\n    public function lstat($filename)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return lstat($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @return array|false\n     */\n    public function getimagesize($filename)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return getimagesize($filename);\n    }\n\n    /**\n     * @param string $filename\n     * @param string|null $required_sections\n     * @param bool $as_arrays\n     * @param bool $read_thumbnail\n     * @return array|false\n     */\n    public function exif_read_data($filename, ?string $required_sections = null, bool $as_arrays = false, bool $read_thumbnail = false)\n    {\n        if (!Utils::functionExists('exif_read_data') || !$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return @exif_read_data($filename, $required_sections, $as_arrays, $read_thumbnail);\n    }\n\n    /**\n     * @param string $filename\n     * @return int|false\n     */\n    public function exif_imagetype($filename)\n    {\n        if (!Utils::functionExists('exif_imagetype') || !$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return @exif_imagetype($filename);\n    }\n\n    /**\n     * @param string $algo\n     * @param string $filename\n     * @param bool $binary\n     * @return string|false\n     */\n    public function hash_file(string $algo, string $filename, bool $binary = false)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return hash_file($algo, $filename, $binary);\n    }\n\n    /**\n     * @param string $algo\n     * @param string $filename\n     * @param string $key\n     * @param bool $binary\n     * @return string|false\n     */\n    public function hash_hmac_file(string $algo, string $filename, string $key, bool $binary = false)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return hash_hmac_file($algo, $filename, $key, $binary);\n    }\n\n    /**\n     * @param string $filename\n     * @param bool $binary\n     * @return string|false\n     */\n    public function md5_file($filename, bool $binary = false)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return md5_file($filename, $binary);\n    }\n\n    /**\n     * @param string $filename\n     * @param bool $binary\n     * @return string|false\n     */\n    public function sha1_file($filename, bool $binary = false)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return sha1_file($filename, $binary);\n    }\n\n    /**\n     * @param string $filename\n     * @return array|false\n     */\n    public function get_meta_tags($filename)\n    {\n        if (!$this->checkFilename($filename)) {\n            return false;\n        }\n\n        return get_meta_tags($filename);\n    }\n\n    /**\n     * @param string $path\n     * @param int|null $flags\n     * @return string|string[]\n     */\n    public function pathinfo($path, $flags = null)\n    {\n        return Utils::pathinfo($path, $flags);\n    }\n\n    /**\n     * @param string $filename\n     * @return bool\n     */\n    private function checkFilename($filename): bool\n    {\n        return is_string($filename) && (!str_contains($filename, '://') || $this->locator->isStream($filename));\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Extension/GravExtension.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Extension;\n\nuse CallbackFilterIterator;\nuse Cron\\CronExpression;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Inflector;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Collection;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Media;\nuse Grav\\Common\\Scheduler\\Cron;\nuse Grav\\Common\\Security;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserCache;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserLink;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserRender;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserScript;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserStyle;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserSwitch;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserThrow;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserTryCatch;\nuse Grav\\Common\\Twig\\TokenParser\\TwigTokenParserMarkdown;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Common\\Yaml;\nuse Grav\\Common\\Helpers\\Base32;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Psr7\\Response;\nuse Iterator;\nuse JsonSerializable;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse Traversable;\nuse Twig\\Environment;\nuse Twig\\Error\\RuntimeError;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\Extension\\GlobalsInterface;\nuse Twig\\Loader\\FilesystemLoader;\nuse Twig\\Markup;\nuse Twig\\TwigFilter;\nuse Twig\\TwigFunction;\nuse function array_slice;\nuse function count;\nuse function func_get_args;\nuse function func_num_args;\nuse function get_class;\nuse function gettype;\nuse function in_array;\nuse function is_array;\nuse function is_bool;\nuse function is_float;\nuse function is_int;\nuse function is_numeric;\nuse function is_object;\nuse function is_scalar;\nuse function is_string;\nuse function strlen;\n\n/**\n * Class GravExtension\n * @package Grav\\Common\\Twig\\Extension\n */\nclass GravExtension extends AbstractExtension implements GlobalsInterface\n{\n    /** @var Grav */\n    protected $grav;\n    /** @var Debugger|null */\n    protected $debugger;\n    /** @var Config */\n    protected $config;\n\n    /**\n     * GravExtension constructor.\n     */\n    public function __construct()\n    {\n        $this->grav     = Grav::instance();\n        $this->debugger = $this->grav['debugger'] ?? null;\n        $this->config   = $this->grav['config'];\n    }\n\n    /**\n     * Register some standard globals\n     *\n     * @return array\n     */\n    public function getGlobals(): array\n    {\n        return [\n            'grav' => $this->grav,\n        ];\n    }\n\n    /**\n     * Return a list of all filters.\n     *\n     * @return array\n     */\n    public function getFilters(): array\n    {\n        return [\n            new TwigFilter('*ize', [$this, 'inflectorFilter']),\n            new TwigFilter('absolute_url', [$this, 'absoluteUrlFilter']),\n            new TwigFilter('contains', [$this, 'containsFilter']),\n            new TwigFilter('chunk_split', [$this, 'chunkSplitFilter']),\n            new TwigFilter('nicenumber', [$this, 'niceNumberFunc']),\n            new TwigFilter('nicefilesize', [$this, 'niceFilesizeFunc']),\n            new TwigFilter('nicetime', [$this, 'nicetimeFunc']),\n            new TwigFilter('defined', [$this, 'definedDefaultFilter']),\n            new TwigFilter('ends_with', [$this, 'endsWithFilter']),\n            new TwigFilter('fieldName', [$this, 'fieldNameFilter']),\n            new TwigFilter('parent_field', [$this, 'fieldParentFilter']),\n            new TwigFilter('ksort', [$this, 'ksortFilter']),\n            new TwigFilter('ltrim', [$this, 'ltrimFilter']),\n            new TwigFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]),\n            new TwigFilter('md5', [$this, 'md5Filter']),\n            new TwigFilter('base32_encode', [$this, 'base32EncodeFilter']),\n            new TwigFilter('base32_decode', [$this, 'base32DecodeFilter']),\n            new TwigFilter('base64_encode', [$this, 'base64EncodeFilter']),\n            new TwigFilter('base64_decode', [$this, 'base64DecodeFilter']),\n            new TwigFilter('randomize', [$this, 'randomizeFilter']),\n            new TwigFilter('modulus', [$this, 'modulusFilter']),\n            new TwigFilter('rtrim', [$this, 'rtrimFilter']),\n            new TwigFilter('pad', [$this, 'padFilter']),\n            new TwigFilter('regex_replace', [$this, 'regexReplace']),\n            new TwigFilter('safe_email', [$this, 'safeEmailFilter'], ['is_safe' => ['html']]),\n            new TwigFilter('safe_truncate', [Utils::class, 'safeTruncate']),\n            new TwigFilter('safe_truncate_html', [Utils::class, 'safeTruncateHTML']),\n            new TwigFilter('sort_by_key', [$this, 'sortByKeyFilter']),\n            new TwigFilter('starts_with', [$this, 'startsWithFilter']),\n            new TwigFilter('truncate', [Utils::class, 'truncate']),\n            new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']),\n            new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']),\n            new TwigFilter('array_unique', 'array_unique'),\n            new TwigFilter('basename', 'basename'),\n            new TwigFilter('dirname', 'dirname'),\n            new TwigFilter('print_r', [$this, 'print_r']),\n            new TwigFilter('yaml_encode', [$this, 'yamlEncodeFilter']),\n            new TwigFilter('yaml_decode', [$this, 'yamlDecodeFilter']),\n            new TwigFilter('nicecron', [$this, 'niceCronFilter']),\n            new TwigFilter('replace_last', [$this, 'replaceLastFilter']),\n\n            // Translations\n            new TwigFilter('t', [$this, 'translate'], ['needs_environment' => true]),\n            new TwigFilter('tl', [$this, 'translateLanguage']),\n            new TwigFilter('ta', [$this, 'translateArray']),\n\n            // Casting values\n            new TwigFilter('string', [$this, 'stringFilter']),\n            new TwigFilter('int', [$this, 'intFilter'], ['is_safe' => ['all']]),\n            new TwigFilter('bool', [$this, 'boolFilter']),\n            new TwigFilter('float', [$this, 'floatFilter'], ['is_safe' => ['all']]),\n            new TwigFilter('array', [$this, 'arrayFilter']),\n            new TwigFilter('yaml', [$this, 'yamlFilter']),\n\n            // Object Types\n            new TwigFilter('get_type', [$this, 'getTypeFunc']),\n            new TwigFilter('of_type', [$this, 'ofTypeFunc']),\n\n            // PHP methods\n            new TwigFilter('count', 'count'),\n            new TwigFilter('array_diff', 'array_diff'),\n\n            // Security fixes\n            new TwigFilter('filter', [$this, 'filterFunc'], ['needs_environment' => true]),\n            new TwigFilter('map', [$this, 'mapFunc'], ['needs_environment' => true]),\n            new TwigFilter('reduce', [$this, 'reduceFunc'], ['needs_environment' => true]),\n        ];\n    }\n\n    /**\n     * Return a list of all functions.\n     *\n     * @return array\n     */\n    public function getFunctions(): array\n    {\n        return [\n            new TwigFunction('array', [$this, 'arrayFilter']),\n            new TwigFunction('array_key_value', [$this, 'arrayKeyValueFunc']),\n            new TwigFunction('array_key_exists', 'array_key_exists'),\n            new TwigFunction('array_unique', 'array_unique'),\n            new TwigFunction('array_intersect', [$this, 'arrayIntersectFunc']),\n            new TwigFunction('array_diff', 'array_diff'),\n            new TwigFunction('authorize', [$this, 'authorize']),\n            new TwigFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),\n            new TwigFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),\n            new TwigFunction('vardump', [$this, 'vardumpFunc']),\n            new TwigFunction('print_r', [$this, 'print_r']),\n            new TwigFunction('http_response_code', 'http_response_code'),\n            new TwigFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]),\n            new TwigFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]),\n            new TwigFunction('gist', [$this, 'gistFunc']),\n            new TwigFunction('nonce_field', [$this, 'nonceFieldFunc']),\n            new TwigFunction('pathinfo', 'pathinfo'),\n            new TwigFunction('parseurl', 'parse_url'),\n            new TwigFunction('random_string', [$this, 'randomStringFunc']),\n            new TwigFunction('repeat', [$this, 'repeatFunc']),\n            new TwigFunction('regex_replace', [$this, 'regexReplace']),\n            new TwigFunction('regex_filter', [$this, 'regexFilter']),\n            new TwigFunction('regex_match', [$this, 'regexMatch']),\n            new TwigFunction('regex_split', [$this, 'regexSplit']),\n            new TwigFunction('string', [$this, 'stringFilter']),\n            new TwigFunction('url', [$this, 'urlFunc']),\n            new TwigFunction('json_decode', [$this, 'jsonDecodeFilter']),\n            new TwigFunction('get_cookie', [$this, 'getCookie']),\n            new TwigFunction('redirect_me', [$this, 'redirectFunc']),\n            new TwigFunction('range', [$this, 'rangeFunc']),\n            new TwigFunction('isajaxrequest', [$this, 'isAjaxFunc']),\n            new TwigFunction('exif', [$this, 'exifFunc']),\n            new TwigFunction('media_directory', [$this, 'mediaDirFunc']),\n            new TwigFunction('body_class', [$this, 'bodyClassFunc'], ['needs_context' => true]),\n            new TwigFunction('theme_var', [$this, 'themeVarFunc'], ['needs_context' => true]),\n            new TwigFunction('header_var', [$this, 'pageHeaderVarFunc'], ['needs_context' => true]),\n            new TwigFunction('read_file', [$this, 'readFileFunc']),\n            new TwigFunction('nicenumber', [$this, 'niceNumberFunc']),\n            new TwigFunction('nicefilesize', [$this, 'niceFilesizeFunc']),\n            new TwigFunction('nicetime', [$this, 'nicetimeFunc']),\n            new TwigFunction('cron', [$this, 'cronFunc']),\n            new TwigFunction('svg_image', [$this, 'svgImageFunction']),\n            new TwigFunction('xss', [$this, 'xssFunc']),\n            new TwigFunction('unique_id', [$this, 'uniqueId']),\n\n            // Translations\n            new TwigFunction('t', [$this, 'translate'], ['needs_environment' => true]),\n            new TwigFunction('tl', [$this, 'translateLanguage']),\n            new TwigFunction('ta', [$this, 'translateArray']),\n\n            // Object Types\n            new TwigFunction('get_type', [$this, 'getTypeFunc']),\n            new TwigFunction('of_type', [$this, 'ofTypeFunc']),\n\n            // PHP methods\n            new TwigFunction('is_numeric', 'is_numeric'),\n            new TwigFunction('is_iterable', 'is_iterable'),\n            new TwigFunction('is_countable', 'is_countable'),\n            new TwigFunction('is_null', 'is_null'),\n            new TwigFunction('is_string', 'is_string'),\n            new TwigFunction('is_array', 'is_array'),\n            new TwigFunction('is_object', 'is_object'),\n            new TwigFunction('count', 'count'),\n            new TwigFunction('array_diff', 'array_diff'),\n            new TwigFunction('parse_url', 'parse_url'),\n\n            // Security fixes\n            new TwigFunction('filter', [$this, 'filterFunc'], ['needs_environment' => true]),\n            new TwigFunction('map', [$this, 'mapFunc'], ['needs_environment' => true]),\n            new TwigFunction('reduce', [$this, 'reduceFunc'], ['needs_environment' => true]),\n        ];\n    }\n\n    /**\n     * @return array\n     */\n    public function getTokenParsers(): array\n    {\n        return [\n            new TwigTokenParserRender(),\n            new TwigTokenParserThrow(),\n            new TwigTokenParserTryCatch(),\n            new TwigTokenParserScript(),\n            new TwigTokenParserStyle(),\n            new TwigTokenParserLink(),\n            new TwigTokenParserMarkdown(),\n            new TwigTokenParserSwitch(),\n            new TwigTokenParserCache(),\n        ];\n    }\n\n    /**\n     * @param mixed $var\n     * @return string\n     */\n    public function print_r($var)\n    {\n        return print_r($var, true);\n    }\n\n    /**\n     * Filters field name by changing dot notation into array notation.\n     *\n     * @param  string $str\n     * @return string\n     */\n    public function fieldNameFilter($str)\n    {\n        $path = explode('.', rtrim($str, '.'));\n\n        return array_shift($path) . ($path ? '[' . implode('][', $path) . ']' : '');\n    }\n\n    /**\n     * Filters field name by changing dot notation into array notation.\n     *\n     * @param  string $str\n     * @return string\n     */\n    public function fieldParentFilter($str)\n    {\n        $path = explode('.', rtrim($str, '.'));\n        array_pop($path);\n\n        return implode('.', $path);\n    }\n\n    /**\n     * Protects email address.\n     *\n     * @param  string $str\n     * @return string\n     */\n    public function safeEmailFilter($str)\n    {\n        static $list = [\n            '\"' => '&#34;',\n            \"'\" => '&#39;',\n            '&' => '&amp;',\n            '<' => '&lt;',\n            '>' => '&gt;',\n            '@' => '&#64;'\n        ];\n\n        $characters = mb_str_split($str, 1, 'UTF-8');\n\n        $encoded = '';\n        foreach ($characters as $chr) {\n            $encoded .= $list[$chr] ?? (random_int(0, 1) ? '&#' . mb_ord($chr) . ';' : $chr);\n        }\n\n        return $encoded;\n    }\n\n    /**\n     * Returns array in a random order.\n     *\n     * @param  array|Traversable $original\n     * @param  int   $offset Can be used to return only slice of the array.\n     * @return array\n     */\n    public function randomizeFilter($original, $offset = 0)\n    {\n        if ($original instanceof Traversable) {\n            $original = iterator_to_array($original, false);\n        }\n\n        if (!is_array($original)) {\n            return $original;\n        }\n\n        $sorted = [];\n        $random = array_slice($original, $offset);\n        shuffle($random);\n\n        $sizeOf = count($original);\n        for ($x = 0; $x < $sizeOf; $x++) {\n            if ($x < $offset) {\n                $sorted[] = $original[$x];\n            } else {\n                $sorted[] = array_shift($random);\n            }\n        }\n\n        return $sorted;\n    }\n\n    /**\n     * Returns the modulus of an integer\n     *\n     * @param  string|int   $number\n     * @param  int          $divider\n     * @param  array|null   $items array of items to select from to return\n     * @return int\n     */\n    public function modulusFilter($number, $divider, $items = null)\n    {\n        if (is_string($number)) {\n            $number = strlen($number);\n        }\n\n        $remainder = $number % $divider;\n\n        if (is_array($items)) {\n            return $items[$remainder] ?? $items[0];\n        }\n\n        return $remainder;\n    }\n\n    /**\n     * Inflector supports following notations:\n     *\n     * `{{ 'person'|pluralize }} => people`\n     * `{{ 'shoes'|singularize }} => shoe`\n     * `{{ 'welcome page'|titleize }} => \"Welcome Page\"`\n     * `{{ 'send_email'|camelize }} => SendEmail`\n     * `{{ 'CamelCased'|underscorize }} => camel_cased`\n     * `{{ 'Something Text'|hyphenize }} => something-text`\n     * `{{ 'something_text_to_read'|humanize }} => \"Something text to read\"`\n     * `{{ '181'|monthize }} => 5`\n     * `{{ '10'|ordinalize }} => 10th`\n     *\n     * @param string $action\n     * @param string $data\n     * @param int|null $count\n     * @return string\n     */\n    public function inflectorFilter($action, $data, $count = null)\n    {\n        $action .= 'ize';\n\n        /** @var Inflector $inflector */\n        $inflector = $this->grav['inflector'];\n\n        if (in_array(\n            $action,\n            ['titleize', 'camelize', 'underscorize', 'hyphenize', 'humanize', 'ordinalize', 'monthize'],\n            true\n        )) {\n            return $inflector->{$action}($data);\n        }\n\n        if (in_array($action, ['pluralize', 'singularize'], true)) {\n            return $count ? $inflector->{$action}($data, $count) : $inflector->{$action}($data);\n        }\n\n        return $data;\n    }\n\n    /**\n     * Return MD5 hash from the input.\n     *\n     * @param  string $str\n     * @return string\n     */\n    public function md5Filter($str)\n    {\n        return md5($str);\n    }\n\n    /**\n     * Return Base32 encoded string\n     *\n     * @param string $str\n     * @return string\n     */\n    public function base32EncodeFilter($str)\n    {\n        return Base32::encode($str);\n    }\n\n    /**\n     * Return Base32 decoded string\n     *\n     * @param string $str\n     * @return string\n     */\n    public function base32DecodeFilter($str)\n    {\n        return Base32::decode($str);\n    }\n\n    /**\n     * Return Base64 encoded string\n     *\n     * @param string $str\n     * @return string\n     */\n    public function base64EncodeFilter($str)\n    {\n        return base64_encode((string) $str);\n    }\n\n    /**\n     * Return Base64 decoded string\n     *\n     * @param string $str\n     * @return string|false\n     */\n    public function base64DecodeFilter($str)\n    {\n        return base64_decode($str);\n    }\n\n    /**\n     * Sorts a collection by key\n     *\n     * @param  array    $input\n     * @param  string   $filter\n     * @param  int      $direction\n     * @param  int      $sort_flags\n     * @return array\n     */\n    public function sortByKeyFilter($input, $filter, $direction = SORT_ASC, $sort_flags = SORT_REGULAR)\n    {\n        return Utils::sortArrayByKey($input, $filter, $direction, $sort_flags);\n    }\n\n    /**\n     * Return ksorted collection.\n     *\n     * @param  array|null $array\n     * @return array\n     */\n    public function ksortFilter($array)\n    {\n        if (null === $array) {\n            $array = [];\n        }\n        ksort($array);\n\n        return $array;\n    }\n\n    /**\n     * Wrapper for chunk_split() function\n     *\n     * @param string $value\n     * @param int $chars\n     * @param string $split\n     * @return string\n     */\n    public function chunkSplitFilter($value, $chars, $split = '-')\n    {\n        return chunk_split($value, $chars, $split);\n    }\n\n    /**\n     * determine if a string contains another\n     *\n     * @param string $haystack\n     * @param string $needle\n     * @return string|bool\n     * @todo returning $haystack here doesn't make much sense\n     */\n    public function containsFilter($haystack, $needle)\n    {\n        if (empty($needle)) {\n            return $haystack;\n        }\n\n        return (strpos($haystack, (string) $needle) !== false);\n    }\n\n    /**\n     * Gets a human readable output for cron syntax\n     *\n     * @param string $at\n     * @return string\n     */\n    public function niceCronFilter($at)\n    {\n        $cron = new Cron($at);\n        return $cron->getText('en');\n    }\n\n    /**\n     * @param string|mixed $str\n     * @param string $search\n     * @param string $replace\n     * @return string|mixed\n     */\n    public function replaceLastFilter($str, $search, $replace)\n    {\n        if (is_string($str) && ($pos = mb_strrpos($str, $search)) !== false) {\n            $str = mb_substr($str, 0, $pos) . $replace . mb_substr($str, $pos + mb_strlen($search));\n        }\n\n        return $str;\n    }\n\n    /**\n     * Get Cron object for a crontab 'at' format\n     *\n     * @param string $at\n     * @return CronExpression\n     */\n    public function cronFunc($at)\n    {\n        return CronExpression::factory($at);\n    }\n\n    /**\n     * displays a facebook style 'time ago' formatted date/time\n     *\n     * @param string $date\n     * @param bool $long_strings\n     * @param bool $show_tense\n     * @return string\n     */\n    public function nicetimeFunc($date, $long_strings = true, $show_tense = true)\n    {\n        if (empty($date)) {\n            return $this->grav['language']->translate('GRAV.NICETIME.NO_DATE_PROVIDED');\n        }\n\n        if ($long_strings) {\n            $periods = [\n                'NICETIME.SECOND',\n                'NICETIME.MINUTE',\n                'NICETIME.HOUR',\n                'NICETIME.DAY',\n                'NICETIME.WEEK',\n                'NICETIME.MONTH',\n                'NICETIME.YEAR',\n                'NICETIME.DECADE'\n            ];\n        } else {\n            $periods = [\n                'NICETIME.SEC',\n                'NICETIME.MIN',\n                'NICETIME.HR',\n                'NICETIME.DAY',\n                'NICETIME.WK',\n                'NICETIME.MO',\n                'NICETIME.YR',\n                'NICETIME.DEC'\n            ];\n        }\n\n        $lengths = ['60', '60', '24', '7', '4.35', '12', '10'];\n\n        $now = time();\n\n        // check if unix timestamp\n        if ((string)(int)$date === (string)$date) {\n            $unix_date = $date;\n        } else {\n            $unix_date = strtotime($date);\n        }\n\n        // check validity of date\n        if (empty($unix_date)) {\n            return $this->grav['language']->translate('GRAV.NICETIME.BAD_DATE');\n        }\n\n        // is it future date or past date\n        if ($now > $unix_date) {\n            $difference = $now - $unix_date;\n            $tense      = $this->grav['language']->translate('GRAV.NICETIME.AGO');\n        } elseif ($now == $unix_date) {\n            $difference = $now - $unix_date;\n            $tense      = $this->grav['language']->translate('GRAV.NICETIME.JUST_NOW');\n        } else {\n            $difference = $unix_date - $now;\n            $tense      = $this->grav['language']->translate('GRAV.NICETIME.FROM_NOW');\n        }\n\n        for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) {\n            $difference /= $lengths[$j];\n        }\n\n        $difference = round($difference);\n\n        if ($difference != 1) {\n            $periods[$j] .= '_PLURAL';\n        }\n\n        if ($this->grav['language']->getTranslation(\n            $this->grav['language']->getLanguage(),\n            $periods[$j] . '_MORE_THAN_TWO'\n        )\n        ) {\n            if ($difference > 2) {\n                $periods[$j] .= '_MORE_THAN_TWO';\n            }\n        }\n\n        $periods[$j] = $this->grav['language']->translate('GRAV.'.$periods[$j]);\n\n        if ($now == $unix_date) {\n            return $tense;\n        }\n\n        $time = \"{$difference} {$periods[$j]}\";\n        $time .= $show_tense ? \" {$tense}\" : '';\n\n        return $time;\n    }\n\n    /**\n     * Allow quick check of a string for XSS Vulnerabilities\n     *\n     * @param string|array $data\n     * @return bool|string|array\n     */\n    public function xssFunc($data)\n    {\n        if (!is_array($data)) {\n            return Security::detectXss($data);\n        }\n\n        $results = Security::detectXssFromArray($data);\n        $results_parts = array_map(static function ($value, $key) {\n            return $key.': \\''.$value . '\\'';\n        }, array_values($results), array_keys($results));\n\n        return implode(', ', $results_parts);\n    }\n\n    /**\n     * Generates a random string with configurable length, prefix and suffix.\n     * Unlike the built-in `uniqid()`, this string is non-conflicting and safe\n     *\n     * @param int $length\n     * @param array $options\n     * @return string\n     * @throws \\Exception\n     */\n    public function uniqueId(int $length = 9, array $options = ['prefix' => '', 'suffix' => '']): string\n    {\n        return Utils::uniqueId($length, $options);\n    }\n\n    /**\n     * @param string $string\n     * @return string\n     */\n    public function absoluteUrlFilter($string)\n    {\n        $url    = $this->grav['uri']->base();\n        $string = preg_replace('/((?:href|src) *= *[\\'\"](?!(http|ftp)))/i', \"$1$url\", $string);\n\n        return $string;\n    }\n\n    /**\n     * @param array $context\n     * @param string $string\n     * @param bool $block  Block or Line processing\n     * @return string\n     */\n    public function markdownFunction($context, $string, $block = true)\n    {\n        $page = $context['page'] ?? null;\n        return Utils::processMarkdown($string, $block, $page);\n    }\n\n    /**\n     * @param string $haystack\n     * @param string $needle\n     * @return bool\n     */\n    public function startsWithFilter($haystack, $needle)\n    {\n        return Utils::startsWith($haystack, $needle);\n    }\n\n    /**\n     * @param string $haystack\n     * @param string $needle\n     * @return bool\n     */\n    public function endsWithFilter($haystack, $needle)\n    {\n        return Utils::endsWith($haystack, $needle);\n    }\n\n    /**\n     * @param mixed $value\n     * @param null $default\n     * @return mixed|null\n     */\n    public function definedDefaultFilter($value, $default = null)\n    {\n        return $value ?? $default;\n    }\n\n    /**\n     * @param string $value\n     * @param string|null $chars\n     * @return string\n     */\n    public function rtrimFilter($value, $chars = null)\n    {\n        return null !== $chars ? rtrim($value, $chars) : rtrim($value);\n    }\n\n    /**\n     * @param string $value\n     * @param string|null $chars\n     * @return string\n     */\n    public function ltrimFilter($value, $chars = null)\n    {\n        return  null !== $chars ? ltrim($value, $chars) : ltrim($value);\n    }\n\n    /**\n     * Returns a string from a value. If the value is array, return it json encoded\n     *\n     * @param mixed $value\n     * @return string\n     */\n    public function stringFilter($value)\n    {\n        // Format the array as a string\n        if (is_array($value)) {\n            return json_encode($value);\n        }\n\n        // Boolean becomes '1' or '0'\n        if (is_bool($value)) {\n            $value = (int)$value;\n        }\n\n        // Cast the other values to string.\n        return (string)$value;\n    }\n\n    /**\n     * Casts input to int.\n     *\n     * @param mixed $input\n     * @return int\n     */\n    public function intFilter($input)\n    {\n        return (int) $input;\n    }\n\n    /**\n     * Casts input to bool.\n     *\n     * @param mixed $input\n     * @return bool\n     */\n    public function boolFilter($input)\n    {\n        return (bool) $input;\n    }\n\n    /**\n     * Casts input to float.\n     *\n     * @param mixed $input\n     * @return float\n     */\n    public function floatFilter($input)\n    {\n        return (float) $input;\n    }\n\n    /**\n     * Casts input to array.\n     *\n     * @param mixed $input\n     * @return array\n     */\n    public function arrayFilter($input)\n    {\n        if (is_array($input)) {\n            return $input;\n        }\n\n        if (is_object($input)) {\n            if (method_exists($input, 'toArray')) {\n                return $input->toArray();\n            }\n\n            if ($input instanceof Iterator) {\n                return iterator_to_array($input);\n            }\n        }\n\n        return (array)$input;\n    }\n\n    /**\n     * @param array|object $value\n     * @param int|null $inline\n     * @param int|null $indent\n     * @return string\n     */\n    public function yamlFilter($value, $inline = null, $indent = null): string\n    {\n        return Yaml::dump($value, $inline, $indent);\n    }\n\n    /**\n     * @param Environment $twig\n     * @return string\n     */\n    public function translate(Environment $twig, ...$args)\n    {\n        // If admin and tu filter provided, use it\n        if (isset($this->grav['admin'])) {\n            $numargs = count($args);\n            $lang = null;\n\n            if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) {\n                $lang = array_pop($args);\n                /** @var Language $language */\n                $language = $this->grav['language'];\n                if (is_string($lang) && !$language->getLanguageCode($lang)) {\n                    $args[] = $lang;\n                    $lang = null;\n                }\n            } elseif ($numargs === 2 && is_array($args[1])) {\n                $subs = array_pop($args);\n                $args = array_merge($args, $subs);\n            }\n\n            return $this->grav['admin']->translate($args, $lang);\n        }\n\n        $translation = $this->grav['language']->translate($args);\n\n        if ($this->config->get('system.languages.debug', false)) {\n            $debugger = $this->grav['debugger'];\n            $debugger->addMessage(\"$args[0] -> $translation\", 'debug');\n        }\n\n        return $translation;\n    }\n\n    /**\n     * Translate Strings\n     *\n     * @param string|array $args\n     * @param array|null $languages\n     * @param bool $array_support\n     * @param bool $html_out\n     * @return string\n     */\n    public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false)\n    {\n        /** @var Language $language */\n        $language = $this->grav['language'];\n\n        return $language->translate($args, $languages, $array_support, $html_out);\n    }\n\n    /**\n     * @param string $key\n     * @param string $index\n     * @param array|null $lang\n     * @return string\n     */\n    public function translateArray($key, $index, $lang = null)\n    {\n        /** @var Language $language */\n        $language = $this->grav['language'];\n\n        return $language->translateArray($key, $index, $lang);\n    }\n\n    /**\n     * Repeat given string x times.\n     *\n     * @param  string $input\n     * @param  int    $multiplier\n     *\n     * @return string\n     */\n    public function repeatFunc($input, $multiplier)\n    {\n        return str_repeat($input, (int) $multiplier);\n    }\n\n    /**\n     * Return URL to the resource.\n     *\n     * @example {{ url('theme://images/logo.png')|default('http://www.placehold.it/150x100/f4f4f4') }}\n     *\n     * @param  string $input  Resource to be located.\n     * @param  bool   $domain True to include domain name.\n     * @param  bool   $failGracefully If true, return URL even if the file does not exist.\n     * @return string|false      Returns url to the resource or null if resource was not found.\n     */\n    public function urlFunc($input, $domain = false, $failGracefully = false)\n    {\n        return Utils::url($input, $domain, $failGracefully);\n    }\n\n    /**\n     * This function will evaluate Twig $twig through the $environment, and return its results.\n     *\n     * @param array $context\n     * @param string $twig\n     * @return mixed\n     */\n    public function evaluateTwigFunc($context, $twig)\n    {\n\n        $loader = new FilesystemLoader('.');\n        $env = new Environment($loader);\n        $env->addExtension($this);\n\n        $template = $env->createTemplate($twig);\n\n        return $template->render($context);\n    }\n\n    /**\n     * This function will evaluate a $string through the $environment, and return its results.\n     *\n     * @param array $context\n     * @param string $string\n     * @return mixed\n     */\n    public function evaluateStringFunc($context, $string)\n    {\n        return $this->evaluateTwigFunc($context, \"{{ $string }}\");\n    }\n\n    /**\n     * Based on Twig\\Extension\\Debug / twig_var_dump\n     * (c) 2011 Fabien Potencier\n     *\n     * @param Environment $env\n     * @param array $context\n     */\n    public function dump(Environment $env, $context)\n    {\n        if (!$env->isDebug() || !$this->debugger) {\n            return;\n        }\n\n        $count = func_num_args();\n        if (2 === $count) {\n            $data = [];\n            foreach ($context as $key => $value) {\n                if (is_object($value)) {\n                    if (method_exists($value, 'toArray')) {\n                        $data[$key] = $value->toArray();\n                    } else {\n                        $data[$key] = 'Object (' . get_class($value) . ')';\n                    }\n                } else {\n                    $data[$key] = $value;\n                }\n            }\n            $this->debugger->addMessage($data, 'debug');\n        } else {\n            for ($i = 2; $i < $count; $i++) {\n                $var = func_get_arg($i);\n                $this->debugger->addMessage($var, 'debug');\n            }\n        }\n    }\n\n    /**\n     * Output a Gist\n     *\n     * @param  string $id\n     * @param  string|false $file\n     * @return string\n     */\n    public function gistFunc($id, $file = false)\n    {\n        $url = 'https://gist.github.com/' . $id . '.js';\n        if ($file) {\n            $url .= '?file=' . $file;\n        }\n        return '<script src=\"' . $url . '\"></script>';\n    }\n\n    /**\n     * Generate a random string\n     *\n     * @param int $count\n     * @return string\n     */\n    public function randomStringFunc($count = 5)\n    {\n        return Utils::generateRandomString($count);\n    }\n\n    /**\n     * Pad a string to a certain length with another string\n     *\n     * @param string $input\n     * @param int    $pad_length\n     * @param string $pad_string\n     * @param int    $pad_type\n     * @return string\n     */\n    public static function padFilter($input, $pad_length, $pad_string = ' ', $pad_type = STR_PAD_RIGHT)\n    {\n        return str_pad($input, (int)$pad_length, $pad_string, $pad_type);\n    }\n\n    /**\n     * Workaround for twig associative array initialization\n     * Returns a key => val array\n     *\n     * @param string $key           key of item\n     * @param string $val           value of item\n     * @param array|null $current_array optional array to add to\n     * @return array\n     */\n    public function arrayKeyValueFunc($key, $val, $current_array = null)\n    {\n        if (empty($current_array)) {\n            return array($key => $val);\n        }\n\n        $current_array[$key] = $val;\n\n        return $current_array;\n    }\n\n    /**\n     * Wrapper for array_intersect() method\n     *\n     * @param array|Collection $array1\n     * @param array|Collection $array2\n     * @return array|Collection\n     */\n    public function arrayIntersectFunc($array1, $array2)\n    {\n        if ($array1 instanceof Collection && $array2 instanceof Collection) {\n            return $array1->intersect($array2)->toArray();\n        }\n\n        return array_intersect($array1, $array2);\n    }\n\n    /**\n     * Translate a string\n     *\n     * @return string\n     */\n    public function translateFunc()\n    {\n        return $this->grav['language']->translate(func_get_args());\n    }\n\n    /**\n     * Authorize an action. Returns true if the user is logged in and\n     * has the right to execute $action.\n     *\n     * @param  string|array $action An action or a list of actions. Each\n     *                              entry can be a string like 'group.action'\n     *                              or without dot notation an associative\n     *                              array.\n     * @return bool                 Returns TRUE if the user is authorized to\n     *                              perform the action, FALSE otherwise.\n     */\n    public function authorize($action)\n    {\n        // Admin can use Flex users even if the site does not; make sure we use the right version of the user.\n        $admin = $this->grav['admin'] ?? null;\n        if ($admin) {\n            $user = $admin->user;\n        } else {\n            /** @var UserInterface|null $user */\n            $user = $this->grav['user'] ?? null;\n        }\n\n        if (!$user) {\n            return false;\n        }\n\n        if (is_array($action)) {\n            if (Utils::isAssoc($action)) {\n                // Handle nested access structure.\n                $actions = Utils::arrayFlattenDotNotation($action);\n            } else {\n                // Handle simple access list.\n                $actions = array_combine($action, array_fill(0, count($action), true));\n            }\n        } else {\n            // Handle single action.\n            $actions = [(string)$action => true];\n        }\n\n        $count = count($actions);\n        foreach ($actions as $act => $authenticated) {\n            // Ignore 'admin.super' if it's not the only value to be checked.\n            if ($act === 'admin.super' && $count > 1 && $user instanceof FlexObjectInterface) {\n                continue;\n            }\n\n            $auth = $user->authorize($act) ?? false;\n            if (is_bool($auth) && $auth === Utils::isPositive($authenticated)) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Used to add a nonce to a form. Call {{ nonce_field('action') }} specifying a string representing the action.\n     *\n     * For maximum protection, ensure that the string representing the action is as specific as possible\n     *\n     * @param string $action         the action\n     * @param string $nonceParamName a custom nonce param name\n     * @return string the nonce input field\n     */\n    public function nonceFieldFunc($action, $nonceParamName = 'nonce')\n    {\n        $string = '<input type=\"hidden\" name=\"' . $nonceParamName . '\" value=\"' . Utils::getNonce($action) . '\" />';\n\n        return $string;\n    }\n\n    /**\n     * Decodes string from JSON.\n     *\n     * @param  string  $str\n     * @param  bool  $assoc\n     * @param  int $depth\n     * @param  int $options\n     * @return array\n     */\n    public function jsonDecodeFilter($str, $assoc = false, $depth = 512, $options = 0)\n    {\n        if ($str === null) {\n            $str = '';\n        }\n        return json_decode(html_entity_decode($str, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options);\n    }\n\n    /**\n     * Used to retrieve a cookie value\n     *\n     * @param string $key     The cookie name to retrieve\n     * @return string\n     */\n    public function getCookie($key)\n    {\n        $cookie_value = filter_input(INPUT_COOKIE, $key);\n\n        if ($cookie_value === null) {\n            return null;\n        }\n\n        return htmlspecialchars(strip_tags($cookie_value), ENT_QUOTES, 'UTF-8');\n    }\n\n    /**\n     * Twig wrapper for PHP's preg_replace method\n     *\n     * @param string|string[] $subject the content to perform the replacement on\n     * @param string|string[] $pattern the regex pattern to use for matches\n     * @param string|string[] $replace the replacement value either as a string or an array of replacements\n     * @param int   $limit   the maximum possible replacements for each pattern in each subject\n     * @return string|string[]|null the resulting content\n     */\n    public function regexReplace($subject, $pattern, $replace, $limit = -1)\n    {\n        return preg_replace($pattern, $replace, $subject, $limit);\n    }\n\n    /**\n     * Twig wrapper for PHP's preg_grep method\n     *\n     * @param array $array\n     * @param string $regex\n     * @param int $flags\n     * @return array\n     */\n    public function regexFilter($array, $regex, $flags = 0)\n    {\n        return preg_grep($regex, $array, $flags);\n    }\n\n    /**\n     * Twig wrapper for PHP's preg_match method\n     *\n     * @param string $subject the content to perform the match on\n     * @param string $pattern the regex pattern to use for match\n     * @param int $flags\n     * @param int $offset\n     * @return array|false returns the matches if there is at least one match in the subject for a given pattern or null if not.\n     */\n    public function regexMatch($subject, $pattern, $flags = 0, $offset = 0)\n    {\n        if (preg_match($pattern, $subject, $matches, $flags, $offset) === false) {\n            return false;\n        }\n\n        return $matches;\n    }\n\n    /**\n     * Twig wrapper for PHP's preg_split method\n     *\n     * @param string $subject the content to perform the split on\n     * @param string $pattern the regex pattern to use for split\n     * @param int $limit the maximum possible splits for the given pattern\n     * @param int $flags\n     * @return array|false the resulting array after performing the split operation\n     */\n    public function regexSplit($subject, $pattern, $limit = -1, $flags = 0)\n    {\n        return preg_split($pattern, $subject, $limit, $flags);\n    }\n\n    /**\n     * redirect browser from twig\n     *\n     * @param string $url          the url to redirect to\n     * @param int $statusCode      statusCode, default 303\n     * @return void\n     */\n    public function redirectFunc($url, $statusCode = 303)\n    {\n        $response = new Response($statusCode, ['location' => $url]);\n\n        $this->grav->close($response);\n    }\n\n    /**\n     * Generates an array containing a range of elements, optionally stepped\n     *\n     * @param int $start      Minimum number, default 0\n     * @param int $end        Maximum number, default `getrandmax()`\n     * @param int $step       Increment between elements in the sequence, default 1\n     * @return array\n     */\n    public function rangeFunc($start = 0, $end = 100, $step = 1)\n    {\n        return range($start, $end, $step);\n    }\n\n    /**\n     * Check if HTTP_X_REQUESTED_WITH has been set to xmlhttprequest,\n     * in which case we may unsafely assume ajax. Non critical use only.\n     *\n     * @return bool True if HTTP_X_REQUESTED_WITH exists and has been set to xmlhttprequest\n     */\n    public function isAjaxFunc()\n    {\n        return (\n            !empty($_SERVER['HTTP_X_REQUESTED_WITH'])\n            && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest');\n    }\n\n    /**\n     * Get the Exif data for a file\n     *\n     * @param string $image\n     * @param bool $raw\n     * @return mixed\n     */\n    public function exifFunc($image, $raw = false)\n    {\n        if (isset($this->grav['exif'])) {\n            /** @var UniformResourceLocator $locator */\n            $locator = $this->grav['locator'];\n\n            if ($locator->isStream($image)) {\n                $image = $locator->findResource($image);\n            }\n\n            $exif_reader = $this->grav['exif']->getReader();\n\n            if ($image && file_exists($image) && $this->config->get('system.media.auto_metadata_exif') && $exif_reader) {\n                $exif_data = $exif_reader->read($image);\n\n                if ($exif_data) {\n                    if ($raw) {\n                        return $exif_data->getRawData();\n                    }\n\n                    return $exif_data->getData();\n                }\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * Simple function to read a file based on a filepath and output it\n     *\n     * @param string $filepath\n     * @return bool|string\n     */\n    public function readFileFunc($filepath)\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n\n        if ($locator->isStream($filepath)) {\n            $filepath = $locator->findResource($filepath);\n        }\n\n        if ($filepath && file_exists($filepath)) {\n            return file_get_contents($filepath);\n        }\n\n        return false;\n    }\n\n    /**\n     * Process a folder as Media and return a media object\n     *\n     * @param string $media_dir\n     * @return Media|null\n     */\n    public function mediaDirFunc($media_dir)\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n\n        if ($locator->isStream($media_dir)) {\n            $media_dir = $locator->findResource($media_dir);\n        }\n\n        if ($media_dir && file_exists($media_dir)) {\n            return new Media($media_dir);\n        }\n\n        return null;\n    }\n\n    /**\n     * Dump a variable to the browser\n     *\n     * @param mixed $var\n     * @return void\n     */\n    public function vardumpFunc($var)\n    {\n        dump($var);\n    }\n\n    /**\n     * Returns a nicer more readable filesize based on bytes\n     *\n     * @param int $bytes\n     * @return string\n     */\n    public function niceFilesizeFunc($bytes)\n    {\n        return Utils::prettySize($bytes);\n    }\n\n    /**\n     * Returns a nicer more readable number\n     *\n     * @param int|float|string $n\n     * @return string|bool\n     */\n    public function niceNumberFunc($n)\n    {\n        if (!is_float($n) && !is_int($n)) {\n            if (!is_string($n) || $n === '') {\n                return false;\n            }\n\n            // Strip any thousand formatting and find the first number.\n            $list = array_filter(preg_split(\"/\\D+/\", str_replace(',', '', $n)));\n            $n = reset($list);\n\n            if (!is_numeric($n)) {\n                return false;\n            }\n\n            $n = (float)$n;\n        }\n\n        // now filter it;\n        if ($n > 1000000000000) {\n            return round($n/1000000000000, 2).' t';\n        }\n        if ($n > 1000000000) {\n            return round($n/1000000000, 2).' b';\n        }\n        if ($n > 1000000) {\n            return round($n/1000000, 2).' m';\n        }\n        if ($n > 1000) {\n            return round($n/1000, 2).' k';\n        }\n\n        return number_format($n);\n    }\n\n    /**\n     * Get a theme variable\n     * Will try to get the variable for the current page, if not found, it tries it's parent page on up to root.\n     * If still not found, will use the theme's configuration value,\n     * If still not found, will use the $default value passed in\n     *\n     * @param array $context      Twig Context\n     * @param string $var variable to be found (using dot notation)\n     * @param null $default the default value to be used as last resort\n     * @param PageInterface|null $page an optional page to use for the current page\n     * @param bool $exists toggle to simply return the page where the variable is set, else null\n     * @return mixed\n     */\n    public function themeVarFunc($context, $var, $default = null, $page = null, $exists = false)\n    {\n        $page = $page ?? $context['page'] ?? Grav::instance()['page'] ?? null;\n\n        // Try to find var in the page headers\n        if ($page instanceof PageInterface && $page->exists()) {\n            // Loop over pages and look for header vars\n            while ($page && !$page->root()) {\n                $header = new Data((array)$page->header());\n                $value = $header->get($var);\n                if (isset($value)) {\n                    if ($exists) {\n                        return $page;\n                    }\n\n                    return $value;\n                }\n                $page = $page->parent();\n            }\n        }\n\n        if ($exists) {\n            return false;\n        }\n\n        return Grav::instance()['config']->get('theme.' . $var, $default);\n    }\n\n    /**\n     * Look for a page header variable in an array of pages working its way through until a value is found\n     *\n     * @param array $context\n     * @param string $var the variable to look for in the page header\n     * @param string|string[]|null $pages array of pages to check (current page upwards if not null)\n     * @return mixed\n     * @deprecated 1.7 Use themeVarFunc() instead\n     */\n    public function pageHeaderVarFunc($context, $var, $pages = null)\n    {\n        if (is_array($pages)) {\n            $page = array_shift($pages);\n        } else {\n            $page = null;\n        }\n        return $this->themeVarFunc($context, $var, null, $page);\n    }\n\n    /**\n     * takes an array of classes, and if they are not set on body_classes\n     * look to see if they are set in theme config\n     *\n     * @param array $context\n     * @param string|string[] $classes\n     * @return string\n     */\n    public function bodyClassFunc($context, $classes)\n    {\n\n        $header = $context['page']->header();\n        $body_classes = $header->body_classes ?? '';\n\n        foreach ((array)$classes as $class) {\n            if (!empty($body_classes) && Utils::contains($body_classes, $class)) {\n                continue;\n            }\n\n            $val = $this->config->get('theme.' . $class, false) ? $class : false;\n            $body_classes .= $val ? ' ' . $val : '';\n        }\n\n        return $body_classes;\n    }\n\n    /**\n     * Returns the content of an SVG image and adds extra classes as needed\n     *\n     * @param string $path\n     * @param string|null $classes\n     * @return string|string[]|null\n     */\n    public static function svgImageFunction($path, $classes = null, $strip_style = false)\n    {\n        $path = Utils::fullPath($path);\n\n        $classes = $classes ?: '';\n\n        if (file_exists($path) && !is_dir($path)) {\n            $svg = file_get_contents($path);\n            $classes = \" inline-block $classes\";\n            $matched = false;\n\n            //Remove xml tag if it exists\n            $svg = preg_replace('/^<\\?xml.*\\?>/','', $svg);\n\n            //Strip style if needed\n            if ($strip_style) {\n                $svg = preg_replace('/<style.*<\\/style>/s', '', $svg);\n            }\n\n            //Look for existing class\n            $svg = preg_replace_callback('/^<svg[^>]*(class=\\\"([^\"]*)\\\")[^>]*>/', function($matches) use ($classes, &$matched) {\n                if (isset($matches[2])) {\n                    $new_classes = $matches[2] . $classes;\n                    $matched = true;\n                    return str_replace($matches[1], \"class=\\\"$new_classes\\\"\", $matches[0]);\n                }\n                return $matches[0];\n            }, $svg\n            );\n\n            // no matches found just add the class\n            if (!$matched) {\n                $classes = trim($classes);\n                $svg = str_replace('<svg ', \"<svg class=\\\"$classes\\\" \", $svg);\n            }\n\n            return trim($svg);\n        }\n\n        return null;\n    }\n\n\n    /**\n     * Dump/Encode data into YAML format\n     *\n     * @param array|object $data\n     * @param int $inline integer number of levels of inline syntax\n     * @return string\n     */\n    public function yamlEncodeFilter($data, $inline = 10)\n    {\n        if (!is_array($data)) {\n            if ($data instanceof JsonSerializable) {\n                $data = $data->jsonSerialize();\n            } elseif (method_exists($data, 'toArray')) {\n                $data = $data->toArray();\n            } else {\n                $data = json_decode(json_encode($data), true);\n            }\n        }\n\n        return Yaml::dump($data, $inline);\n    }\n\n    /**\n     * Decode/Parse data from YAML format\n     *\n     * @param string $data\n     * @return array\n     */\n    public function yamlDecodeFilter($data)\n    {\n        return Yaml::parse($data);\n    }\n\n    /**\n     * Function/Filter to return the type of variable\n     *\n     * @param mixed $var\n     * @return string\n     */\n    public function getTypeFunc($var)\n    {\n        return gettype($var);\n    }\n\n    /**\n     * Function/Filter to test type of variable\n     *\n     * @param mixed $var\n     * @param string|null $typeTest\n     * @param string|null $className\n     * @return bool\n     */\n    public function ofTypeFunc($var, $typeTest = null, $className = null)\n    {\n\n        switch ($typeTest) {\n            default:\n                return false;\n\n            case 'array':\n                return is_array($var);\n\n            case 'bool':\n                return is_bool($var);\n\n            case 'class':\n                return is_object($var) === true && get_class($var) === $className;\n\n            case 'float':\n                return is_float($var);\n\n            case 'int':\n                return is_int($var);\n\n            case 'numeric':\n                return is_numeric($var);\n\n            case 'object':\n                return is_object($var);\n\n            case 'scalar':\n                return is_scalar($var);\n\n            case 'string':\n                return is_string($var);\n        }\n    }\n\n    /**\n     * @param Environment $env\n     * @param array $array\n     * @param callable|string $arrow\n     * @return array|CallbackFilterIterator\n     * @throws RuntimeError\n     */\n    function filterFunc(Environment $env, $array, $arrow)\n    {\n        if (!$arrow instanceof \\Closure && !is_string($arrow) || Utils::isDangerousFunction($arrow)) {\n            throw new RuntimeError('Twig |filter(\"' . $arrow . '\") is not allowed.');\n        }\n\n        return twig_array_filter($env, $array, $arrow);\n    }\n\n    /**\n     * @param Environment $env\n     * @param array $array\n     * @param callable|string $arrow\n     * @return array|CallbackFilterIterator\n     * @throws RuntimeError\n     */\n    function mapFunc(Environment $env, $array, $arrow)\n    {\n        if (!$arrow instanceof \\Closure && !is_string($arrow) || Utils::isDangerousFunction($arrow)) {\n            throw new RuntimeError('Twig |map(\"' . $arrow . '\") is not allowed.');\n        }\n\n        return twig_array_map($env, $array, $arrow);\n    }\n\n    /**\n     * @param Environment $env\n     * @param array $array\n     * @param callable|string $arrow\n     * @return array|CallbackFilterIterator\n     * @throws RuntimeError\n     */\n    function reduceFunc(Environment $env, $array, $arrow)\n    {\n        if (!$arrow instanceof \\Closure && !is_string($arrow) || Utils::isDangerousFunction($arrow)) {\n            throw new RuntimeError('Twig |reduce(\"' . $arrow . '\") is not allowed.');\n        }\n\n        return twig_array_map($env, $array, $arrow);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeCache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse Twig\\Compiler;\nuse Twig\\Node\\Expression\\AbstractExpression;\nuse Twig\\Node\\Node;\nuse Twig\\Node\\NodeOutputInterface;\n\n/**\n * Class TwigNodeCache\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeCache extends Node implements NodeOutputInterface\n{\n    /**\n     * @param string    $key       unique name for key\n     * @param int       $lifetime  in seconds\n     * @param Node      $body\n     * @param integer   $lineno\n     * @param string|null $tag\n     */\n    public function __construct(Node $body, ?AbstractExpression $key, ?AbstractExpression $lifetime, array $defaults, int $lineno, string $tag)\n    {\n        $nodes = ['body' => $body];\n\n        if ($key !== null) {\n            $nodes['key'] = $key;\n        }\n\n        if ($lifetime !== null) {\n            $nodes['lifetime'] = $lifetime;\n        }\n\n        parent::__construct($nodes, $defaults, $lineno, $tag);\n    }\n\n    public function compile(Compiler $compiler): void\n    {\n        $compiler->addDebugInfo($this);\n\n\n        // Generate the cache key\n        if ($this->hasNode('key')) {\n            $compiler\n                ->write('$key = \"twigcache-\" . ')\n                ->subcompile($this->getNode('key'))\n                ->raw(\";\\n\");\n        } else {\n            $compiler\n                ->write('$key = ')\n                ->string($this->getAttribute('key'))\n                ->raw(\";\\n\");\n        }\n\n        // Set the cache timeout\n        if ($this->hasNode('lifetime')) {\n            $compiler\n                ->write('$lifetime = ')\n                ->subcompile($this->getNode('lifetime'))\n                ->raw(\";\\n\");\n        } else {\n            $compiler\n                ->write('$lifetime = ')\n                ->write($this->getAttribute('lifetime'))\n                ->raw(\";\\n\");\n        }\n\n        $compiler\n            ->write(\"\\$cache = \\\\Grav\\\\Common\\\\Grav::instance()['cache'];\\n\")\n            ->write(\"\\$cache_body = \\$cache->fetch(\\$key);\\n\")\n            ->write(\"if (\\$cache_body === false) {\\n\")\n            ->indent()\n                ->write(\"\\\\Grav\\\\Common\\\\Grav::instance()['debugger']->addMessage(\\\"Cache Key: \\$key, Lifetime: \\$lifetime\\\");\\n\")\n                ->write(\"ob_start();\\n\")\n                    ->indent()\n                        ->subcompile($this->getNode('body'))\n                    ->outdent()\n                ->write(\"\\n\")\n                ->write(\"\\$cache_body = ob_get_clean();\\n\")\n                ->write(\"\\$cache->save(\\$key, \\$cache_body, \\$lifetime);\\n\")\n            ->outdent()\n            ->write(\"}\\n\")\n            ->write(\"echo '' === \\$cache_body ? '' : new Markup(\\$cache_body, \\$this->env->getCharset());\\n\");\n    }\n}"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeLink.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse LogicException;\nuse Twig\\Compiler;\nuse Twig\\Node\\Expression\\AbstractExpression;\nuse Twig\\Node\\Node;\nuse Twig\\Node\\NodeCaptureInterface;\n\n/**\n * Class TwigNodeLink\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeLink extends Node implements NodeCaptureInterface\n{\n    /** @var string */\n    protected $tagName = 'link';\n\n    /**\n     * TwigNodeLink constructor.\n     * @param string|null $rel\n     * @param AbstractExpression|null $file\n     * @param AbstractExpression|null $group\n     * @param AbstractExpression|null $priority\n     * @param AbstractExpression|null $attributes\n     * @param int $lineno\n     * @param string|null $tag\n     */\n    public function __construct(?string $rel, ?AbstractExpression $file, ?AbstractExpression $group, ?AbstractExpression $priority, ?AbstractExpression $attributes, $lineno = 0, $tag = null)\n    {\n        $nodes = ['file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes];\n        $nodes = array_filter($nodes);\n\n        parent::__construct($nodes, ['rel' => $rel], $lineno, $tag);\n    }\n\n    /**\n     * Compiles the node to PHP.\n     *\n     * @param Compiler $compiler A Twig Compiler instance\n     * @return void\n     * @throws LogicException\n     */\n    public function compile(Compiler $compiler): void\n    {\n        $compiler->addDebugInfo($this);\n        if (!$this->hasNode('file')) {\n            return;\n        }\n\n        $compiler->write('$attributes = [\\'rel\\' => \\'' . $this->getAttribute('rel') . '\\'];' . \"\\n\");\n        if ($this->hasNode('attributes')) {\n            $compiler\n                ->write('$attributes += ')\n                ->subcompile($this->getNode('attributes'))\n                ->raw(';' . PHP_EOL)\n                ->write('if (!is_array($attributes)) {' . PHP_EOL)\n                ->indent()\n                ->write(\"throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\" . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n        }\n\n        if ($this->hasNode('group')) {\n            $compiler\n                ->write('$group = ')\n                ->subcompile($this->getNode('group'))\n                ->raw(';' . PHP_EOL)\n                ->write('if (!is_string($group)) {' . PHP_EOL)\n                ->indent()\n                ->write(\"throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\" . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n        } else {\n            $compiler->write('$group = \\'head\\';' . PHP_EOL);\n        }\n\n        if ($this->hasNode('priority')) {\n            $compiler\n                ->write('$priority = (int)(')\n                ->subcompile($this->getNode('priority'))\n                ->raw(');' . PHP_EOL);\n        } else {\n            $compiler->write('$priority = 10;' . PHP_EOL);\n        }\n\n        $compiler->write(\"\\$assets = \\\\Grav\\\\Common\\\\Grav::instance()['assets'];\" . PHP_EOL);\n        $compiler->write(\"\\$block = \\$context['block'] ?? null;\" . PHP_EOL);\n\n        $compiler\n            ->write('$file = (string)(')\n            ->subcompile($this->getNode('file'))\n            ->raw(');' . PHP_EOL);\n\n        // Assets support.\n        $compiler->write('$assets->addLink($file, [\\'group\\' => $group, \\'priority\\' => $priority] + $attributes);' . PHP_EOL);\n\n        // HtmlBlock support.\n        $compiler\n            ->write('if ($block instanceof \\Grav\\Framework\\ContentBlock\\HtmlBlock) {' . PHP_EOL)\n            ->indent()\n            ->write('$block->addLink([\\'href\\'=> $file] + $attributes, $priority, $group);' . PHP_EOL)\n            ->outdent()\n            ->write('}' . PHP_EOL);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse Twig\\Compiler;\nuse Twig\\Node\\Node;\nuse Twig\\Node\\NodeOutputInterface;\n\n/**\n * Class TwigNodeMarkdown\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeMarkdown extends Node implements NodeOutputInterface\n{\n    /**\n     * TwigNodeMarkdown constructor.\n     * @param Node $body\n     * @param int $lineno\n     * @param string $tag\n     */\n    public function __construct(Node $body, $lineno, $tag = 'markdown')\n    {\n        parent::__construct(['body' => $body], [], $lineno, $tag);\n    }\n\n    /**\n     * Compiles the node to PHP.\n     *\n     * @param Compiler $compiler A Twig Compiler instance\n     * @return void\n     */\n    public function compile(Compiler $compiler): void\n    {\n        $compiler\n            ->addDebugInfo($this)\n            ->write('ob_start();' . PHP_EOL)\n            ->subcompile($this->getNode('body'))\n            ->write('$content = ob_get_clean();' . PHP_EOL)\n            ->write('preg_match(\"/^\\s*/\", $content, $matches);' . PHP_EOL)\n            ->write('$lines = explode(\"\\n\", $content);' . PHP_EOL)\n            ->write('$content = preg_replace(\\'/^\\' . $matches[0]. \\'/\\', \"\", $lines);' . PHP_EOL)\n            ->write('$content = join(\"\\n\", $content);' . PHP_EOL)\n            ->write('echo $this->env->getExtension(\\'Grav\\Common\\Twig\\Extension\\GravExtension\\')->markdownFunction($context, $content);' . PHP_EOL);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeRender.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse LogicException;\nuse Twig\\Compiler;\nuse Twig\\Node\\Expression\\AbstractExpression;\nuse Twig\\Node\\Node;\nuse Twig\\Node\\NodeCaptureInterface;\n\n/**\n * Class TwigNodeRender\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeRender extends Node implements NodeCaptureInterface\n{\n    /** @var string */\n    protected $tagName = 'render';\n\n    /**\n     * @param AbstractExpression $object\n     * @param AbstractExpression|null $layout\n     * @param AbstractExpression|null $context\n     * @param int $lineno\n     * @param string|null $tag\n     */\n    public function __construct(AbstractExpression $object, ?AbstractExpression $layout, ?AbstractExpression $context, $lineno, $tag = null)\n    {\n        $nodes = ['object' => $object, 'layout' => $layout, 'context' => $context];\n        $nodes = array_filter($nodes);\n\n        parent::__construct($nodes, [], $lineno, $tag);\n    }\n\n    /**\n     * Compiles the node to PHP.\n     *\n     * @param Compiler $compiler A Twig Compiler instance\n     * @return void\n     * @throws LogicException\n     */\n    public function compile(Compiler $compiler): void\n    {\n        $compiler->addDebugInfo($this);\n        $compiler->write('$object = ')->subcompile($this->getNode('object'))->raw(';' . PHP_EOL);\n\n        if ($this->hasNode('layout')) {\n            $layout = $this->getNode('layout');\n            $compiler->write('$layout = ')->subcompile($layout)->raw(';' . PHP_EOL);\n        } else {\n            $compiler->write('$layout = null;' . PHP_EOL);\n        }\n\n        if ($this->hasNode('context')) {\n            $context = $this->getNode('context');\n            $compiler->write('$attributes = ')->subcompile($context)->raw(';' . PHP_EOL);\n        } else {\n            $compiler->write('$attributes = null;' . PHP_EOL);\n        }\n\n        $compiler\n            ->write('$html = $object->render($layout, $attributes ?? []);' . PHP_EOL)\n            ->write('$block = $context[\\'block\\'] ?? null;' . PHP_EOL)\n            ->write('if ($block instanceof \\Grav\\Framework\\ContentBlock\\ContentBlock && $html instanceof \\Grav\\Framework\\ContentBlock\\ContentBlock) {' . PHP_EOL)\n            ->indent()\n            ->write('$block->addBlock($html);' . PHP_EOL)\n            ->write('echo $html->getToken();' . PHP_EOL)\n            ->outdent()\n            ->write('} else {' . PHP_EOL)\n            ->indent()\n            ->write('\\Grav\\Common\\Assets\\BlockAssets::registerAssets($html);' . PHP_EOL)\n            ->write('echo (string)$html;' . PHP_EOL)\n            ->outdent()\n            ->write('}' . PHP_EOL)\n        ;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeScript.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse LogicException;\nuse Twig\\Compiler;\nuse Twig\\Node\\Expression\\AbstractExpression;\nuse Twig\\Node\\Node;\nuse Twig\\Node\\NodeCaptureInterface;\n\n/**\n * Class TwigNodeScript\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeScript extends Node implements NodeCaptureInterface\n{\n    /** @var string */\n    protected $tagName = 'script';\n\n    /**\n     * TwigNodeScript constructor.\n     * @param Node|null $body\n     * @param string|null $type\n     * @param AbstractExpression|null $file\n     * @param AbstractExpression|null $group\n     * @param AbstractExpression|null $priority\n     * @param AbstractExpression|null $attributes\n     * @param int $lineno\n     * @param string|null $tag\n     */\n    public function __construct(?Node $body, ?string $type, ?AbstractExpression $file, ?AbstractExpression $group, ?AbstractExpression $priority, ?AbstractExpression $attributes, $lineno = 0, $tag = null)\n    {\n        $nodes = ['body' => $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes];\n        $nodes = array_filter($nodes);\n\n        parent::__construct($nodes, ['type' => $type], $lineno, $tag);\n    }\n\n    /**\n     * Compiles the node to PHP.\n     *\n     * @param Compiler $compiler A Twig Compiler instance\n     * @return void\n     * @throws LogicException\n     */\n    public function compile(Compiler $compiler): void\n    {\n        $compiler->addDebugInfo($this);\n\n        if ($this->hasNode('attributes')) {\n            $compiler\n                ->write('$attributes = ')\n                ->subcompile($this->getNode('attributes'))\n                ->raw(';' . PHP_EOL)\n                ->write('if (!is_array($attributes)) {' . PHP_EOL)\n                ->indent()\n                ->write(\"throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\" . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n        } else {\n            $compiler->write('$attributes = [];' . PHP_EOL);\n        }\n\n        if ($this->hasNode('group')) {\n            $compiler\n                ->write('$group = ')\n                ->subcompile($this->getNode('group'))\n                ->raw(';' . PHP_EOL)\n                ->write('if (!is_string($group)) {' . PHP_EOL)\n                ->indent()\n                ->write(\"throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\" . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n        } else {\n            $compiler->write('$group = \\'head\\';' . PHP_EOL);\n        }\n\n        if ($this->hasNode('priority')) {\n            $compiler\n                ->write('$priority = (int)(')\n                ->subcompile($this->getNode('priority'))\n                ->raw(');' . PHP_EOL);\n        } else {\n            $compiler->write('$priority = 10;' . PHP_EOL);\n        }\n\n        $compiler->write(\"\\$assets = \\\\Grav\\\\Common\\\\Grav::instance()['assets'];\" . PHP_EOL);\n        $compiler->write(\"\\$block = \\$context['block'] ?? null;\" . PHP_EOL);\n\n        if ($this->hasNode('file')) {\n            // JS file.\n            $compiler\n                ->write('$file = (string)(')\n                ->subcompile($this->getNode('file'))\n                ->raw(');' . PHP_EOL);\n\n            $method = $this->getAttribute('type') === 'module' ? 'addJsModule' : 'addJs';\n\n            // Assets support.\n            $compiler->write('$assets->' . $method . '($file, [\\'group\\' => $group, \\'priority\\' => $priority] + $attributes);' . PHP_EOL);\n\n            $method = $this->getAttribute('type') === 'module' ? 'addModule' : 'addScript';\n\n            // HtmlBlock support.\n            $compiler\n                ->write('if ($block instanceof \\Grav\\Framework\\ContentBlock\\HtmlBlock) {' . PHP_EOL)\n                ->indent()\n                ->write('$block->' . $method . '([\\'src\\'=> $file] + $attributes, $priority, $group);' . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n\n        } else {\n            // Inline script.\n            $compiler\n                ->write('ob_start();' . PHP_EOL)\n                ->subcompile($this->getNode('body'))\n                ->write('$content = ob_get_clean();' . PHP_EOL);\n\n            $method = $this->getAttribute('type') === 'module' ? 'addInlineJsModule' : 'addInlineJs';\n\n            // Assets support.\n            $compiler->write('$assets->' . $method . '($content, [\\'group\\' => $group, \\'priority\\' => $priority] + $attributes);' . PHP_EOL);\n\n            $method = $this->getAttribute('type') === 'module' ? 'addInlineModule' : 'addInlineScript';\n\n            // HtmlBlock support.\n            $compiler\n                ->write('if ($block instanceof \\Grav\\Framework\\ContentBlock\\HtmlBlock) {' . PHP_EOL)\n                ->indent()\n                ->write('$block->' . $method . '([\\'content\\'=> $content] + $attributes, $priority, $group);' . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeStyle.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse LogicException;\nuse Twig\\Compiler;\nuse Twig\\Node\\Expression\\AbstractExpression;\nuse Twig\\Node\\Node;\nuse Twig\\Node\\NodeCaptureInterface;\n\n/**\n * Class TwigNodeStyle\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeStyle extends Node implements NodeCaptureInterface\n{\n    /** @var string */\n    protected $tagName = 'style';\n\n    /**\n     * TwigNodeAssets constructor.\n     * @param Node|null $body\n     * @param AbstractExpression|null $file\n     * @param AbstractExpression|null $group\n     * @param AbstractExpression|null $priority\n     * @param AbstractExpression|null $attributes\n     * @param int $lineno\n     * @param string|null $tag\n     */\n    public function __construct(?Node $body, ?AbstractExpression $file, ?AbstractExpression $group, ?AbstractExpression $priority, ?AbstractExpression $attributes, $lineno = 0, $tag = null)\n    {\n        $nodes = ['body' => $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes];\n        $nodes = array_filter($nodes);\n\n        parent::__construct($nodes, [], $lineno, $tag);\n    }\n\n    /**\n     * Compiles the node to PHP.\n     *\n     * @param Compiler $compiler A Twig Compiler instance\n     * @return void\n     * @throws LogicException\n     */\n    public function compile(Compiler $compiler): void\n    {\n        $compiler->addDebugInfo($this);\n\n        if ($this->hasNode('attributes')) {\n            $compiler\n                ->write('$attributes = ')\n                ->subcompile($this->getNode('attributes'))\n                ->raw(';' . PHP_EOL)\n                ->write('if (!is_array($attributes)) {' . PHP_EOL)\n                ->indent()\n                ->write(\"throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\" . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n        } else {\n            $compiler->write('$attributes = [];' . PHP_EOL);\n        }\n\n        if ($this->hasNode('group')) {\n            $compiler\n                ->write('$group = ')\n                ->subcompile($this->getNode('group'))\n                ->raw(';' . PHP_EOL)\n                ->write('if (!is_string($group)) {' . PHP_EOL)\n                ->indent()\n                ->write(\"throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\" . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n        } else {\n            $compiler->write('$group = \\'head\\';' . PHP_EOL);\n        }\n\n        if ($this->hasNode('priority')) {\n            $compiler\n                ->write('$priority = (int)(')\n                ->subcompile($this->getNode('priority'))\n                ->raw(');' . PHP_EOL);\n        } else {\n            $compiler->write('$priority = 10;' . PHP_EOL);\n        }\n\n        $compiler->write(\"\\$assets = \\\\Grav\\\\Common\\\\Grav::instance()['assets'];\" . PHP_EOL);\n        $compiler->write(\"\\$block = \\$context['block'] ?? null;\" . PHP_EOL);\n\n        if ($this->hasNode('file')) {\n            // CSS file.\n            $compiler\n                ->write('$file = (string)(')\n                ->subcompile($this->getNode('file'))\n                ->raw(');' . PHP_EOL);\n\n            // Assets support.\n            $compiler->write('$assets->addCss($file, [\\'group\\' => $group, \\'priority\\' => $priority] + $attributes);' . PHP_EOL);\n\n            // HtmlBlock support.\n            $compiler\n                ->write('if ($block instanceof \\Grav\\Framework\\ContentBlock\\HtmlBlock) {' . PHP_EOL)\n                ->indent()\n                ->write('$block->addStyle([\\'href\\'=> $file] + $attributes, $priority, $group);' . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n\n        } else {\n            // Inline style.\n            $compiler\n                ->write('ob_start();' . PHP_EOL)\n                ->subcompile($this->getNode('body'))\n                ->write('$content = ob_get_clean();' . PHP_EOL);\n\n            // Assets support.\n            $compiler->write('$assets->addInlineCss($content, [\\'group\\' => $group, \\'priority\\' => $priority] + $attributes);' . PHP_EOL);\n\n            // HtmlBlock support.\n            $compiler\n                ->write('if ($block instanceof \\Grav\\Framework\\ContentBlock\\HtmlBlock) {' . PHP_EOL)\n                ->indent()\n                ->write('$block->addInlineStyle([\\'content\\'=> $content] + $attributes, $priority, $group);' . PHP_EOL)\n                ->outdent()\n                ->write('}' . PHP_EOL);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse Twig\\Compiler;\nuse Twig\\Node\\Node;\n\n/**\n * Class TwigNodeSwitch\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeSwitch extends Node\n{\n    /**\n     * TwigNodeSwitch constructor.\n     * @param Node $value\n     * @param Node $cases\n     * @param Node|null $default\n     * @param int $lineno\n     * @param string|null $tag\n     */\n    public function __construct(Node $value, Node $cases, Node $default = null, $lineno = 0, $tag = null)\n    {\n        $nodes = ['value' => $value, 'cases' => $cases, 'default' => $default];\n        $nodes = array_filter($nodes);\n\n        parent::__construct($nodes, [], $lineno, $tag);\n    }\n\n    /**\n     * Compiles the node to PHP.\n     *\n     * @param Compiler $compiler A Twig Compiler instance\n     * @return void\n     */\n    public function compile(Compiler $compiler): void\n    {\n        $compiler\n            ->addDebugInfo($this)\n            ->write('switch (')\n            ->subcompile($this->getNode('value'))\n            ->raw(\") {\\n\")\n            ->indent();\n\n        /** @var Node $case */\n        foreach ($this->getNode('cases') as $case) {\n            if (!$case->hasNode('body')) {\n                continue;\n            }\n\n            foreach ($case->getNode('values') as $value) {\n                $compiler\n                    ->write('case ')\n                    ->subcompile($value)\n                    ->raw(\":\\n\");\n            }\n\n            $compiler\n                ->write(\"{\\n\")\n                ->indent()\n                ->subcompile($case->getNode('body'))\n                ->write(\"break;\\n\")\n                ->outdent()\n                ->write(\"}\\n\");\n        }\n\n        if ($this->hasNode('default')) {\n            $compiler\n                ->write(\"default:\\n\")\n                ->write(\"{\\n\")\n                ->indent()\n                ->subcompile($this->getNode('default'))\n                ->outdent()\n                ->write(\"}\\n\");\n        }\n\n        $compiler\n            ->outdent()\n            ->write(\"}\\n\");\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeThrow.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse LogicException;\nuse Twig\\Compiler;\nuse Twig\\Node\\Node;\n\n/**\n * Class TwigNodeThrow\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeThrow extends Node\n{\n    /**\n     * TwigNodeThrow constructor.\n     * @param int $code\n     * @param Node $message\n     * @param int $lineno\n     * @param string|null $tag\n     */\n    public function __construct($code, Node $message, $lineno = 0, $tag = null)\n    {\n        parent::__construct(['message' => $message], ['code' => $code], $lineno, $tag);\n    }\n\n    /**\n     * Compiles the node to PHP.\n     *\n     * @param Compiler $compiler A Twig Compiler instance\n     * @return void\n     * @throws LogicException\n     */\n    public function compile(Compiler $compiler): void\n    {\n        $compiler->addDebugInfo($this);\n\n        $compiler\n            ->write('throw new \\Grav\\Common\\Twig\\Exception\\TwigException(')\n            ->subcompile($this->getNode('message'))\n            ->write(', ')\n            ->write($this->getAttribute('code') ?: 500)\n            ->write(\");\\n\");\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\Node;\n\nuse LogicException;\nuse Twig\\Compiler;\nuse Twig\\Node\\Node;\n\n/**\n * Class TwigNodeTryCatch\n * @package Grav\\Common\\Twig\\Node\n */\nclass TwigNodeTryCatch extends Node\n{\n    /**\n     * TwigNodeTryCatch constructor.\n     * @param Node $try\n     * @param Node|null $catch\n     * @param int $lineno\n     * @param string|null $tag\n     */\n    public function __construct(Node $try, Node $catch = null, $lineno = 0, $tag = null)\n    {\n        $nodes = ['try' => $try, 'catch' => $catch];\n        $nodes = array_filter($nodes);\n\n        parent::__construct($nodes, [], $lineno, $tag);\n    }\n\n    /**\n     * Compiles the node to PHP.\n     *\n     * @param Compiler $compiler A Twig Compiler instance\n     * @return void\n     * @throws LogicException\n     */\n    public function compile(Compiler $compiler): void\n    {\n        $compiler->addDebugInfo($this);\n\n        $compiler->write('try {');\n\n        $compiler\n            ->indent()\n            ->subcompile($this->getNode('try'))\n            ->outdent()\n            ->write('} catch (\\Exception $e) {' . \"\\n\")\n            ->indent()\n            ->write('if (isset($context[\\'grav\\'][\\'debugger\\'])) $context[\\'grav\\'][\\'debugger\\']->addException($e);' . \"\\n\")\n            ->write('$context[\\'e\\'] = $e;' . \"\\n\");\n\n        if ($this->hasNode('catch')) {\n            $compiler->subcompile($this->getNode('catch'));\n        }\n\n        $compiler\n            ->outdent()\n            ->write(\"}\\n\");\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Twig\\Node\\TwigNodeCache;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Adds ability to cache Twig between tags.\n *\n * {% cache 600 %}\n * {{ some_complex_work() }}\n * {% endcache %}\n *\n * Also can provide a unique key for the cache:\n *\n * {% cache \"prefix-\"~lang 600 %}\n *\n * Where the \"prefix-\"~lang will use a unique key based on the current language. \"prefix-en\" for example\n */\nclass TwigTokenParserCache extends AbstractTokenParser\n{\n    public function parse(Token $token)\n    {\n        $stream = $this->parser->getStream();\n        $lineno = $token->getLine();\n\n        // Parse the optional key and timeout parameters\n        $defaults = [\n            'key' => $this->parser->getVarName() . $lineno,\n            'lifetime' => Grav::instance()['cache']->getLifetime()\n        ];\n\n        $key = null;\n        $lifetime = null;\n        while (!$stream->test(Token::BLOCK_END_TYPE)) {\n            if ($stream->test(Token::STRING_TYPE)) {\n                $key = $this->parser->getExpressionParser()->parseExpression();\n            } elseif ($stream->test(Token::NUMBER_TYPE)) {\n                $lifetime = $this->parser->getExpressionParser()->parseExpression();\n            } else {\n                throw new \\Twig\\Error\\SyntaxError(\"Unexpected token type in cache tag.\", $token->getLine(), $stream->getSourceContext());\n            }\n        }\n\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        // Parse the content inside the cache block\n        $body = $this->parser->subparse([$this, 'decideCacheEnd'], true);\n\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        return new TwigNodeCache($body, $key, $lifetime, $defaults, $lineno, $this->getTag());\n    }\n\n    public function decideCacheEnd(Token $token): bool\n    {\n        return $token->test('endcache');\n    }\n\n    public function getTag(): string\n    {\n        return 'cache';\n    }\n}"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserLink.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Twig\\Node\\TwigNodeLink;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Adds a link to the document. First parameter is always value of `rel` without quotes.\n *\n * {% link icon 'theme://images/favicon.png' priority: 20 with { type: 'image/png' } %}\n * {% link modulepreload 'plugin://grav-plugin/build/js/vendor.js' %}\n */\nclass TwigTokenParserLink extends AbstractTokenParser\n{\n    protected $rel = [\n        'alternate',\n        'author',\n        'dns-prefetch',\n        'help',\n        'icon',\n        'license',\n        'next',\n        'pingback',\n        'preconnect',\n        'prefetch',\n        'preload',\n        'prerender',\n        'prev',\n        'search',\n        'stylesheet',\n    ];\n\n    /**\n     * Parses a token and returns a node.\n     *\n     * @param Token $token\n     * @return TwigNodeLink\n     * @throws SyntaxError\n     */\n    public function parse(Token $token)\n    {\n        $lineno = $token->getLine();\n\n        [$rel, $file, $group, $priority, $attributes] = $this->parseArguments($token);\n\n        return new TwigNodeLink($rel, $file, $group, $priority, $attributes, $lineno, $this->getTag());\n    }\n\n    /**\n     * @param Token $token\n     * @return array\n     */\n    protected function parseArguments(Token $token): array\n    {\n        $stream = $this->parser->getStream();\n\n\n        $rel = null;\n        if ($stream->test(Token::NAME_TYPE, $this->rel)) {\n            $rel = $stream->getCurrent()->getValue();\n            $stream->next();\n        }\n\n        $file = null;\n        if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::BLOCK_END_TYPE)) {\n            $file = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $group = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'at')) {\n            $group = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $priority = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'priority')) {\n            $stream->expect(Token::PUNCTUATION_TYPE, ':');\n            $priority = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $attributes = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'with')) {\n            $attributes = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        return [$rel, $file, $group, $priority, $attributes];\n    }\n\n    /**\n     * Gets the tag name associated with this token parser.\n     *\n     * @return string The tag name\n     */\n    public function getTag(): string\n    {\n        return 'link';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Twig\\Node\\TwigNodeMarkdown;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Adds ability to inline markdown between tags.\n *\n * {% markdown %}\n * This is **bold** and this _underlined_\n *\n * 1. This is a bullet list\n * 2. This is another item in that same list\n * {% endmarkdown %}\n */\nclass TwigTokenParserMarkdown extends AbstractTokenParser\n{\n    /**\n     * @param Token $token\n     * @return TwigNodeMarkdown\n     * @throws SyntaxError\n     */\n    public function parse(Token $token)\n    {\n        $lineno = $token->getLine();\n        $this->parser->getStream()->expect(Token::BLOCK_END_TYPE);\n        $body = $this->parser->subparse([$this, 'decideMarkdownEnd'], true);\n        $this->parser->getStream()->expect(Token::BLOCK_END_TYPE);\n        return new TwigNodeMarkdown($body, $lineno, $this->getTag());\n    }\n    /**\n     * Decide if current token marks end of Markdown block.\n     *\n     * @param Token $token\n     * @return bool\n     */\n    public function decideMarkdownEnd(Token $token): bool\n    {\n        return $token->test('endmarkdown');\n    }\n    /**\n     * {@inheritdoc}\n     */\n    public function getTag(): string\n    {\n        return 'markdown';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserRender.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Twig\\Node\\TwigNodeRender;\nuse Twig\\Node\\Node;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Renders an object.\n *\n * {% render object layout: 'default' with { variable: true } %}\n */\nclass TwigTokenParserRender extends AbstractTokenParser\n{\n    /**\n     * Parses a token and returns a node.\n     *\n     * @param Token $token\n     * @return TwigNodeRender\n     */\n    public function parse(Token $token)\n    {\n        $lineno = $token->getLine();\n\n        [$object, $layout, $context] = $this->parseArguments($token);\n\n        return new TwigNodeRender($object, $layout, $context, $lineno, $this->getTag());\n    }\n\n    /**\n     * @param Token $token\n     * @return array\n     */\n    protected function parseArguments(Token $token): array\n    {\n        $stream = $this->parser->getStream();\n\n        $object = $this->parser->getExpressionParser()->parseExpression();\n\n        $layout = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'layout')) {\n            $stream->expect(Token::PUNCTUATION_TYPE, ':');\n            $layout = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $context = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'with')) {\n            $context = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        return [$object, $layout, $context];\n    }\n\n    /**\n     * Gets the tag name associated with this token parser.\n     *\n     * @return string The tag name\n     */\n    public function getTag(): string\n    {\n        return 'render';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Twig\\Node\\TwigNodeScript;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Adds a script to head/bottom/custom group location in the document.\n *\n * {% script 'theme://js/something.js' at 'bottom' priority: 20 with { position: 'pipeline', loading: 'async defer' } %}\n * {% script module 'theme://js/module.mjs' at 'head' %}\n *\n * {% script 'theme://js/something.js' at 'bottom' priority: 20 with { loading: 'inline' } %}\n * {% script at 'bottom' priority: 20 %}\n *   alert('Warning!');\n * {% endscript %}\n *\n * {% script module 'theme://js/module.mjs' at 'bottom' with { loading: 'inline' } %}\n * {% script module at 'bottom' %}\n *   ...\n * {% endscript %}\n */\nclass TwigTokenParserScript extends AbstractTokenParser\n{\n    /**\n     * Parses a token and returns a node.\n     *\n     * @param Token $token\n     * @return TwigNodeScript\n     * @throws SyntaxError\n     */\n    public function parse(Token $token)\n    {\n        $lineno = $token->getLine();\n        $stream = $this->parser->getStream();\n\n        [$type, $file, $group, $priority, $attributes] = $this->parseArguments($token);\n\n        $content = null;\n        if ($file === null) {\n            $content = $this->parser->subparse([$this, 'decideBlockEnd'], true);\n            $stream->expect(Token::BLOCK_END_TYPE);\n        }\n\n        return new TwigNodeScript($content, $type, $file, $group, $priority, $attributes, $lineno, $this->getTag());\n    }\n\n    /**\n     * @param Token $token\n     * @return array\n     */\n    protected function parseArguments(Token $token): array\n    {\n        $stream = $this->parser->getStream();\n\n        // Look for deprecated {% script ... in ... %}\n        if (!$stream->test(Token::BLOCK_END_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in')) {\n            $i = 0;\n            do {\n                $token = $stream->look(++$i);\n                if ($token->test(Token::BLOCK_END_TYPE)) {\n                    break;\n                }\n                if ($token->test(Token::OPERATOR_TYPE, 'in') && $stream->look($i+1)->test(Token::STRING_TYPE)) {\n                    user_error(\"Twig: Using {% script ... in ... %} is deprecated, use {% script ...  at ... %} instead\", E_USER_DEPRECATED);\n\n                    break;\n                }\n            } while (true);\n        }\n\n        $type = null;\n        if ($stream->test(Token::NAME_TYPE, 'module')) {\n            $type = $stream->getCurrent()->getValue();\n            $stream->next();\n        }\n\n        $file = null;\n        if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in') && !$stream->test(Token::BLOCK_END_TYPE)) {\n            $file = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $group = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) {\n            $group = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $priority = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'priority')) {\n            $stream->expect(Token::PUNCTUATION_TYPE, ':');\n            $priority = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $attributes = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'with')) {\n            $attributes = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        return [$type, $file, $group, $priority, $attributes];\n    }\n\n    /**\n     * @param Token $token\n     * @return bool\n     */\n    public function decideBlockEnd(Token $token): bool\n    {\n        return $token->test('endscript');\n    }\n\n    /**\n     * Gets the tag name associated with this token parser.\n     *\n     * @return string The tag name\n     */\n    public function getTag(): string\n    {\n        return 'script';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Twig\\Node\\TwigNodeStyle;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Adds a style to the document.\n *\n * {% style 'theme://css/foo.css' priority: 20 %}\n\n * {% style priority: 20 with { media: 'screen' } %}\n *     a { color: red; }\n * {% endstyle %}\n */\nclass TwigTokenParserStyle extends AbstractTokenParser\n{\n    /**\n     * Parses a token and returns a node.\n     *\n     * @param Token $token\n     * @return TwigNodeStyle\n     * @throws SyntaxError\n     */\n    public function parse(Token $token)\n    {\n        $lineno = $token->getLine();\n        $stream = $this->parser->getStream();\n\n        [$file, $group, $priority, $attributes] = $this->parseArguments($token);\n\n        $content = null;\n        if (!$file) {\n            $content = $this->parser->subparse([$this, 'decideBlockEnd'], true);\n            $stream->expect(Token::BLOCK_END_TYPE);\n        }\n\n        return new TwigNodeStyle($content, $file, $group, $priority, $attributes, $lineno, $this->getTag());\n    }\n\n    /**\n     * @param Token $token\n     * @return array\n     */\n    protected function parseArguments(Token $token): array\n    {\n        $stream = $this->parser->getStream();\n\n        // Look for deprecated {% style ... in ... %}\n        if (!$stream->test(Token::BLOCK_END_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in')) {\n            $i = 0;\n            do {\n                $token = $stream->look(++$i);\n                if ($token->test(Token::BLOCK_END_TYPE)) {\n                    break;\n                }\n                if ($token->test(Token::OPERATOR_TYPE, 'in') && $stream->look($i+1)->test(Token::STRING_TYPE)) {\n                    user_error(\"Twig: Using {% style ... in ... %} is deprecated, use {% style ...  at ... %} instead\", E_USER_DEPRECATED);\n\n                    break;\n                }\n            } while (true);\n        }\n\n        $file = null;\n        if (!$stream->test(Token::NAME_TYPE) && !$stream->test(Token::OPERATOR_TYPE, 'in') && !$stream->test(Token::BLOCK_END_TYPE)) {\n            $file = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $group = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'at') || $stream->nextIf(Token::OPERATOR_TYPE, 'in')) {\n            $group = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $priority = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'priority')) {\n            $stream->expect(Token::PUNCTUATION_TYPE, ':');\n            $priority = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $attributes = null;\n        if ($stream->nextIf(Token::NAME_TYPE, 'with')) {\n            $attributes = $this->parser->getExpressionParser()->parseExpression();\n        }\n\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        return [$file, $group, $priority, $attributes];\n    }\n\n    /**\n     * @param Token $token\n     * @return bool\n     */\n    public function decideBlockEnd(Token $token): bool\n    {\n        return $token->test('endstyle');\n    }\n\n    /**\n     * Gets the tag name associated with this token parser.\n     *\n     * @return string The tag name\n     */\n    public function getTag(): string\n    {\n        return 'style';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n * @origin     https://gist.github.com/maxgalbu/9409182\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Twig\\Node\\TwigNodeSwitch;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Node\\Node;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Adds ability use elegant switch instead of ungainly if statements\n *\n * {% switch type %}\n *   {% case 'foo' %}\n *      {{ my_data.foo }}\n *   {% case 'bar' %}\n *      {{ my_data.bar }}\n *   {% default %}\n *      {{ my_data.default }}\n * {% endswitch %}\n */\nclass TwigTokenParserSwitch extends AbstractTokenParser\n{\n    /**\n     * @param Token $token\n     * @return TwigNodeSwitch\n     * @throws SyntaxError\n     */\n    public function parse(Token $token)\n    {\n        $lineno = $token->getLine();\n        $stream = $this->parser->getStream();\n\n        $name = $this->parser->getExpressionParser()->parseExpression();\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        // There can be some whitespace between the {% switch %} and first {% case %} tag.\n        while ($stream->getCurrent()->getType() === Token::TEXT_TYPE && trim($stream->getCurrent()->getValue()) === '') {\n            $stream->next();\n        }\n\n        $stream->expect(Token::BLOCK_START_TYPE);\n\n        $expressionParser = $this->parser->getExpressionParser();\n\n        $default = null;\n        $cases = [];\n        $end = false;\n\n        while (!$end) {\n            $next = $stream->next();\n\n            switch ($next->getValue()) {\n                case 'case':\n                    $values = [];\n\n                    while (true) {\n                        $values[] = $expressionParser->parsePrimaryExpression();\n                        // Multiple allowed values?\n                        if ($stream->test(Token::OPERATOR_TYPE, 'or')) {\n                            $stream->next();\n                        } else {\n                            break;\n                        }\n                    }\n\n                    $stream->expect(Token::BLOCK_END_TYPE);\n                    $body = $this->parser->subparse([$this, 'decideIfFork']);\n                    $cases[] = new Node([\n                        'values' => new Node($values),\n                        'body' => $body\n                    ]);\n                    break;\n\n                case 'default':\n                    $stream->expect(Token::BLOCK_END_TYPE);\n                    $default = $this->parser->subparse([$this, 'decideIfEnd']);\n                    break;\n\n                case 'endswitch':\n                    $end = true;\n                    break;\n\n                default:\n                    throw new SyntaxError(sprintf('Unexpected end of template. Twig was looking for the following tags \"case\", \"default\", or \"endswitch\" to close the \"switch\" block started at line %d)', $lineno), -1);\n            }\n        }\n\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        return new TwigNodeSwitch($name, new Node($cases), $default, $lineno, $this->getTag());\n    }\n\n    /**\n     * Decide if current token marks switch logic.\n     *\n     * @param Token $token\n     * @return bool\n     */\n    public function decideIfFork(Token $token): bool\n    {\n        return $token->test(['case', 'default', 'endswitch']);\n    }\n\n    /**\n     * Decide if current token marks end of swtich block.\n     *\n     * @param Token $token\n     * @return bool\n     */\n    public function decideIfEnd(Token $token): bool\n    {\n        return $token->test(['endswitch']);\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getTag(): string\n    {\n        return 'switch';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserThrow.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Twig\\Node\\TwigNodeThrow;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Node\\Node;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Handles try/catch in template file.\n *\n * <pre>\n * {% throw 404 'Not Found' %}\n * </pre>\n */\nclass TwigTokenParserThrow extends AbstractTokenParser\n{\n    /**\n     * Parses a token and returns a node.\n     *\n     * @param Token $token\n     * @return TwigNodeThrow\n     * @throws SyntaxError\n     */\n    public function parse(Token $token)\n    {\n        $lineno = $token->getLine();\n        $stream = $this->parser->getStream();\n\n        $code = $stream->expect(Token::NUMBER_TYPE)->getValue();\n        $message = $this->parser->getExpressionParser()->parseExpression();\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        return new TwigNodeThrow((int)$code, $message, $lineno, $this->getTag());\n    }\n\n    /**\n     * Gets the tag name associated with this token parser.\n     *\n     * @return string The tag name\n     */\n    public function getTag(): string\n    {\n        return 'throw';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig\\TokenParser;\n\nuse Grav\\Common\\Twig\\Node\\TwigNodeTryCatch;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Node\\Node;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\n\n/**\n * Handles try/catch in template file.\n *\n * <pre>\n * {% try %}\n *    <li>{{ user.get('name') }}</li>\n * {% catch %}\n *    {{ e.message }}\n * {% endcatch %}\n * </pre>\n */\nclass TwigTokenParserTryCatch extends AbstractTokenParser\n{\n    /**\n     * Parses a token and returns a node.\n     *\n     * @param Token $token\n     * @return TwigNodeTryCatch\n     * @throws SyntaxError\n     */\n    public function parse(Token $token)\n    {\n        $lineno = $token->getLine();\n        $stream = $this->parser->getStream();\n\n        $stream->expect(Token::BLOCK_END_TYPE);\n        $try = $this->parser->subparse([$this, 'decideCatch']);\n        $stream->next();\n        $stream->expect(Token::BLOCK_END_TYPE);\n        $catch = $this->parser->subparse([$this, 'decideEnd']);\n        $stream->next();\n        $stream->expect(Token::BLOCK_END_TYPE);\n\n        return new TwigNodeTryCatch($try, $catch, $lineno, $this->getTag());\n    }\n\n    /**\n     * @param Token $token\n     * @return bool\n     */\n    public function decideCatch(Token $token): bool\n    {\n        return $token->test(['catch']);\n    }\n\n    /**\n     * @param Token $token\n     * @return bool\n     */\n    public function decideEnd(Token $token): bool\n    {\n        return $token->test(['endtry']) || $token->test(['endcatch']);\n    }\n\n    /**\n     * Gets the tag name associated with this token parser.\n     *\n     * @return string The tag name\n     */\n    public function getTag(): string\n    {\n        return 'try';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/Twig.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig;\n\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Language\\LanguageCodes;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Security;\nuse Grav\\Common\\Twig\\Exception\\TwigException;\nuse Grav\\Common\\Twig\\Extension\\FilesystemExtension;\nuse Grav\\Common\\Twig\\Extension\\GravExtension;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RuntimeException;\nuse Twig\\Cache\\FilesystemCache;\nuse Twig\\DeferredExtension\\DeferredExtension;\nuse Twig\\Environment;\nuse Twig\\Error\\LoaderError;\nuse Twig\\Error\\RuntimeError;\nuse Twig\\Extension\\CoreExtension;\nuse Twig\\Extension\\DebugExtension;\nuse Twig\\Extension\\StringLoaderExtension;\nuse Twig\\Loader\\ArrayLoader;\nuse Twig\\Loader\\ChainLoader;\nuse Twig\\Loader\\ExistsLoaderInterface;\nuse Twig\\Loader\\FilesystemLoader;\nuse Twig\\Profiler\\Profile;\nuse Twig\\TwigFilter;\nuse Twig\\TwigFunction;\nuse function function_exists;\nuse function in_array;\nuse function is_array;\n\n/**\n * Class Twig\n * @package Grav\\Common\\Twig\n */\nclass Twig\n{\n    /** @var Environment */\n    public $twig;\n    /** @var array */\n    public $twig_vars = [];\n    /** @var array */\n    public $twig_paths;\n    /** @var string */\n    public $template;\n\n    /** @var array */\n    public $plugins_hooked_nav = [];\n    /** @var array */\n    public $plugins_quick_tray = [];\n    /** @var array */\n    public $plugins_hooked_dashboard_widgets_top = [];\n    /** @var array */\n    public $plugins_hooked_dashboard_widgets_main = [];\n\n    /** @var Grav */\n    protected $grav;\n    /** @var FilesystemLoader */\n    protected $loader;\n    /** @var ArrayLoader */\n    protected $loaderArray;\n    /** @var bool */\n    protected $autoescape;\n    /** @var Profile */\n    protected $profile;\n\n    /**\n     * Constructor\n     *\n     * @param Grav $grav\n     */\n    public function __construct(Grav $grav)\n    {\n        $this->grav = $grav;\n        $this->twig_paths = [];\n    }\n\n    /**\n     * Twig initialization that sets the twig loader chain, then the environment, then extensions\n     * and also the base set of twig vars\n     *\n     * @return $this\n     */\n    public function init()\n    {\n        if (null === $this->twig) {\n            /** @var Config $config */\n            $config = $this->grav['config'];\n            /** @var UniformResourceLocator $locator */\n            $locator = $this->grav['locator'];\n            /** @var Language $language */\n            $language = $this->grav['language'];\n\n            $active_language = $language->getActive();\n\n            // handle language templates if available\n            if ($language->enabled()) {\n                $lang_templates = $locator->findResource('theme://templates/' . ($active_language ?: $language->getDefault()));\n                if ($lang_templates) {\n                    $this->twig_paths[] = $lang_templates;\n                }\n            }\n\n            $this->twig_paths = array_merge($this->twig_paths, $locator->findResources('theme://templates'));\n\n            $this->grav->fireEvent('onTwigTemplatePaths');\n\n            // Add Grav core templates location\n            $core_templates = array_merge($locator->findResources('system://templates'), $locator->findResources('system://templates/testing'));\n            $this->twig_paths = array_merge($this->twig_paths, $core_templates);\n\n            $this->loader = new FilesystemLoader($this->twig_paths);\n\n            // Register all other prefixes as namespaces in twig\n            foreach ($locator->getPaths('theme') as $prefix => $_) {\n                if ($prefix === '') {\n                    continue;\n                }\n\n                $twig_paths = [];\n\n                // handle language templates if available\n                if ($language->enabled()) {\n                    $lang_templates = $locator->findResource('theme://'.$prefix.'templates/' . ($active_language ?: $language->getDefault()));\n                    if ($lang_templates) {\n                        $twig_paths[] = $lang_templates;\n                    }\n                }\n\n                $twig_paths = array_merge($twig_paths, $locator->findResources('theme://'.$prefix.'templates'));\n\n                $namespace = trim($prefix, '/');\n                $this->loader->setPaths($twig_paths, $namespace);\n            }\n\n            $this->grav->fireEvent('onTwigLoader');\n\n            $this->loaderArray = new ArrayLoader([]);\n            $loader_chain = new ChainLoader([$this->loaderArray, $this->loader]);\n\n            $params = $config->get('system.twig');\n            if (!empty($params['cache'])) {\n                $cachePath = $locator->findResource('cache://twig', true, true);\n                $params['cache'] = new FilesystemCache($cachePath, FilesystemCache::FORCE_BYTECODE_INVALIDATION);\n            }\n\n            if (!$config->get('system.strict_mode.twig_compat', false)) {\n                // Force autoescape on for all files if in strict mode.\n                $params['autoescape'] = 'html';\n            } elseif (!empty($this->autoescape)) {\n                $params['autoescape'] = $this->autoescape ? 'html' : false;\n            }\n\n            if (empty($params['autoescape'])) {\n                user_error('Grav 2.0 will have Twig auto-escaping forced on (can be emulated by turning off \\'system.strict_mode.twig_compat\\' setting in your configuration)', E_USER_DEPRECATED);\n            }\n\n            $this->twig = new TwigEnvironment($loader_chain, $params);\n\n            $this->twig->registerUndefinedFunctionCallback(function (string $name) use ($config) {\n                $allowed = $config->get('system.twig.safe_functions');\n                if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) {\n                    return new TwigFunction($name, $name);\n                }\n                if ($config->get('system.twig.undefined_functions')) {\n                    if (function_exists($name)) {\n                        if (!Utils::isDangerousFunction($name)) {\n                            user_error(\"PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_functions`\", E_USER_DEPRECATED);\n\n                            return new TwigFunction($name, $name);\n                        }\n\n                        /** @var Debugger $debugger */\n                        $debugger = $this->grav['debugger'];\n                        $debugger->addException(new RuntimeException(\"Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: `system.twig.safe_functions`\"));\n                    }\n\n                    return new TwigFunction($name, static function () {});\n                }\n\n                return false;\n            });\n\n            $this->twig->registerUndefinedFilterCallback(function (string $name) use ($config) {\n                $allowed = $config->get('system.twig.safe_filters');\n                if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) {\n                    return new TwigFilter($name, $name);\n                }\n                if ($config->get('system.twig.undefined_filters')) {\n                    if (function_exists($name)) {\n                        if (!Utils::isDangerousFunction($name)) {\n                            user_error(\"PHP function {$name}() used as Twig filter. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_filters`\", E_USER_DEPRECATED);\n\n                            return new TwigFilter($name, $name);\n                        }\n\n                        /** @var Debugger $debugger */\n                        $debugger = $this->grav['debugger'];\n                        $debugger->addException(new RuntimeException(\"Blocked potentially dangerous PHP function {$name}() being used as Twig filter. If you really want to use it, please add it to system configuration: `system.twig.safe_filters`\"));\n                    }\n\n                    return new TwigFilter($name, static function () {});\n                }\n\n                return false;\n            });\n\n            $this->grav->fireEvent('onTwigInitialized');\n\n            // set default date format if set in config\n            if ($config->get('system.pages.dateformat.long')) {\n                /** @var CoreExtension $extension */\n                $extension = $this->twig->getExtension(CoreExtension::class);\n                $extension->setDateFormat($config->get('system.pages.dateformat.long'));\n            }\n            // enable the debug extension if required\n            if ($config->get('system.twig.debug')) {\n                $this->twig->addExtension(new DebugExtension());\n            }\n            $this->twig->addExtension(new GravExtension());\n            $this->twig->addExtension(new FilesystemExtension());\n            $this->twig->addExtension(new DeferredExtension());\n            $this->twig->addExtension(new StringLoaderExtension());\n\n            /** @var Debugger $debugger */\n            $debugger = $this->grav['debugger'];\n            $debugger->addTwigProfiler($this->twig);\n\n            $this->grav->fireEvent('onTwigExtensions');\n\n            /** @var Pages $pages */\n            $pages = $this->grav['pages'];\n\n            // Set some standard variables for twig\n            $this->twig_vars += [\n                    'config'            => $config,\n                    'system'            => $config->get('system'),\n                    'theme'             => $config->get('theme'),\n                    'site'              => $config->get('site'),\n                    'uri'               => $this->grav['uri'],\n                    'assets'            => $this->grav['assets'],\n                    'taxonomy'          => $this->grav['taxonomy'],\n                    'browser'           => $this->grav['browser'],\n                    'base_dir'          => GRAV_ROOT,\n                    'home_url'          => $pages->homeUrl($active_language),\n                    'base_url'          => $pages->baseUrl($active_language),\n                    'base_url_absolute' => $pages->baseUrl($active_language, true),\n                    'base_url_relative' => $pages->baseUrl($active_language, false),\n                    'base_url_simple'   => $this->grav['base_url'],\n                    'theme_dir'         => $locator->findResource('theme://'),\n                    'theme_url'         => $this->grav['base_url'] . '/' . $locator->findResource('theme://', false),\n                    'html_lang'         => $this->grav['language']->getActive() ?: $config->get('site.default_lang', 'en'),\n                    'language_codes'    => new LanguageCodes,\n                ];\n        }\n\n        return $this;\n    }\n\n    /**\n     * @return Environment\n     */\n    public function twig()\n    {\n        return $this->twig;\n    }\n\n    /**\n     * @return FilesystemLoader\n     */\n    public function loader()\n    {\n        return $this->loader;\n    }\n\n    /**\n     * @return Profile\n     */\n    public function profile()\n    {\n        return $this->profile;\n    }\n\n\n    /**\n     * Adds or overrides a template.\n     *\n     * @param string $name     The template name\n     * @param string $template The template source\n     */\n    public function setTemplate($name, $template)\n    {\n        $this->loaderArray->setTemplate($name, $template);\n    }\n\n    /**\n     * Twig process that renders a page item. It supports two variations:\n     * 1) Handles modular pages by rendering a specific page based on its modular twig template\n     * 2) Renders individual page items for twig processing before the site rendering\n     *\n     * @param  PageInterface   $item    The page item to render\n     * @param  string|null $content Optional content override\n     *\n     * @return string          The rendered output\n     */\n    public function processPage(PageInterface $item, $content = null)\n    {\n        $content = $content ?? $item->content();\n        $content = Security::cleanDangerousTwig($content);\n\n        // override the twig header vars for local resolution\n        $this->grav->fireEvent('onTwigPageVariables', new Event(['page' => $item]));\n        $twig_vars = $this->twig_vars;\n\n        $twig_vars['page'] = $item;\n        $twig_vars['media'] = $item->media();\n        $twig_vars['header'] = $item->header();\n        $local_twig = clone $this->twig;\n\n        $output = '';\n\n        try {\n            if ($item->isModule()) {\n                $twig_vars['content'] = $content;\n                $template = $this->getPageTwigTemplate($item);\n                $output = $content = $local_twig->render($template, $twig_vars);\n            }\n\n            // Process in-page Twig\n            if ($item->shouldProcess('twig')) {\n                $name = '@Page:' . $item->path();\n                $this->setTemplate($name, $content);\n                $output = $local_twig->render($name, $twig_vars);\n            }\n\n        } catch (LoaderError $e) {\n            throw new RuntimeException($e->getRawMessage(), 400, $e);\n        }\n\n        return $output;\n    }\n\n    /**\n     * Process a Twig template directly by using a template name\n     * and optional array of variables\n     *\n     * @param string $template template to render with\n     * @param array  $vars     Optional variables\n     *\n     * @return string\n     */\n    public function processTemplate($template, $vars = [])\n    {\n        // override the twig header vars for local resolution\n        $this->grav->fireEvent('onTwigTemplateVariables');\n        $vars += $this->twig_vars;\n\n        try {\n            $output = $this->twig->render($template, $vars);\n        } catch (LoaderError $e) {\n            throw new RuntimeException($e->getRawMessage(), 404, $e);\n        }\n\n        return $output;\n    }\n\n\n    /**\n     * Process a Twig template directly by using a Twig string\n     * and optional array of variables\n     *\n     * @param string $string string to render.\n     * @param array  $vars   Optional variables\n     *\n     * @return string\n     */\n    public function processString($string, array $vars = [])\n    {\n        // override the twig header vars for local resolution\n        $this->grav->fireEvent('onTwigStringVariables');\n        $vars += $this->twig_vars;\n\n        $string = Security::cleanDangerousTwig($string);\n\n        $name = '@Var:' . $string;\n        $this->setTemplate($name, $string);\n\n        try {\n            $output = $this->twig->render($name, $vars);\n        } catch (LoaderError $e) {\n            throw new RuntimeException($e->getRawMessage(), 404, $e);\n        }\n\n        return $output;\n    }\n\n    /**\n     * Twig process that renders the site layout. This is the main twig process that renders the overall\n     * page and handles all the layout for the site display.\n     *\n     * @param string|null $format Output format (defaults to HTML).\n     * @param array $vars\n     * @return string the rendered output\n     * @throws RuntimeException\n     */\n    public function processSite($format = null, array $vars = [])\n    {\n        try {\n            $grav = $this->grav;\n\n            // set the page now it's been processed\n            $grav->fireEvent('onTwigSiteVariables');\n\n            /** @var Pages $pages */\n            $pages = $grav['pages'];\n\n            /** @var PageInterface $page */\n            $page = $grav['page'];\n\n            $content = Security::cleanDangerousTwig($page->content());\n\n            $twig_vars = $this->twig_vars;\n            $twig_vars['theme'] = $grav['config']->get('theme');\n            $twig_vars['pages'] = $pages->root();\n            $twig_vars['page'] = $page;\n            $twig_vars['header'] = $page->header();\n            $twig_vars['media'] = $page->media();\n            $twig_vars['content'] = $content;\n\n            // determine if params are set, if so disable twig cache\n            $params = $grav['uri']->params(null, true);\n            if (!empty($params)) {\n                $this->twig->setCache(false);\n            }\n\n            // Get Twig template layout\n            $template = $this->getPageTwigTemplate($page, $format);\n            $page->templateFormat($format);\n\n            $output = $this->twig->render($template, $vars + $twig_vars);\n        } catch (LoaderError $e) {\n            throw new RuntimeException($e->getMessage(), 400, $e);\n        } catch (RuntimeError $e) {\n            $prev = $e->getPrevious();\n            if ($prev instanceof TwigException) {\n                $code = $prev->getCode() ?: 500;\n                // Fire onPageNotFound event.\n                $event = new Event([\n                    'page' => $page,\n                    'code' => $code,\n                    'message' => $prev->getMessage(),\n                    'exception' => $prev,\n                    'route' => $grav['route'],\n                    'request' => $grav['request']\n                ]);\n                $event = $grav->fireEvent(\"onDisplayErrorPage.{$code}\", $event);\n                $newPage = $event['page'];\n                if ($newPage && $newPage !== $page) {\n                    unset($grav['page']);\n                    $grav['page'] = $newPage;\n\n                    return $this->processSite($newPage->templateFormat(), $vars);\n                }\n            }\n\n            throw $e;\n        }\n\n        return $output;\n    }\n\n    /**\n     * Wraps the FilesystemLoader addPath method (should be used only in `onTwigLoader()` event\n     * @param string $template_path\n     * @param string $namespace\n     * @throws LoaderError\n     */\n    public function addPath($template_path, $namespace = '__main__')\n    {\n        $this->loader->addPath($template_path, $namespace);\n    }\n\n    /**\n     * Wraps the FilesystemLoader prependPath method (should be used only in `onTwigLoader()` event\n     * @param string $template_path\n     * @param string $namespace\n     * @throws LoaderError\n     */\n    public function prependPath($template_path, $namespace = '__main__')\n    {\n        $this->loader->prependPath($template_path, $namespace);\n    }\n\n    /**\n     * Simple helper method to get the twig template if it has already been set, else return\n     * the one being passed in\n     * NOTE: Modular pages that are injected should not use this pre-set template as it's usually set at the page level\n     *\n     * @param  string $template the template name\n     * @return string           the template name\n     */\n    public function template(string $template): string\n    {\n        if (isset($this->template)) {\n            $template = $this->template;\n            unset($this->template);\n        }\n        \n        return $template;\n    }\n\n    /**\n     * @param PageInterface $page\n     * @param string|null $format\n     * @return string\n     */\n    public function getPageTwigTemplate($page, &$format = null)\n    {\n        $template = $page->template();\n        $default = $page->isModule() ? 'modular/default' : 'default';\n        $extension = $format ?: $page->templateFormat();\n        $twig_extension = $extension ? '.'. $extension .TWIG_EXT : TEMPLATE_EXT;\n        $template_file = $this->template($template . $twig_extension);\n\n        // TODO: no longer needed in Twig 3.\n        /** @var ExistsLoaderInterface $loader */\n        $loader = $this->twig->getLoader();\n        if ($loader->exists($template_file)) {\n            // template.xxx.twig\n            $page_template = $template_file;\n        } elseif ($twig_extension !== TEMPLATE_EXT && $loader->exists($template . TEMPLATE_EXT)) {\n            // template.html.twig\n            $page_template = $template . TEMPLATE_EXT;\n            $format = 'html';\n        } elseif ($loader->exists($default . $twig_extension)) {\n            // default.xxx.twig\n            $page_template = $default . $twig_extension;\n        } else {\n            // default.html.twig\n            $page_template = $default . TEMPLATE_EXT;\n            $format = 'html';\n        }\n\n        return $page_template;\n\n    }\n\n    /**\n     * Overrides the autoescape setting\n     *\n     * @param bool $state\n     * @return void\n     * @deprecated 1.5 Auto-escape should always be turned on to protect against XSS issues (can be disabled per template file).\n     */\n    public function setAutoescape($state)\n    {\n        if (!$state) {\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '(false) is deprecated since Grav 1.5', E_USER_DEPRECATED);\n        }\n\n        $this->autoescape = (bool) $state;\n    }\n\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TwigClockworkDataSource.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig;\n\nuse Clockwork\\DataSource\\DataSource;\nuse Clockwork\\Request\\Request;\nuse Twig\\Environment;\nuse Twig\\Extension\\ProfilerExtension;\nuse Twig\\Profiler\\Profile;\n\n/**\n * Class TwigClockworkDataSource\n * @package Grav\\Common\\Twig\n */\nclass TwigClockworkDataSource extends DataSource\n{\n    /** @var Environment */\n    protected $twig;\n\n    /** @var Profile */\n    protected $profile;\n\n    // Create a new data source, takes Twig instance as an argument\n    public function __construct(Environment $twig)\n    {\n        $this->twig = $twig;\n    }\n\n    /**\n     * Register the Twig profiler extension\n     */\n    public function listenToEvents(): void\n    {\n        $this->twig->addExtension(new ProfilerExtension($this->profile = new Profile()));\n    }\n\n    /**\n     * Adds rendered views to the request\n     *\n     * @param Request $request\n     * @return Request\n     */\n    public function resolve(Request $request)\n    {\n        $timeline = (new TwigClockworkDumper())->dump($this->profile);\n\n        $request->viewsData = array_merge($request->viewsData, $timeline->finalize());\n\n        return $request;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TwigClockworkDumper.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig;\n\nuse Clockwork\\Request\\Timeline\\Timeline;\nuse Twig\\Profiler\\Profile;\n\n/**\n * Class TwigClockworkDumper\n * @package Grav\\Common\\Twig\n */\nclass TwigClockworkDumper\n{\n    protected $lastId = 1;\n\n    /**\n     * Dumps a profile into a new rendered views timeline\n     *\n     * @param Profile $profile\n     * @return Timeline\n     */\n    public function dump(Profile $profile)\n    {\n        $timeline = new Timeline;\n\n        $this->dumpProfile($profile, $timeline);\n\n        return $timeline;\n    }\n\n    /**\n     * @param Profile $profile\n     * @param Timeline $timeline\n     * @param null $parent\n     */\n    public function dumpProfile(Profile $profile, Timeline $timeline, $parent = null)\n    {\n        $id = $this->lastId++;\n\n        if ($profile->isRoot()) {\n            $name = $profile->getName();\n        } elseif ($profile->isTemplate()) {\n            $name = $profile->getTemplate();\n        } else {\n            $name = $profile->getTemplate() . '::' . $profile->getType() . '(' . $profile->getName() . ')';\n        }\n\n        foreach ($profile as $p) {\n            $this->dumpProfile($p, $timeline, $id);\n        }\n\n        $data = $profile->__serialize();\n\n        $timeline->event($name, [\n            'name'  => $id,\n            'start' => $data[3]['wt'] ?? null,\n            'end'   => $data[4]['wt'] ?? null,\n            'data'  => [\n                'data'        => [],\n                'memoryUsage' => $data[4]['mu'] ?? null,\n                'parent'      => $parent\n            ]\n        ]);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TwigEnvironment.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig;\n\nuse Twig\\Environment;\nuse Twig\\Error\\LoaderError;\nuse Twig\\Loader\\ExistsLoaderInterface;\nuse Twig\\Loader\\LoaderInterface;\nuse Twig\\Template;\nuse Twig\\TemplateWrapper;\n\n/**\n * Class TwigEnvironment\n * @package Grav\\Common\\Twig\n */\nclass TwigEnvironment extends Environment\n{\n    use WriteCacheFileTrait;\n\n    /**\n     * @inheritDoc\n     */\n    public function resolveTemplate($names)\n    {\n        if (!\\is_array($names)) {\n            $names = [$names];\n        }\n\n        $count = \\count($names);\n        foreach ($names as $name) {\n            if ($name instanceof Template) {\n                return $name;\n            }\n            if ($name instanceof TemplateWrapper) {\n                return $name;\n            }\n\n            // Optimization: Avoid throwing an exception when it would be ignored anyway.\n            if (1 !== $count) {\n                /** @var LoaderInterface|ExistsLoaderInterface $loader */\n                $loader = $this->getLoader();\n                if (!$loader->exists($name)) {\n                    continue;\n                }\n            }\n\n            // Throws LoaderError: Unable to find template \"%s\".\n            return $this->loadTemplate($name);\n        }\n\n        throw new LoaderError(sprintf('Unable to find one of the following templates: \"%s\".', implode('\", \"', $names)));\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/TwigExtension.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig;\n\nuse Grav\\Common\\Twig\\Extension\\GravExtension;\n\n/**\n * Class TwigExtension\n * @package Grav\\Common\\Twig\n * @deprecated 1.7 Use GravExtension instead\n */\nclass TwigExtension extends GravExtension\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Twig/WriteCacheFileTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Twig\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\Twig;\n\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse function dirname;\n\n/**\n * Trait WriteCacheFileTrait\n * @package Grav\\Common\\Twig\n */\ntrait WriteCacheFileTrait\n{\n    /** @var bool */\n    protected static $umask;\n\n    /**\n     * This exists so template cache files use the same\n     * group between apache and cli\n     *\n     * @param string $file\n     * @param string $content\n     * @return void\n     */\n    protected function writeCacheFile($file, $content)\n    {\n        if (empty($file)) {\n            return;\n        }\n\n        if (!isset(self::$umask)) {\n            self::$umask = Grav::instance()['config']->get('system.twig.umask_fix', false);\n        }\n\n        if (self::$umask) {\n            $dir = dirname($file);\n            if (!is_dir($dir)) {\n                $old = umask(0002);\n                Folder::create($dir);\n                umask($old);\n            }\n            parent::writeCacheFile($file, $content);\n            chmod($file, 0775);\n        } else {\n            parent::writeCacheFile($file, $content);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Uri.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Framework\\Route\\Route;\nuse Grav\\Framework\\Route\\RouteFactory;\nuse Grav\\Framework\\Uri\\UriFactory;\nuse Grav\\Framework\\Uri\\UriPartsFilter;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RuntimeException;\nuse function array_key_exists;\nuse function count;\nuse function in_array;\nuse function is_array;\nuse function is_string;\nuse function strlen;\n\n/**\n * Class Uri\n * @package Grav\\Common\n */\nclass Uri\n{\n    const HOSTNAME_REGEX = '/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$/';\n\n    /** @var \\Grav\\Framework\\Uri\\Uri|null */\n    protected static $currentUri;\n\n    /** @var Route|null */\n    protected static $currentRoute;\n\n    /** @var string */\n    public $url;\n\n    // Uri parts.\n    /** @var string|null */\n    protected $scheme;\n    /** @var string|null */\n    protected $user;\n    /** @var string|null */\n    protected $password;\n    /** @var string|null */\n    protected $host;\n    /** @var int|null */\n    protected $port;\n    /** @var string */\n    protected $path;\n    /** @var string */\n    protected $query;\n    /** @var string|null */\n    protected $fragment;\n\n    // Internal stuff.\n    /** @var string */\n    protected $base;\n    /** @var string|null */\n    protected $basename;\n    /** @var string */\n    protected $content_path;\n    /** @var string|null */\n    protected $extension;\n    /** @var string */\n    protected $env;\n    /** @var array */\n    protected $paths;\n    /** @var array */\n    protected $queries;\n    /** @var array */\n    protected $params;\n    /** @var string */\n    protected $root;\n    /** @var string */\n    protected $setup_base;\n    /** @var string */\n    protected $root_path;\n    /** @var string */\n    protected $uri;\n    /** @var array */\n    protected $post;\n\n    /**\n     * Uri constructor.\n     * @param string|array|null $env\n     */\n    public function __construct($env = null)\n    {\n        if (is_string($env)) {\n            $this->createFromString($env);\n        } else {\n            $this->createFromEnvironment(is_array($env) ? $env : $_SERVER);\n        }\n    }\n\n    /**\n     * Initialize the URI class with a url passed via parameter.\n     * Used for testing purposes.\n     *\n     * @param string $url the URL to use in the class\n     * @return $this\n     */\n    public function initializeWithUrl($url = '')\n    {\n        if ($url) {\n            $this->createFromString($url);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Initialize the URI class by providing url and root_path arguments\n     *\n     * @param string $url\n     * @param string $root_path\n     * @return $this\n     */\n    public function initializeWithUrlAndRootPath($url, $root_path)\n    {\n        $this->initializeWithUrl($url);\n        $this->root_path = $root_path;\n\n        return $this;\n    }\n\n    /**\n     * Validate a hostname\n     *\n     * @param string $hostname The hostname\n     * @return bool\n     */\n    public function validateHostname($hostname)\n    {\n        return (bool)preg_match(static::HOSTNAME_REGEX, $hostname);\n    }\n\n    /**\n     * Initializes the URI object based on the url set on the object\n     *\n     * @return void\n     */\n    public function init()\n    {\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        /** @var Language $language */\n        $language = $grav['language'];\n\n        // add the port to the base for non-standard ports\n        if ($this->port && $config->get('system.reverse_proxy_setup') === false) {\n            $this->base .= ':' . $this->port;\n        }\n\n        // Handle custom base\n        $custom_base = rtrim($grav['config']->get('system.custom_base_url', ''), '/');\n        if ($custom_base) {\n            $custom_parts = parse_url($custom_base);\n            if ($custom_parts === false) {\n                throw new RuntimeException('Bad configuration: system.custom_base_url');\n            }\n            $orig_root_path = $this->root_path;\n            $this->root_path = isset($custom_parts['path']) ? rtrim($custom_parts['path'], '/') : '';\n            if (isset($custom_parts['scheme'])) {\n                $this->base = $custom_parts['scheme'] . '://' . $custom_parts['host'];\n                $this->port = $custom_parts['port'] ?? null;\n                if ($this->port && $config->get('system.reverse_proxy_setup') === false) {\n                    $this->base .= ':' . $this->port;\n                }\n                $this->root = $custom_base;\n            } else {\n                $this->root = $this->base . $this->root_path;\n            }\n            $this->uri = Utils::replaceFirstOccurrence($orig_root_path, $this->root_path, $this->uri);\n        } else {\n            $this->root = $this->base . $this->root_path;\n        }\n\n        $this->url = $this->base . $this->uri;\n\n        $uri = Utils::replaceFirstOccurrence(static::filterPath($this->root), '', $this->url);\n\n        // remove the setup.php based base if set:\n        $setup_base = $grav['pages']->base();\n        if ($setup_base) {\n            $uri = preg_replace('|^' . preg_quote($setup_base, '|') . '|', '', $uri);\n        }\n        $this->setup_base = $setup_base;\n\n        // process params\n        $uri = $this->processParams($uri, $config->get('system.param_sep'));\n\n        // set active language\n        $uri = $language->setActiveFromUri($uri);\n\n        // split the URL and params (and make sure that the path isn't seen as domain)\n        $bits = static::parseUrl('http://domain.com' . $uri);\n\n        //process fragment\n        if (isset($bits['fragment'])) {\n            $this->fragment = $bits['fragment'];\n        }\n\n        // Get the path. If there's no path, make sure pathinfo() still returns dirname variable\n        $path = $bits['path'] ?? '/';\n\n        // remove the extension if there is one set\n        $parts = Utils::pathinfo($path);\n\n        // set the original basename\n        $this->basename = $parts['basename'];\n\n        // set the extension\n        if (isset($parts['extension'])) {\n            $this->extension = $parts['extension'];\n        }\n\n        // Strip the file extension for valid page types\n        if ($this->isValidExtension($this->extension)) {\n            $path = Utils::replaceLastOccurrence(\".{$this->extension}\", '', $path);\n        }\n\n        // set the new url\n        $this->url = $this->root . $path;\n        $this->path = static::cleanPath($path);\n        $this->content_path = trim(Utils::replaceFirstOccurrence($this->base, '', $this->path), '/');\n        if ($this->content_path !== '') {\n            $this->paths = explode('/', $this->content_path);\n        }\n\n        // Set some Grav stuff\n        $grav['base_url_absolute'] = $config->get('system.custom_base_url') ?: $this->rootUrl(true);\n        $grav['base_url_relative'] = $this->rootUrl(false);\n        $grav['base_url'] = $config->get('system.absolute_urls') ? $grav['base_url_absolute'] : $grav['base_url_relative'];\n\n        RouteFactory::setRoot($this->root_path . $setup_base);\n        RouteFactory::setLanguage($language->getLanguageURLPrefix());\n        RouteFactory::setParamValueDelimiter($config->get('system.param_sep'));\n    }\n\n    /**\n     * Return URI path.\n     *\n     * @param int|null $id\n     * @return string|string[]\n     */\n    public function paths($id = null)\n    {\n        if ($id !== null) {\n            return $this->paths[$id];\n        }\n\n        return $this->paths;\n    }\n\n\n    /**\n     * Return route to the current URI. By default route doesn't include base path.\n     *\n     * @param bool $absolute True to include full path.\n     * @param bool $domain True to include domain. Works only if first parameter is also true.\n     * @return string\n     */\n    public function route($absolute = false, $domain = false)\n    {\n        return ($absolute ? $this->rootUrl($domain) : '') . '/' . implode('/', $this->paths);\n    }\n\n    /**\n     * Return full query string or a single query attribute.\n     *\n     * @param string|null $id Optional attribute. Get a single query attribute if set\n     * @param bool $raw If true and $id is not set, return the full query array. Otherwise return the query string\n     *\n     * @return string|array Returns an array if $id = null and $raw = true\n     */\n    public function query($id = null, $raw = false)\n    {\n        if ($id !== null) {\n            return $this->queries[$id] ?? null;\n        }\n\n        if ($raw) {\n            return $this->queries;\n        }\n\n        if (!$this->queries) {\n            return '';\n        }\n\n        return http_build_query($this->queries);\n    }\n\n    /**\n     * Return all or a single query parameter as a URI compatible string.\n     *\n     * @param string|null $id Optional parameter name.\n     * @param boolean $array return the array format or not\n     * @return null|string|array\n     */\n    public function params($id = null, $array = false)\n    {\n        $config = Grav::instance()['config'];\n        $sep = $config->get('system.param_sep');\n\n        $params = null;\n        if ($id === null) {\n            if ($array) {\n                return $this->params;\n            }\n            $output = [];\n            foreach ($this->params as $key => $value) {\n                $output[] = \"{$key}{$sep}{$value}\";\n                $params = '/' . implode('/', $output);\n            }\n        } elseif (isset($this->params[$id])) {\n            if ($array) {\n                return $this->params[$id];\n            }\n            $params = \"/{$id}{$sep}{$this->params[$id]}\";\n        }\n\n        return $params;\n    }\n\n    /**\n     * Get URI parameter.\n     *\n     * @param string $id\n     * @param string|false|null $default\n     * @return string|false|null\n     */\n    public function param($id, $default = false)\n    {\n        if (isset($this->params[$id])) {\n            return html_entity_decode(rawurldecode($this->params[$id]), ENT_COMPAT | ENT_HTML401, 'UTF-8');\n        }\n\n        return $default;\n    }\n\n    /**\n     * Gets the Fragment portion of a URI (eg #target)\n     *\n     * @param string|null $fragment\n     * @return string|null\n     */\n    public function fragment($fragment = null)\n    {\n        if ($fragment !== null) {\n            $this->fragment = $fragment;\n        }\n        return $this->fragment;\n    }\n\n    /**\n     * Return URL.\n     *\n     * @param bool $include_host Include hostname.\n     * @return string\n     */\n    public function url($include_host = false)\n    {\n        if ($include_host) {\n            return $this->url;\n        }\n\n        $url = Utils::replaceFirstOccurrence($this->base, '', rtrim($this->url, '/'));\n\n        return $url ?: '/';\n    }\n\n    /**\n     * Return the Path\n     *\n     * @return string The path of the URI\n     */\n    public function path()\n    {\n        return $this->path;\n    }\n\n    /**\n     * Return the Extension of the URI\n     *\n     * @param string|null $default\n     * @return string|null The extension of the URI\n     */\n    public function extension($default = null)\n    {\n        if (!$this->extension) {\n            $this->extension = $default;\n        }\n\n        return $this->extension;\n    }\n\n    /**\n     * @return string\n     */\n    public function method()\n    {\n        $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET';\n\n        if ($method === 'POST' && isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {\n            $method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);\n        }\n\n        return $method;\n    }\n\n    /**\n     * Return the scheme of the URI\n     *\n     * @param bool|null $raw\n     * @return string The scheme of the URI\n     */\n    public function scheme($raw = false)\n    {\n        if (!$raw) {\n            $scheme = '';\n            if ($this->scheme) {\n                $scheme = $this->scheme . '://';\n            } elseif ($this->host) {\n                $scheme = '//';\n            }\n\n            return $scheme;\n        }\n\n        return $this->scheme;\n    }\n\n\n    /**\n     * Return the host of the URI\n     *\n     * @return string|null The host of the URI\n     */\n    public function host()\n    {\n        return $this->host;\n    }\n\n    /**\n     * Return the port number if it can be figured out\n     *\n     * @param bool $raw\n     * @return int|null\n     */\n    public function port($raw = false)\n    {\n        $port = $this->port;\n        // If not in raw mode and port is not set or is 0, figure it out from scheme.\n        if (!$raw && !$port) {\n            if ($this->scheme === 'http') {\n                $this->port = 80;\n            } elseif ($this->scheme === 'https') {\n                $this->port = 443;\n            }\n        }\n\n        return $this->port ?: null;\n    }\n\n    /**\n     * Return user\n     *\n     * @return string|null\n     */\n    public function user()\n    {\n        return $this->user;\n    }\n\n    /**\n     * Return password\n     *\n     * @return string|null\n     */\n    public function password()\n    {\n        return $this->password;\n    }\n\n    /**\n     * Gets the environment name\n     *\n     * @return string\n     */\n    public function environment()\n    {\n        return $this->env;\n    }\n\n\n    /**\n     * Return the basename of the URI\n     *\n     * @return string The basename of the URI\n     */\n    public function basename()\n    {\n        return $this->basename;\n    }\n\n    /**\n     * Return the full uri\n     *\n     * @param bool $include_root\n     * @return string\n     */\n    public function uri($include_root = true)\n    {\n        if ($include_root) {\n            return $this->uri;\n        }\n\n        return Utils::replaceFirstOccurrence($this->root_path, '', $this->uri);\n    }\n\n    /**\n     * Return the base of the URI\n     *\n     * @return string The base of the URI\n     */\n    public function base()\n    {\n        return $this->base;\n    }\n\n    /**\n     * Return the base relative URL including the language prefix\n     * or the base relative url if multi-language is not enabled\n     *\n     * @return string The base of the URI\n     */\n    public function baseIncludingLanguage()\n    {\n        $grav = Grav::instance();\n\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n\n        return $pages->baseUrl(null, false);\n    }\n\n    /**\n     * Return root URL to the site.\n     *\n     * @param bool $include_host Include hostname.\n     * @return string\n     */\n    public function rootUrl($include_host = false)\n    {\n        if ($include_host) {\n            return $this->root;\n        }\n\n        return Utils::replaceFirstOccurrence($this->base, '', $this->root);\n    }\n\n    /**\n     * Return current page number.\n     *\n     * @return int\n     */\n    public function currentPage()\n    {\n        $page = (int)($this->params['page'] ?? 1);\n\n        return max(1, $page);\n    }\n\n    /**\n     * Return relative path to the referrer defaulting to current or given page.\n     *\n     * You should set the third parameter to `true` for redirects as long as you came from the same sub-site and language.\n     *\n     * @param string|null $default\n     * @param string|null $attributes\n     * @param bool $withoutBaseRoute\n     * @return string\n     */\n    public function referrer($default = null, $attributes = null, bool $withoutBaseRoute = false)\n    {\n        $referrer = $_SERVER['HTTP_REFERER'] ?? null;\n\n        // Check that referrer came from our site.\n        if ($withoutBaseRoute) {\n            /** @var Pages $pages */\n            $pages = Grav::instance()['pages'];\n            $base = $pages->baseUrl(null, true);\n        } else {\n            $base = $this->rootUrl(true);\n        }\n\n        // Referrer should always have host set and it should come from the same base address.\n        if (!is_string($referrer) || !str_starts_with($referrer, $base)) {\n            $referrer = $default ?: $this->route(true, true);\n        }\n\n        // Relative path from grav root.\n        $referrer = substr($referrer, strlen($base));\n        if ($attributes) {\n            $referrer .= $attributes;\n        }\n\n        return $referrer;\n    }\n\n    /**\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return static::buildUrl($this->toArray());\n    }\n\n    /**\n     * @return string\n     */\n    public function toOriginalString()\n    {\n        return static::buildUrl($this->toArray(true));\n    }\n\n    /**\n     * @param bool $full\n     * @return array\n     */\n    public function toArray($full = false)\n    {\n        if ($full === true) {\n            $root_path = $this->root_path ?? '';\n            $extension = isset($this->extension) && $this->isValidExtension($this->extension) ? '.' . $this->extension : '';\n            $path = $root_path . $this->path . $extension;\n        } else {\n            $path = $this->path;\n        }\n\n        return [\n            'scheme'    => $this->scheme,\n            'host'      => $this->host,\n            'port'      => $this->port ?: null,\n            'user'      => $this->user,\n            'pass'      => $this->password,\n            'path'      => $path,\n            'params'    => $this->params,\n            'query'     => $this->query,\n            'fragment'  => $this->fragment\n        ];\n    }\n\n    /**\n     * Calculate the parameter regex based on the param_sep setting\n     *\n     * @return string\n     */\n    public static function paramsRegex()\n    {\n        return '/\\/{1,}([^\\:\\#\\/\\?]*' . Grav::instance()['config']->get('system.param_sep') . '[^\\:\\#\\/\\?]*)/';\n    }\n\n    /**\n     * Return the IP address of the current user\n     *\n     * @return string ip address\n     */\n    public static function ip()\n    {\n        $ip = 'UNKNOWN';\n\n        if (getenv('HTTP_CLIENT_IP')) {\n            $ip = getenv('HTTP_CLIENT_IP');\n        } elseif (getenv('HTTP_CF_CONNECTING_IP')) {\n            $ip = getenv('HTTP_CF_CONNECTING_IP');\n        } elseif (getenv('HTTP_X_FORWARDED_FOR') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) {\n            $ips = array_map('trim', explode(',', getenv('HTTP_X_FORWARDED_FOR')));\n            $ip = array_shift($ips);\n        } elseif (getenv('HTTP_X_FORWARDED') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) {\n            $ip = getenv('HTTP_X_FORWARDED');\n        } elseif (getenv('HTTP_FORWARDED_FOR')) {\n            $ip = getenv('HTTP_FORWARDED_FOR');\n        } elseif (getenv('HTTP_FORWARDED')) {\n            $ip = getenv('HTTP_FORWARDED');\n        } elseif (getenv('REMOTE_ADDR')) {\n            $ip = getenv('REMOTE_ADDR');\n        }\n\n        return $ip;\n    }\n\n    /**\n     * Returns current Uri.\n     *\n     * @return \\Grav\\Framework\\Uri\\Uri\n     */\n    public static function getCurrentUri()\n    {\n        if (!static::$currentUri) {\n            static::$currentUri = UriFactory::createFromEnvironment($_SERVER);\n        }\n\n        return static::$currentUri;\n    }\n\n    /**\n     * Returns current route.\n     *\n     * @return Route\n     */\n    public static function getCurrentRoute()\n    {\n        if (!static::$currentRoute) {\n            /** @var Uri $uri */\n            $uri = Grav::instance()['uri'];\n\n            static::$currentRoute = RouteFactory::createFromLegacyUri($uri);\n        }\n\n        return static::$currentRoute;\n    }\n\n    /**\n     * Is this an external URL? if it starts with `http` then yes, else false\n     *\n     * @param  string $url the URL in question\n     * @return bool      is eternal state\n     */\n    public static function isExternal($url)\n    {\n        return (0 === strpos($url, 'http://') || 0 === strpos($url, 'https://') || 0 === strpos($url, '//') || 0 === strpos($url, 'mailto:') || 0 === strpos($url, 'tel:') || 0 === strpos($url, 'ftp://') || 0 === strpos($url, 'ftps://') || 0 === strpos($url, 'news:') || 0 === strpos($url, 'irc:') || 0 === strpos($url, 'gopher:') || 0 === strpos($url, 'nntp:') || 0 === strpos($url, 'feed:') || 0 === strpos($url, 'cvs:') || 0 === strpos($url, 'ssh:') || 0 === strpos($url, 'git:') || 0 === strpos($url, 'svn:') || 0 === strpos($url, 'hg:'));\n    }\n\n    /**\n     * The opposite of built-in PHP method parse_url()\n     *\n     * @param array $parsed_url\n     * @return string\n     */\n    public static function buildUrl($parsed_url)\n    {\n        $scheme    = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . ':' : '';\n        $authority = isset($parsed_url['host']) ? '//' : '';\n        $host      = $parsed_url['host'] ?? '';\n        $port      = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : '';\n        $user      = $parsed_url['user'] ?? '';\n        $pass      = isset($parsed_url['pass']) ? ':' . $parsed_url['pass']  : '';\n        $pass      = ($user || $pass) ? \"{$pass}@\" : '';\n        $path      = $parsed_url['path'] ?? '';\n        $path      = !empty($parsed_url['params']) ? rtrim($path, '/') . static::buildParams($parsed_url['params']) : $path;\n        $query     = !empty($parsed_url['query']) ? '?' . $parsed_url['query'] : '';\n        $fragment  = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : '';\n\n        return \"{$scheme}{$authority}{$user}{$pass}{$host}{$port}{$path}{$query}{$fragment}\";\n    }\n\n    /**\n     * @param array $params\n     * @return string\n     */\n    public static function buildParams(array $params)\n    {\n        if (!$params) {\n            return '';\n        }\n\n        $grav = Grav::instance();\n        $sep = $grav['config']->get('system.param_sep');\n\n        $output = [];\n        foreach ($params as $key => $value) {\n            $output[] = \"{$key}{$sep}{$value}\";\n        }\n\n        return '/' . implode('/', $output);\n    }\n\n    /**\n     * Converts links from absolute '/' or relative (../..) to a Grav friendly format\n     *\n     * @param PageInterface $page the current page to use as reference\n     * @param string|array $url the URL as it was written in the markdown\n     * @param string $type the type of URL, image | link\n     * @param bool $absolute if null, will use system default, if true will use absolute links internally\n     * @param bool $route_only only return the route, not full URL path\n     * @return string|array the more friendly formatted url\n     */\n    public static function convertUrl(PageInterface $page, $url, $type = 'link', $absolute = false, $route_only = false)\n    {\n        $grav = Grav::instance();\n\n        $uri = $grav['uri'];\n\n        // Link processing should prepend language\n        $language = $grav['language'];\n        $language_append = '';\n        if ($type === 'link' && $language->enabled()) {\n            $language_append = $language->getLanguageURLPrefix();\n        }\n\n        // Handle Excerpt style $url array\n        $url_path = is_array($url) ? $url['path'] : $url;\n\n        $external          = false;\n        $base              = $grav['base_url_relative'];\n        $base_url          = rtrim($base . $grav['pages']->base(), '/') . $language_append;\n        $pages_dir         = $grav['locator']->findResource('page://');\n\n        // if absolute and starts with a base_url move on\n        if (isset($url['scheme']) && Utils::startsWith($url['scheme'], 'http')) {\n            $external = true;\n        } elseif ($url_path === '' && isset($url['fragment'])) {\n            $external = true;\n        } elseif ($url_path === '/' || ($base_url !== '' && Utils::startsWith($url_path, $base_url))) {\n            $url_path = $base_url . $url_path;\n        } else {\n            // see if page is relative to this or absolute\n            if (Utils::startsWith($url_path, '/')) {\n                $normalized_url = Utils::normalizePath($base_url . $url_path);\n                $normalized_path = Utils::normalizePath($pages_dir . $url_path);\n            } else {\n                $page_route = ($page->home() && !empty($url_path)) ? $page->rawRoute() : $page->route();\n                $normalized_url = $base_url . Utils::normalizePath(rtrim($page_route, '/') . '/' . $url_path);\n                $normalized_path = Utils::normalizePath($page->path() . '/' . $url_path);\n            }\n\n            // special check to see if path checking is required.\n            $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path);\n            if ($normalized_url === '/' || $just_path === $page->path()) {\n                $url_path = $normalized_url;\n            } else {\n                $url_bits = static::parseUrl($normalized_path);\n                $full_path = $url_bits['path'];\n                $raw_full_path = rawurldecode($full_path);\n\n                if (file_exists($raw_full_path)) {\n                    $full_path = $raw_full_path;\n                } elseif (!file_exists($full_path)) {\n                    $full_path = false;\n                }\n\n                if ($full_path) {\n                    $path_info = Utils::pathinfo($full_path);\n                    $page_path = $path_info['dirname'];\n                    $filename = '';\n\n                    if ($url_path === '..') {\n                        $page_path = $full_path;\n                    } else {\n                        // save the filename if a file is part of the path\n                        if (is_file($full_path)) {\n                            if ($path_info['extension'] !== 'md') {\n                                $filename = '/' . $path_info['basename'];\n                            }\n                        } else {\n                            $page_path = $full_path;\n                        }\n                    }\n\n                    // get page instances and try to find one that fits\n                    $instances = $grav['pages']->instances();\n                    if (isset($instances[$page_path])) {\n                        /** @var PageInterface $target */\n                        $target = $instances[$page_path];\n                        $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename;\n\n                        $url_path = Uri::buildUrl($url_bits);\n                    } else {\n                        $url_path = $normalized_url;\n                    }\n                } else {\n                    $url_path = $normalized_url;\n                }\n            }\n        }\n\n        // handle absolute URLs\n        if (is_array($url) && !$external && ($absolute === true || $grav['config']->get('system.absolute_urls', false))) {\n            $url['scheme'] = $uri->scheme(true);\n            $url['host'] = $uri->host();\n            $url['port'] = $uri->port(true);\n\n            // check if page exists for this route, and if so, check if it has SSL enabled\n            $pages = $grav['pages'];\n            $routes = $pages->routes();\n\n            // if this is an image, get the proper path\n            $url_bits = Utils::pathinfo($url_path);\n            if (isset($url_bits['extension'])) {\n                $target_path = $url_bits['dirname'];\n            } else {\n                $target_path = $url_path;\n            }\n\n            // strip base from this path\n            $target_path = Utils::replaceFirstOccurrence($uri->rootUrl(), '', $target_path);\n\n            // set to / if root\n            if (empty($target_path)) {\n                $target_path = '/';\n            }\n\n            // look to see if this page exists and has ssl enabled\n            if (isset($routes[$target_path])) {\n                $target_page = $pages->get($routes[$target_path]);\n                if ($target_page) {\n                    $ssl_enabled = $target_page->ssl();\n                    if ($ssl_enabled !== null) {\n                        if ($ssl_enabled) {\n                            $url['scheme'] = 'https';\n                        } else {\n                            $url['scheme'] = 'http';\n                        }\n                    }\n                }\n            }\n        }\n\n        // Handle route only\n        if ($route_only) {\n            $url_path = Utils::replaceFirstOccurrence(static::filterPath($base_url), '', $url_path);\n        }\n\n        // transform back to string/array as needed\n        if (is_array($url)) {\n            $url['path'] = $url_path;\n        } else {\n            $url = $url_path;\n        }\n\n        return $url;\n    }\n\n    /**\n     * @param string $url\n     * @return array|false\n     */\n    public static function parseUrl($url)\n    {\n        $grav = Grav::instance();\n\n        // Remove extra slash from streams, parse_url() doesn't like it.\n        $url = preg_replace('/([^:])(\\/{2,})/', '$1/', $url);\n\n        $encodedUrl = preg_replace_callback(\n            '%[^:/@?&=#]+%usD',\n            static function ($matches) {\n                return rawurlencode($matches[0]);\n            },\n            $url\n        );\n\n        $parts = parse_url($encodedUrl);\n\n        if (false === $parts) {\n            return false;\n        }\n\n        foreach ($parts as $name => $value) {\n            $parts[$name] = rawurldecode($value);\n        }\n\n        if (!isset($parts['path'])) {\n            $parts['path'] = '';\n        }\n\n        [$stripped_path, $params] = static::extractParams($parts['path'], $grav['config']->get('system.param_sep'));\n\n        if (!empty($params)) {\n            $parts['path'] = $stripped_path;\n            $parts['params'] = $params;\n        }\n\n        return $parts;\n    }\n\n    /**\n     * @param string $uri\n     * @param string $delimiter\n     * @return array\n     */\n    public static function extractParams($uri, $delimiter)\n    {\n        $params = [];\n\n        if (strpos($uri, $delimiter) !== false) {\n            preg_match_all(static::paramsRegex(), $uri, $matches, PREG_SET_ORDER);\n\n            foreach ($matches as $match) {\n                $param = explode($delimiter, $match[1]);\n                if (count($param) === 2) {\n                    $plain_var = htmlspecialchars(strip_tags(rawurldecode($param[1])), ENT_QUOTES, 'UTF-8');\n                    $params[$param[0]] = $plain_var;\n                    $uri = str_replace($match[0], '', $uri);\n                }\n            }\n        }\n\n        return [$uri, $params];\n    }\n\n    /**\n     * Converts links from absolute '/' or relative (../..) to a Grav friendly format\n     *\n     * @param PageInterface   $page         the current page to use as reference\n     * @param string $markdown_url the URL as it was written in the markdown\n     * @param string $type         the type of URL, image | link\n     * @param bool|null $relative     if null, will use system default, if true will use relative links internally\n     *\n     * @return string the more friendly formatted url\n     */\n    public static function convertUrlOld(PageInterface $page, $markdown_url, $type = 'link', $relative = null)\n    {\n        $grav = Grav::instance();\n\n        $language = $grav['language'];\n\n        // Link processing should prepend language\n        $language_append = '';\n        if ($type === 'link' && $language->enabled()) {\n            $language_append = $language->getLanguageURLPrefix();\n        }\n        $pages_dir = $grav['locator']->findResource('page://');\n        if ($relative === null) {\n            $base = $grav['base_url'];\n        } else {\n            $base = $relative ? $grav['base_url_relative'] : $grav['base_url_absolute'];\n        }\n\n        $base_url = rtrim($base . $grav['pages']->base(), '/') . $language_append;\n\n        // if absolute and starts with a base_url move on\n        if (Utils::pathinfo($markdown_url, PATHINFO_DIRNAME) === '.' && $page->url() === '/') {\n            return '/' . $markdown_url;\n        }\n        // no path to convert\n        if ($base_url !== '' && Utils::startsWith($markdown_url, $base_url)) {\n            return $markdown_url;\n        }\n        // if contains only a fragment\n        if (Utils::startsWith($markdown_url, '#')) {\n            return $markdown_url;\n        }\n\n        $target = null;\n        // see if page is relative to this or absolute\n        if (Utils::startsWith($markdown_url, '/')) {\n            $normalized_url = Utils::normalizePath($base_url . $markdown_url);\n            $normalized_path = Utils::normalizePath($pages_dir . $markdown_url);\n        } else {\n            $normalized_url = $base_url . Utils::normalizePath($page->route() . '/' . $markdown_url);\n            $normalized_path = Utils::normalizePath($page->path() . '/' . $markdown_url);\n        }\n\n        // special check to see if path checking is required.\n        $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path);\n        if ($just_path === $page->path()) {\n            return $normalized_url;\n        }\n\n        $url_bits = parse_url($normalized_path);\n        $full_path = $url_bits['path'];\n\n        if (file_exists($full_path)) {\n            // do nothing\n        } elseif (file_exists(rawurldecode($full_path))) {\n            $full_path = rawurldecode($full_path);\n        } else {\n            return $normalized_url;\n        }\n\n        $path_info = Utils::pathinfo($full_path);\n        $page_path = $path_info['dirname'];\n        $filename = '';\n\n        if ($markdown_url === '..') {\n            $page_path = $full_path;\n        } else {\n            // save the filename if a file is part of the path\n            if (is_file($full_path)) {\n                if ($path_info['extension'] !== 'md') {\n                    $filename = '/' . $path_info['basename'];\n                }\n            } else {\n                $page_path = $full_path;\n            }\n        }\n\n        // get page instances and try to find one that fits\n        $instances = $grav['pages']->instances();\n        if (isset($instances[$page_path])) {\n            /** @var PageInterface $target */\n            $target = $instances[$page_path];\n            $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename;\n\n            return static::buildUrl($url_bits);\n        }\n\n        return $normalized_url;\n    }\n\n    /**\n     * Adds the nonce to a URL for a specific action\n     *\n     * @param string $url            the url\n     * @param string $action         the action\n     * @param string $nonceParamName the param name to use\n     *\n     * @return string the url with the nonce\n     */\n    public static function addNonce($url, $action, $nonceParamName = 'nonce')\n    {\n        $fake = $url && strpos($url, '/') === 0;\n\n        if ($fake) {\n            $url = 'http://domain.com' . $url;\n        }\n        $uri = new static($url);\n        $parts = $uri->toArray();\n        $nonce = Utils::getNonce($action);\n        $parts['params'] = ($parts['params'] ?? []) + [$nonceParamName => $nonce];\n\n        if ($fake) {\n            unset($parts['scheme'], $parts['host']);\n        }\n\n        return static::buildUrl($parts);\n    }\n\n    /**\n     * Is the passed in URL a valid URL?\n     *\n     * @param string $url\n     * @return bool\n     */\n    public static function isValidUrl($url)\n    {\n        $regex = '/^(?:(https?|ftp|telnet):)?\\/\\/((?:[a-z0-9@:.-]|%[0-9A-F]{2}){3,})(?::(\\d+))?((?:\\/(?:[a-z0-9-._~!$&\\'\\(\\)\\*\\+\\,\\;\\=\\:\\@]|%[0-9A-F]{2})*)*)(?:\\?((?:[a-z0-9-._~!$&\\'\\(\\)\\*\\+\\,\\;\\=\\:\\/?@]|%[0-9A-F]{2})*))?(?:#((?:[a-z0-9-._~!$&\\'\\(\\)\\*\\+\\,\\;\\=\\:\\/?@]|%[0-9A-F]{2})*))?/';\n\n        return (bool)preg_match($regex, $url);\n    }\n\n    /**\n     * Removes extra double slashes and fixes back-slashes\n     *\n     * @param string $path\n     * @return string\n     */\n    public static function cleanPath($path)\n    {\n        $regex = '/(\\/)\\/+/';\n        $path = str_replace(['\\\\', '/ /'], '/', $path);\n        $path = preg_replace($regex, '$1', $path);\n\n        return $path;\n    }\n\n    /**\n     * Filters the user info string.\n     *\n     * @param string|null $info The raw user or password.\n     * @return string The percent-encoded user or password string.\n     */\n    public static function filterUserInfo($info)\n    {\n        return $info !== null ? UriPartsFilter::filterUserInfo($info) : '';\n    }\n\n    /**\n     * Filter Uri path.\n     *\n     * This method percent-encodes all reserved\n     * characters in the provided path string. This method\n     * will NOT double-encode characters that are already\n     * percent-encoded.\n     *\n     * @param  string|null $path The raw uri path.\n     * @return string       The RFC 3986 percent-encoded uri path.\n     * @link   http://www.faqs.org/rfcs/rfc3986.html\n     */\n    public static function filterPath($path)\n    {\n        return $path !== null ? UriPartsFilter::filterPath($path) : '';\n    }\n\n    /**\n     * Filters the query string or fragment of a URI.\n     *\n     * @param string|null $query The raw uri query string.\n     * @return string The percent-encoded query string.\n     */\n    public static function filterQuery($query)\n    {\n        return $query !== null ? UriPartsFilter::filterQueryOrFragment($query) : '';\n    }\n\n    /**\n     * @param array $env\n     * @return void\n     */\n    protected function createFromEnvironment(array $env)\n    {\n        // Build scheme.\n        if (isset($env['HTTP_X_FORWARDED_PROTO']) && Grav::instance()['config']->get('system.http_x_forwarded.protocol')) {\n            $this->scheme = $env['HTTP_X_FORWARDED_PROTO'];\n        } elseif (isset($env['X-FORWARDED-PROTO'])) {\n            $this->scheme = $env['X-FORWARDED-PROTO'];\n        } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) {\n            $this->scheme = $env['HTTP_CLOUDFRONT_FORWARDED_PROTO'];\n        } elseif (isset($env['REQUEST_SCHEME']) && empty($env['HTTPS'])) {\n            $this->scheme = $env['REQUEST_SCHEME'];\n        } else {\n            $https = $env['HTTPS'] ?? '';\n            $this->scheme = (empty($https) || strtolower($https) === 'off') ? 'http' : 'https';\n        }\n\n        // Build user and password.\n        $this->user = $env['PHP_AUTH_USER'] ?? null;\n        $this->password = $env['PHP_AUTH_PW'] ?? null;\n\n        // Build host.\n        if (isset($env['HTTP_X_FORWARDED_HOST']) && Grav::instance()['config']->get('system.http_x_forwarded.host')) {\n            $hostname = $env['HTTP_X_FORWARDED_HOST'];\n        } else if (isset($env['HTTP_HOST'])) {\n            $hostname = $env['HTTP_HOST'];\n        } elseif (isset($env['SERVER_NAME'])) {\n            $hostname = $env['SERVER_NAME'];\n        } else {\n            $hostname = 'localhost';\n        }\n        // Remove port from HTTP_HOST generated $hostname\n        $hostname = Utils::substrToString($hostname, ':');\n        // Validate the hostname\n        $this->host = $this->validateHostname($hostname) ? $hostname : 'unknown';\n\n        // Build port.\n        if (isset($env['HTTP_X_FORWARDED_PORT']) && Grav::instance()['config']->get('system.http_x_forwarded.port')) {\n            $this->port = (int)$env['HTTP_X_FORWARDED_PORT'];\n        } elseif (isset($env['X-FORWARDED-PORT'])) {\n            $this->port = (int)$env['X-FORWARDED-PORT'];\n        } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) {\n           // Since AWS Cloudfront does not provide a forwarded port header,\n           // we have to build the port using the scheme.\n            $this->port = $this->port();\n        } elseif (isset($env['SERVER_PORT'])) {\n            $this->port = (int)$env['SERVER_PORT'];\n        } else {\n            $this->port = null;\n        }\n\n        if ($this->port === 0 || $this->hasStandardPort()) {\n            $this->port = null;\n        }\n\n        // Build path.\n        $request_uri = $env['REQUEST_URI'] ?? '/';\n        $this->path = rawurldecode(parse_url('http://example.com' . $request_uri, PHP_URL_PATH));\n\n        // Build query string.\n        $this->query = $env['QUERY_STRING'] ?? '';\n        if ($this->query === '') {\n            $this->query = parse_url('http://example.com' . $request_uri, PHP_URL_QUERY) ?? '';\n        }\n\n        // Support ngnix routes.\n        if (strpos($this->query, '_url=') === 0) {\n            parse_str($this->query, $query);\n            unset($query['_url']);\n            $this->query = http_build_query($query);\n        }\n\n        // Build fragment.\n        $this->fragment = null;\n\n        // Filter userinfo, path and query string.\n        $this->user = $this->user !== null ? static::filterUserInfo($this->user) : null;\n        $this->password = $this->password !== null ? static::filterUserInfo($this->password) : null;\n        $this->path = empty($this->path) ? '/' : static::filterPath($this->path);\n        $this->query = static::filterQuery($this->query);\n\n        $this->reset();\n    }\n\n    /**\n     * Does this Uri use a standard port?\n     *\n     * @return bool\n     */\n    protected function hasStandardPort()\n    {\n        return (!$this->port || $this->port === 80 || $this->port === 443);\n    }\n\n    /**\n     * @param string $url\n     */\n    protected function createFromString($url)\n    {\n        // Set Uri parts.\n        $parts = parse_url($url);\n        if ($parts === false) {\n            throw new RuntimeException('Malformed URL: ' . $url);\n        }\n        $port = (int)($parts['port'] ?? 0);\n\n        $this->scheme = $parts['scheme'] ?? null;\n        $this->user = $parts['user'] ?? null;\n        $this->password = $parts['pass'] ?? null;\n        $this->host = $parts['host'] ?? null;\n        $this->port = $port ?: null;\n        $this->path = $parts['path'] ?? '';\n        $this->query = $parts['query'] ?? '';\n        $this->fragment = $parts['fragment'] ?? null;\n\n        // Validate the hostname\n        if ($this->host) {\n            $this->host = $this->validateHostname($this->host) ? $this->host : 'unknown';\n        }\n        // Filter userinfo, path, query string and fragment.\n        $this->user = $this->user !== null ? static::filterUserInfo($this->user) : null;\n        $this->password = $this->password !== null ? static::filterUserInfo($this->password) : null;\n        $this->path = empty($this->path) ? '/' : static::filterPath($this->path);\n        $this->query = static::filterQuery($this->query);\n        $this->fragment = $this->fragment !== null ? static::filterQuery($this->fragment) : null;\n\n        $this->reset();\n    }\n\n    /**\n     * @return void\n     */\n    protected function reset()\n    {\n        // resets\n        parse_str($this->query, $this->queries);\n        $this->extension    = null;\n        $this->basename     = null;\n        $this->paths        = [];\n        $this->params       = [];\n        $this->env          = $this->buildEnvironment();\n        $this->uri          = $this->path . (!empty($this->query) ? '?' . $this->query : '');\n\n        $this->base         = $this->buildBaseUrl();\n        $this->root_path    = $this->buildRootPath();\n        $this->root         = $this->base . $this->root_path;\n        $this->url          = $this->base . $this->uri;\n    }\n\n    /**\n     * Get post from either $_POST or JSON response object\n     * By default returns all data, or can return a single item\n     *\n     * @param string|null $element\n     * @param string|null $filter_type\n     * @return array|null\n     */\n    public function post($element = null, $filter_type = null)\n    {\n        if (!$this->post) {\n            $content_type = $this->getContentType();\n            if ($content_type === 'application/json') {\n                $json = file_get_contents('php://input');\n                $this->post = json_decode($json, true);\n            } elseif (!empty($_POST)) {\n                $this->post = (array)$_POST;\n            }\n\n            $event = new Event(['post' => &$this->post]);\n            Grav::instance()->fireEvent('onHttpPostFilter', $event);\n        }\n\n        if ($this->post && null !== $element) {\n            $item = Utils::getDotNotation($this->post, $element);\n            if ($filter_type) {\n                if ($filter_type === FILTER_SANITIZE_STRING || $filter_type === GRAV_SANITIZE_STRING) {\n                    $item = htmlspecialchars(strip_tags($item), ENT_QUOTES, 'UTF-8');\n                } else {\n                    $item = filter_var($item, $filter_type);\n                }\n            }\n            return $item;\n        }\n\n        return $this->post;\n    }\n\n    /**\n     * Get content type from request\n     *\n     * @param bool $short\n     * @return null|string\n     */\n    public function getContentType($short = true)\n    {\n       $content_type = $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? $_SERVER['HTTP_ACCEPT'] ?? null;\n        if ($content_type) {\n            if ($short) {\n                return Utils::substrToString($content_type, ';');\n            }\n        }\n        return $content_type;\n    }\n\n    /**\n     * Check if this is a valid Grav extension\n     *\n     * @param string|null $extension\n     * @return bool\n     */\n    public function isValidExtension($extension): bool\n    {\n        $extension = (string)$extension;\n\n        return $extension !== '' && in_array($extension, Utils::getSupportPageTypes(), true);\n    }\n\n    /**\n     * Allow overriding of any element (be careful!)\n     *\n     * @param array $data\n     * @return Uri\n     */\n    public function setUriProperties($data)\n    {\n        foreach (get_object_vars($this) as $property => $default) {\n            if (!array_key_exists($property, $data)) {\n                continue;\n            }\n            $this->{$property} = $data[$property]; // assign value to object\n        }\n        return $this;\n    }\n\n\n    /**\n     * Compatibility in case getallheaders() is not available on platform\n     */\n    public static function getAllHeaders()\n    {\n        if (!function_exists('getallheaders')) {\n            $headers = [];\n            foreach ($_SERVER as $name => $value) {\n                if (substr($name, 0, 5) == 'HTTP_') {\n                    $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;\n                }\n            }\n            return $headers;\n        }\n        return getallheaders();\n    }\n\n    /**\n     * Get the base URI with port if needed\n     *\n     * @return string\n     */\n    private function buildBaseUrl()\n    {\n        return $this->scheme() . $this->host;\n    }\n\n    /**\n     * Get the Grav Root Path\n     *\n     * @return string\n     */\n    private function buildRootPath()\n    {\n        // In Windows script path uses backslash, convert it:\n        $scriptPath = str_replace('\\\\', '/', $_SERVER['PHP_SELF']);\n        $rootPath = str_replace(' ', '%20', rtrim(substr($scriptPath, 0, strpos($scriptPath, 'index.php')), '/'));\n\n        return $rootPath;\n    }\n\n    /**\n     * @return string\n     */\n    private function buildEnvironment()\n    {\n        // check for localhost variations\n        if ($this->host === '127.0.0.1' || $this->host === '::1') {\n            return 'localhost';\n        }\n\n        return $this->host ?: 'unknown';\n    }\n\n    /**\n     * Process any params based in this URL, supports any valid delimiter\n     *\n     * @param string $uri\n     * @param string $delimiter\n     * @return string\n     */\n    private function processParams(string $uri, string $delimiter = ':'): string\n    {\n        if (strpos($uri, $delimiter) !== false) {\n            preg_match_all(static::paramsRegex(), $uri, $matches, PREG_SET_ORDER);\n\n            foreach ($matches as $match) {\n                $param = explode($delimiter, $match[1]);\n                if (count($param) === 2) {\n                    $plain_var = htmlspecialchars(strip_tags($param[1]), ENT_QUOTES, 'UTF-8');\n                    $this->params[$param[0]] = $plain_var;\n                    $uri = str_replace($match[0], '', $uri);\n                }\n            }\n        }\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/Access.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User;\n\nuse function is_bool;\n\n/**\n * Class Access\n * @package Grav\\Common\\User\n */\nclass Access extends \\Grav\\Framework\\Acl\\Access\n{\n    /** @var array[] */\n    private $aliases = [\n        'admin.configuration.system' => ['admin.configuration_system'],\n        'admin.configuration.site' => ['admin.configuration_site', 'admin.settings'],\n        'admin.configuration.media' => ['admin.configuration_media'],\n        'admin.configuration.info' => ['admin.configuration_info'],\n    ];\n\n    /**\n     * @param string $action\n     * @return bool|null\n     */\n    public function get(string $action)\n    {\n        $result = parent::get($action);\n        if (is_bool($result)) {\n            return $result;\n        }\n\n        // Get access value.\n        if (isset($this->aliases[$action])) {\n            $aliases = $this->aliases[$action];\n            foreach ($aliases as $alias) {\n                $result = parent::get($alias);\n                if (is_bool($result)) {\n                    return $result;\n                }\n            }\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/Authentication.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User;\n\nuse RuntimeException;\n\n/**\n * Class Authentication\n * @package Grav\\Common\\User\n */\nabstract class Authentication\n{\n    /**\n     * Create password hash from plaintext password.\n     *\n     * @param string $password Plaintext password.\n     *\n     * @throws RuntimeException\n     * @return string\n     */\n    public static function create($password): string\n    {\n        if (!$password) {\n            throw new RuntimeException('Password hashing failed: no password provided.');\n        }\n\n        $hash = password_hash($password, PASSWORD_DEFAULT);\n\n        if (!$hash) {\n            throw new RuntimeException('Password hashing failed: internal error.');\n        }\n\n        return $hash;\n    }\n\n    /**\n     * Verifies that a password matches a hash.\n     *\n     * @param string $password Plaintext password.\n     * @param string $hash     Hash to verify against.\n     *\n     * @return int              Returns 0 if the check fails, 1 if password matches, 2 if hash needs to be updated.\n     */\n    public static function verify($password, $hash): int\n    {\n        // Fail if hash doesn't match\n        if (!$password || !$hash || !password_verify($password, $hash)) {\n            return 0;\n        }\n\n        // Otherwise check if hash needs an update.\n        return password_needs_rehash($hash, PASSWORD_DEFAULT) ? 2 : 1;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/DataUser/User.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User\\DataUser;\n\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\Blueprints;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Media\\Interfaces\\MediaCollectionInterface;\nuse Grav\\Common\\Page\\Media;\nuse Grav\\Common\\Page\\Medium\\Medium;\nuse Grav\\Common\\Page\\Medium\\MediumFactory;\nuse Grav\\Common\\User\\Authentication;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\User\\Traits\\UserTrait;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Flex\\Flex;\nuse function is_array;\n\n/**\n * Class User\n * @package Grav\\Common\\User\\DataUser\n */\nclass User extends Data implements UserInterface\n{\n    use UserTrait;\n\n    /** @var MediaCollectionInterface */\n    protected $_media;\n\n    /**\n     * User constructor.\n     * @param array $items\n     * @param Blueprint|null $blueprints\n     */\n    public function __construct(array $items = [], $blueprints = null)\n    {\n        // User can only be authenticated via login.\n        unset($items['authenticated'], $items['authorized']);\n\n        // Always set blueprints.\n        if (null === $blueprints) {\n            $blueprints = (new Blueprints)->get('user/account');\n        }\n\n        parent::__construct($items, $blueprints);\n    }\n\n    /**\n     * @param string $offset\n     * @return bool\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset)\n    {\n        $value = parent::offsetExists($offset);\n\n        // Handle special case where user was logged in before 'authorized' was added to the user object.\n        if (false === $value && $offset === 'authorized') {\n            $value = $this->offsetExists('authenticated');\n        }\n\n        return $value;\n    }\n\n    /**\n     * @param string $offset\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        $value = parent::offsetGet($offset);\n\n        // Handle special case where user was logged in before 'authorized' was added to the user object.\n        if (null === $value && $offset === 'authorized') {\n            $value = $this->offsetGet('authenticated');\n            $this->offsetSet($offset, $value);\n        }\n\n        return $value;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isValid(): bool\n    {\n        return $this->items !== null;\n    }\n\n    /**\n     * Update object with data\n     *\n     * @param array $data\n     * @param array $files\n     * @return $this\n     */\n    public function update(array $data, array $files = [])\n    {\n        // Note: $this->merge() would cause infinite loop as it calls this method.\n        parent::merge($data);\n\n        return $this;\n    }\n\n    /**\n     * Save user\n     *\n     * @return void\n     */\n    public function save()\n    {\n        /** @var CompiledYamlFile|null $file */\n        $file = $this->file();\n        if (!$file || !$file->filename()) {\n            user_error(__CLASS__ . ': calling \\$user = new ' . __CLASS__ . \"() is deprecated since Grav 1.6, use \\$grav['accounts']->load(\\$username) or \\$grav['accounts']->load('') instead\", E_USER_DEPRECATED);\n        }\n\n        if ($file) {\n            $username = $this->filterUsername((string)$this->get('username'));\n\n            if (!$file->filename()) {\n                $locator = Grav::instance()['locator'];\n                $file->filename($locator->findResource('account://' . $username . YAML_EXT, true, true));\n            }\n\n            // if plain text password, hash it and remove plain text\n            $password = $this->get('password') ?? $this->get('password1');\n            if (null !== $password && '' !== $password) {\n                $password2 = $this->get('password2');\n                if (!\\is_string($password) || ($password2 && $password !== $password2)) {\n                    throw new \\RuntimeException('Passwords did not match.');\n                }\n\n                $this->set('hashed_password', Authentication::create($password));\n            }\n            $this->undef('password');\n            $this->undef('password1');\n            $this->undef('password2');\n\n            $data = $this->items;\n            if ($username === $data['username']) {\n                unset($data['username']);\n            }\n            unset($data['authenticated'], $data['authorized']);\n\n            $file->save($data);\n\n            // We need to signal Flex Users about the change.\n            /** @var Flex|null $flex */\n            $flex = Grav::instance()['flex'] ?? null;\n            $users = $flex ? $flex->getDirectory('user-accounts') : null;\n            if (null !== $users) {\n                $users->clearCache();\n            }\n        }\n    }\n\n    /**\n     * @return MediaCollectionInterface|Media\n     */\n    public function getMedia()\n    {\n        if (null === $this->_media) {\n            // Media object should only contain avatar, nothing else.\n            $media = new Media($this->getMediaFolder() ?? '', $this->getMediaOrder(), false);\n\n            $path = $this->getAvatarFile();\n            if ($path && is_file($path)) {\n                $medium = MediumFactory::fromFile($path);\n                if ($medium) {\n                    $media->add(Utils::basename($path), $medium);\n                }\n            }\n\n            $this->_media = $media;\n        }\n\n        return $this->_media;\n    }\n\n    /**\n     * @return string\n     */\n    public function getMediaFolder()\n    {\n        return $this->blueprints()->fields()['avatar']['destination'] ?? 'account://avatars';\n    }\n\n    /**\n     * @return array\n     */\n    public function getMediaOrder()\n    {\n        return [];\n    }\n\n    /**\n     * Serialize user.\n     *\n     * @return string[]\n     */\n    public function __sleep()\n    {\n        return [\n            'items',\n            'storage'\n        ];\n    }\n\n    /**\n     * Unserialize user.\n     */\n    public function __wakeup()\n    {\n        $this->gettersVariable = 'items';\n        $this->nestedSeparator = '.';\n\n        if (null === $this->items) {\n            $this->items = [];\n        }\n\n        // Always set blueprints.\n        if (null === $this->blueprints) {\n            $this->blueprints = (new Blueprints)->get('user/account');\n        }\n    }\n\n    /**\n     * Merge two configurations together.\n     *\n     * @param array $data\n     * @return $this\n     * @deprecated 1.6 Use `->update($data)` instead (same but with data validation & filtering, file upload support).\n     */\n    public function merge(array $data)\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->update($data) method instead', E_USER_DEPRECATED);\n\n        return $this->update($data);\n    }\n\n    /**\n     * Return media object for the User's avatar.\n     *\n     * @return Medium|null\n     * @deprecated 1.6 Use ->getAvatarImage() method instead.\n     */\n    public function getAvatarMedia()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use getAvatarImage() method instead', E_USER_DEPRECATED);\n\n        return $this->getAvatarImage();\n    }\n\n    /**\n     * Return the User's avatar URL\n     *\n     * @return string\n     * @deprecated 1.6 Use ->getAvatarUrl() method instead.\n     */\n    public function avatarUrl()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use getAvatarUrl() method instead', E_USER_DEPRECATED);\n\n        return $this->getAvatarUrl();\n    }\n\n    /**\n     * Checks user authorization to the action.\n     * Ensures backwards compatibility\n     *\n     * @param  string $action\n     * @return bool\n     * @deprecated 1.5 Use ->authorize() method instead.\n     */\n    public function authorise($action)\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use authorize() method instead', E_USER_DEPRECATED);\n\n        return $this->authorize($action) ?? false;\n    }\n\n    /**\n     * Implements Countable interface.\n     *\n     * @return int\n     * @deprecated 1.6 Method makes no sense for user account.\n     */\n    #[\\ReturnTypeWillChange]\n    public function count()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6', E_USER_DEPRECATED);\n\n        return parent::count();\n    }\n\n    /**\n     * @param string $username\n     * @return string\n     */\n    protected function filterUsername(string $username): string\n    {\n        return mb_strtolower($username);\n    }\n\n    /**\n     * @return string|null\n     */\n    protected function getAvatarFile(): ?string\n    {\n        $avatars = $this->get('avatar');\n        if (is_array($avatars) && $avatars) {\n            $avatar = array_shift($avatars);\n            return $avatar['path'] ?? null;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/DataUser/UserCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User\\DataUser;\n\nuse Grav\\Common\\Data\\Blueprints;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Interfaces\\UserCollectionInterface;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function count;\nuse function in_array;\nuse function is_string;\n\n/**\n * Class UserCollection\n * @package Grav\\Common\\User\\DataUser\n */\nclass UserCollection implements UserCollectionInterface\n{\n    /** @var string */\n    private $className;\n\n    /**\n     * UserCollection constructor.\n     * @param string $className\n     */\n    public function __construct(string $className)\n    {\n        $this->className = $className;\n    }\n\n    /**\n     * Load user account.\n     *\n     * Always creates user object. To check if user exists, use $this->exists().\n     *\n     * @param string $username\n     * @return UserInterface\n     */\n    public function load($username): UserInterface\n    {\n        $username = (string)$username;\n\n        $grav = Grav::instance();\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        // Filter username.\n        $username = $this->filterUsername($username);\n\n        $filename = 'account://' . $username . YAML_EXT;\n        $path = $locator->findResource($filename) ?: $locator->findResource($filename, true, true);\n        if (!is_string($path)) {\n            throw new RuntimeException('Internal Error');\n        }\n        $file = CompiledYamlFile::instance($path);\n        $content = (array)$file->content() + ['username' => $username, 'state' => 'enabled'];\n\n        $userClass = $this->className;\n        $callable = static function () {\n            $blueprints = new Blueprints;\n\n            return $blueprints->get('user/account');\n        };\n\n        /** @var UserInterface $user */\n        $user = new $userClass($content, $callable);\n        $user->file($file);\n\n        return $user;\n    }\n\n    /**\n     * Find a user by username, email, etc\n     *\n     * @param string $query the query to search for\n     * @param array $fields the fields to search\n     * @return UserInterface\n     */\n    public function find($query, $fields = ['username', 'email']): UserInterface\n    {\n        $fields = (array)$fields;\n\n        $grav = Grav::instance();\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        $account_dir = $locator->findResource('account://');\n        if (!is_string($account_dir)) {\n            return $this->load('');\n        }\n\n        $files = array_diff(scandir($account_dir) ?: [], ['.', '..']);\n\n        // Try with username first, you never know!\n        if (in_array('username', $fields, true)) {\n            $user = $this->load($query);\n            unset($fields[array_search('username', $fields, true)]);\n        } else {\n            $user = $this->load('');\n        }\n\n        // If not found, try the fields\n        if (!$user->exists()) {\n            $query = mb_strtolower($query);\n            foreach ($files as $file) {\n                if (Utils::endsWith($file, YAML_EXT)) {\n                    $find_user = $this->load(trim(Utils::pathinfo($file, PATHINFO_FILENAME)));\n                    foreach ($fields as $field) {\n                        if (isset($find_user[$field]) && mb_strtolower($find_user[$field]) === $query) {\n                            return $find_user;\n                        }\n                    }\n                }\n            }\n        }\n        return $user;\n    }\n\n    /**\n     * Remove user account.\n     *\n     * @param string $username\n     * @return bool True if the action was performed\n     */\n    public function delete($username): bool\n    {\n        $file_path = Grav::instance()['locator']->findResource('account://' . $username . YAML_EXT);\n\n        return $file_path && unlink($file_path);\n    }\n\n    /**\n     * @return int\n     */\n    public function count(): int\n    {\n        // check for existence of a user account\n        $account_dir = $file_path = Grav::instance()['locator']->findResource('account://');\n        $accounts = glob($account_dir . '/*.yaml') ?: [];\n\n        return count($accounts);\n    }\n\n    /**\n     * @param string $username\n     * @return string\n     */\n    protected function filterUsername(string $username): string\n    {\n        return mb_strtolower($username);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/Group.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Blueprints;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\n\n/**\n * @deprecated 1.7 Use $grav['user_groups'] instead of this class. In type hints, please use UserGroupInterface.\n */\nclass Group extends Data\n{\n    /**\n     * Get the groups list\n     *\n     * @return array\n     * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead\n     */\n    protected static function groups()\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\\'user_groups\\'] Flex UserGroupCollection instead', E_USER_DEPRECATED);\n\n        return Grav::instance()['config']->get('groups', []);\n    }\n\n    /**\n     * Get the groups list\n     *\n     * @return array\n     * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead\n     */\n    public static function groupNames()\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\\'user_groups\\'] Flex UserGroupCollection instead', E_USER_DEPRECATED);\n\n        $groups = [];\n\n        foreach (static::groups() as $groupname => $group) {\n            $groups[$groupname] = $group['readableName'] ?? $groupname;\n        }\n\n        return $groups;\n    }\n\n    /**\n     * Checks if a group exists\n     *\n     * @param string $groupname\n     * @return bool\n     * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead\n     */\n    public static function groupExists($groupname)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\\'user_groups\\'] Flex UserGroupCollection instead', E_USER_DEPRECATED);\n\n        return isset(self::groups()[$groupname]);\n    }\n\n    /**\n     * Get a group by name\n     *\n     * @param string $groupname\n     * @return object\n     * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead\n     */\n    public static function load($groupname)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\\'user_groups\\'] Flex UserGroupCollection instead', E_USER_DEPRECATED);\n\n        $groups = self::groups();\n\n        $content = $groups[$groupname] ?? [];\n        $content += ['groupname' => $groupname];\n\n        $blueprints = new Blueprints();\n        $blueprint = $blueprints->get('user/group');\n\n        return new Group($content, $blueprint);\n    }\n\n    /**\n     * Save a group\n     *\n     * @return void\n     */\n    public function save()\n    {\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        $blueprints = new Blueprints();\n        $blueprint = $blueprints->get('user/group');\n\n        $config->set(\"groups.{$this->get('groupname')}\", []);\n\n        $fields = $blueprint->fields();\n        foreach ($fields as $field) {\n            if ($field['type'] === 'text') {\n                $value = $field['name'];\n                if (isset($this->items['data'][$value])) {\n                    $config->set(\"groups.{$this->get('groupname')}.{$value}\", $this->items['data'][$value]);\n                }\n            }\n            if ($field['type'] === 'array' || $field['type'] === 'permissions') {\n                $value = $field['name'];\n                $arrayValues = Utils::getDotNotation($this->items['data'], $field['name']);\n\n                if ($arrayValues) {\n                    foreach ($arrayValues as $arrayIndex => $arrayValue) {\n                        $config->set(\"groups.{$this->get('groupname')}.{$value}.{$arrayIndex}\", $arrayValue);\n                    }\n                }\n            }\n        }\n\n        $type = 'groups';\n        $blueprints = $this->blueprints();\n\n        $filename = CompiledYamlFile::instance($grav['locator']->findResource(\"config://{$type}.yaml\"));\n\n        $obj = new Data($config->get($type), $blueprints);\n        $obj->file($filename);\n        $obj->save();\n    }\n\n    /**\n     * Remove a group\n     *\n     * @param string $groupname\n     * @return bool True if the action was performed\n     * @deprecated 1.7, use $grav['user_groups'] Flex UserGroupCollection instead\n     */\n    public static function remove($groupname)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use $grav[\\'user_groups\\'] Flex UserGroupCollection instead', E_USER_DEPRECATED);\n\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        $blueprints = new Blueprints();\n        $blueprint = $blueprints->get('user/group');\n\n        $type = 'groups';\n\n        $groups = $config->get($type);\n        unset($groups[$groupname]);\n        $config->set($type, $groups);\n\n        $filename = CompiledYamlFile::instance($grav['locator']->findResource(\"config://{$type}.yaml\"));\n\n        $obj = new Data($groups, $blueprint);\n        $obj->file($filename);\n        $obj->save();\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/Interfaces/AuthorizeInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User\\Interfaces;\n\n/**\n * Interface AuthorizeInterface\n * @package Grav\\Common\\User\\Interfaces\n */\ninterface AuthorizeInterface\n{\n    /**\n     * Checks user authorization to the action.\n     *\n     * @param  string $action\n     * @param  string|null $scope\n     * @return bool|null\n     */\n    public function authorize(string $action, string $scope = null): ?bool;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/Interfaces/UserCollectionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User\\Interfaces;\n\ninterface UserCollectionInterface extends \\Countable\n{\n    /**\n     * Load user account.\n     *\n     * Always creates user object. To check if user exists, use $this->exists().\n     *\n     * @param string $username\n     * @return UserInterface\n     */\n    public function load($username): UserInterface;\n\n    /**\n     * Find a user by username, email, etc\n     *\n     * @param string $query the query to search for\n     * @param array $fields the fields to search\n     * @return UserInterface\n     */\n    public function find($query, $fields = ['username', 'email']): UserInterface;\n\n    /**\n     * Delete user account.\n     *\n     * @param string $username\n     * @return bool True if user account was found and was deleted.\n     */\n    public function delete($username): bool;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/Interfaces/UserGroupInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User\\Interfaces;\n\n/**\n * Interface UserGroupInterface\n * @package Grav\\Common\\User\\Interfaces\n */\ninterface UserGroupInterface extends AuthorizeInterface\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/Interfaces/UserInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User\\Interfaces;\n\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\DataInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaInterface;\nuse Grav\\Common\\Page\\Medium\\Medium;\nuse RocketTheme\\Toolbox\\ArrayTraits\\ExportInterface;\nuse RuntimeException;\n\n/**\n * Interface UserInterface\n * @package Grav\\Common\\User\\Interfaces\n *\n * @property string $username\n * @property string $email\n * @property string $fullname\n * @property string $state\n * @property array $groups\n * @property array $access\n *\n * @property bool $authenticated\n * @property bool $authorized\n */\ninterface UserInterface extends AuthorizeInterface, DataInterface, MediaInterface, \\ArrayAccess, \\JsonSerializable, ExportInterface\n{\n    /**\n     * @param array $items\n     * @param Blueprint|callable $blueprints\n     */\n    //public function __construct(array $items = [], $blueprints = null);\n\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @example $value = $this->get('this.is.my.nested.variable');\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $default    Default value (or null).\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return mixed  Value.\n     */\n    public function get($name, $default = null, $separator = null);\n\n    /**\n     * Set value by using dot notation for nested arrays/objects.\n     *\n     * @example $data->set('this.is.my.nested.variable', $value);\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $value      New value.\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function set($name, $value, $separator = null);\n\n    /**\n     * Unset value by using dot notation for nested arrays/objects.\n     *\n     * @example $data->undef('this.is.my.nested.variable');\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function undef($name, $separator = null);\n\n    /**\n     * Set default value by using dot notation for nested arrays/objects.\n     *\n     * @example $data->def('this.is.my.nested.variable', 'default');\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $default    Default value (or null).\n     * @param string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function def($name, $default = null, $separator = null);\n\n    /**\n     * Join nested values together by using blueprints.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $value      Value to be joined.\n     * @param string  $separator  Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function join($name, $value, $separator = '.');\n\n    /**\n     * Get nested structure containing default values defined in the blueprints.\n     *\n     * Fields without default value are ignored in the list.\n\n     * @return array\n     */\n    public function getDefaults();\n\n    /**\n     * Set default values by using blueprints.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param mixed   $value      Value to be joined.\n     * @param string  $separator  Separator, defaults to '.'\n     * @return $this\n     */\n    public function joinDefaults($name, $value, $separator = '.');\n\n    /**\n     * Get value from the configuration and join it with given data.\n     *\n     * @param string  $name       Dot separated path to the requested value.\n     * @param array|object $value      Value to be joined.\n     * @param string  $separator  Separator, defaults to '.'\n     * @return array\n     * @throws RuntimeException\n     */\n    public function getJoined($name, $value, $separator = '.');\n\n    /**\n     * Set default values to the configuration if variables were not set.\n     *\n     * @param array $data\n     * @return $this\n     */\n    public function setDefaults(array $data);\n\n    /**\n     * Update object with data\n     *\n     * @param array $data\n     * @param array $files\n     * @return $this\n     */\n    public function update(array $data, array $files = []);\n\n    /**\n     * Returns whether the data already exists in the storage.\n     *\n     * NOTE: This method does not check if the data is current.\n     *\n     * @return bool\n     */\n    public function exists();\n\n    /**\n     * Return unmodified data as raw string.\n     *\n     * NOTE: This function only returns data which has been saved to the storage.\n     *\n     * @return string\n     */\n    public function raw();\n\n    /**\n     * Authenticate user.\n     *\n     * If user password needs to be updated, new information will be saved.\n     *\n     * @param string $password Plaintext password.\n     * @return bool\n     */\n    public function authenticate(string $password): bool;\n\n    /**\n     * Return media object for the User's avatar.\n     *\n     * Note: if there's no local avatar image for the user, you should call getAvatarUrl() to get the external avatar URL.\n     *\n     * @return Medium|null\n     */\n    public function getAvatarImage(): ?Medium;\n\n    /**\n     * Return the User's avatar URL.\n     *\n     * @return string\n     */\n    public function getAvatarUrl(): string;\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/Traits/UserTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User\\Traits;\n\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Medium\\ImageMedium;\nuse Grav\\Common\\Page\\Medium\\Medium;\nuse Grav\\Common\\Page\\Medium\\StaticImageMedium;\nuse Grav\\Common\\User\\Authentication;\nuse Grav\\Common\\Utils;\nuse Multiavatar;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse function is_array;\nuse function is_string;\n\n/**\n * Trait UserTrait\n * @package Grav\\Common\\User\\Traits\n */\ntrait UserTrait\n{\n    /**\n     * Authenticate user.\n     *\n     * If user password needs to be updated, new information will be saved.\n     *\n     * @param string $password Plaintext password.\n     * @return bool\n     */\n    public function authenticate(string $password): bool\n    {\n        $hash = $this->get('hashed_password');\n\n        $isHashed = null !== $hash;\n        if (!$isHashed) {\n            // If there is no hashed password, fake verify with default hash.\n            $hash = Grav::instance()['config']->get('system.security.default_hash');\n        }\n\n        // Always execute verify() to protect us from timing attacks, but make the test to fail if hashed password wasn't set.\n        $result = Authentication::verify($password, $hash) && $isHashed;\n\n        $plaintext_password = $this->get('password');\n        if (null !== $plaintext_password) {\n            // Plain-text password is still stored, check if it matches.\n            if ($password !== $plaintext_password) {\n                return false;\n            }\n\n            // Force hash update to get rid of plaintext password.\n            $result = 2;\n        }\n\n        if ($result === 2) {\n            // Password needs to be updated, save the user.\n            $this->set('password', $password);\n            $this->undef('hashed_password');\n            $this->save();\n        }\n\n        return (bool)$result;\n    }\n\n    /**\n     * Checks user authorization to the action.\n     *\n     * @param  string $action\n     * @param  string|null $scope\n     * @return bool|null\n     */\n    public function authorize(string $action, string $scope = null): ?bool\n    {\n        // User needs to be enabled.\n        if ($this->get('state', 'enabled') !== 'enabled') {\n            return false;\n        }\n\n        // User needs to be logged in.\n        if (!$this->get('authenticated')) {\n            return false;\n        }\n\n        // User needs to be authorized (2FA).\n        if (strpos($action, 'login') === false && !$this->get('authorized', true)) {\n            return false;\n        }\n\n        if (null !== $scope) {\n            $action = $scope . '.' . $action;\n        }\n\n        $config = Grav::instance()['config'];\n        $authorized = false;\n\n        //Check group access level\n        $groups = (array)$this->get('groups');\n        foreach ($groups as $group) {\n            $permission = $config->get(\"groups.{$group}.access.{$action}\");\n            $authorized = Utils::isPositive($permission);\n            if ($authorized === true) {\n                break;\n            }\n        }\n\n        //Check user access level\n        $access = $this->get('access');\n        if ($access && Utils::getDotNotation($access, $action) !== null) {\n            $permission = $this->get(\"access.{$action}\");\n            $authorized = Utils::isPositive($permission);\n        }\n\n        return $authorized;\n    }\n\n    /**\n     * Return media object for the User's avatar.\n     *\n     * Note: if there's no local avatar image for the user, you should call getAvatarUrl() to get the external avatar URL.\n     *\n     * @return ImageMedium|StaticImageMedium|null\n     */\n    public function getAvatarImage(): ?Medium\n    {\n        $avatars = $this->get('avatar');\n        if (is_array($avatars) && $avatars) {\n            $avatar = array_shift($avatars);\n\n            $media = $this->getMedia();\n            $name = $avatar['name'] ?? null;\n\n            $image = $name ? $media[$name] : null;\n            if ($image instanceof ImageMedium ||\n                $image instanceof StaticImageMedium) {\n                return $image;\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * Return the User's avatar URL\n     *\n     * @return string\n     */\n    public function getAvatarUrl(): string\n    {\n        // Try to locate avatar image.\n        $avatar = $this->getAvatarImage();\n        if ($avatar) {\n            return $avatar->url();\n        }\n\n        // Try if avatar is a sting (URL).\n        $avatar = $this->get('avatar');\n        if (is_string($avatar)) {\n            return $avatar;\n        }\n\n        // Try looking for provider.\n        $provider = $this->get('provider');\n        $provider_options = $this->get($provider);\n        if (is_array($provider_options)) {\n            if (isset($provider_options['avatar_url']) && is_string($provider_options['avatar_url'])) {\n                return $provider_options['avatar_url'];\n            }\n            if (isset($provider_options['avatar']) && is_string($provider_options['avatar'])) {\n                return $provider_options['avatar'];\n            }\n        }\n\n        $email = $this->get('email');\n        $avatar_generator = Grav::instance()['config']->get('system.accounts.avatar', 'multiavatar');\n        if ($avatar_generator === 'gravatar') {\n            if (!$email) {\n                return '';\n            }\n\n            $hash = md5(strtolower(trim($email)));\n\n            return 'https://www.gravatar.com/avatar/' . $hash;\n        }\n\n        $hash = $this->get('avatar_hash');\n        if (!$hash) {\n            $username = $this->get('username');\n            $hash = md5(strtolower(trim($email ?? $username)));\n        }\n\n        return $this->generateMultiavatar($hash);\n    }\n\n    /**\n     * @param string $hash\n     * @return string\n     */\n    protected function generateMultiavatar(string $hash): string\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n\n        $storage = $locator->findResource('image://multiavatar', true, true);\n        $avatar_file = \"{$storage}/{$hash}.svg\";\n\n        if (!file_exists($storage)) {\n            Folder::create($storage);\n        }\n\n        if (!file_exists($avatar_file)) {\n            $mavatar = new Multiavatar();\n\n            file_put_contents($avatar_file, $mavatar->generate($hash, null, null));\n        }\n\n        $avatar_url = $locator->findResource(\"image://multiavatar/{$hash}.svg\", false, true);\n\n        return Utils::url($avatar_url);\n\n    }\n\n    abstract public function get($name, $default = null, $separator = null);\n    abstract public function set($name, $value, $separator = null);\n    abstract public function undef($name, $separator = null);\n    abstract public function save();\n}\n"
  },
  {
    "path": "system/src/Grav/Common/User/User.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\User\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common\\User;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\DataUser;\nuse Grav\\Common\\Flex;\nuse Grav\\Common\\User\\Interfaces\\UserCollectionInterface;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\n\nif (!defined('GRAV_USER_INSTANCE')) {\n    throw new \\LogicException('User class was called too early!');\n}\n\nif (defined('GRAV_USER_INSTANCE') && GRAV_USER_INSTANCE === 'FLEX') {\n    /**\n     * @deprecated 1.6 Use $grav['accounts'] instead of static calls. In type hints, please use UserInterface.\n     */\n    class User extends Flex\\Types\\Users\\UserObject\n    {\n        /**\n         * Load user account.\n         *\n         * Always creates user object. To check if user exists, use $this->exists().\n         *\n         * @param string $username\n         * @return UserInterface\n         * @deprecated 1.6 Use $grav['accounts']->load(...) instead.\n         */\n        public static function load($username)\n        {\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\\'accounts\\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED);\n\n            return static::getCollection()->load($username);\n        }\n\n        /**\n         * Find a user by username, email, etc\n         *\n         * Always creates user object. To check if user exists, use $this->exists().\n         *\n         * @param string $query the query to search for\n         * @param array $fields the fields to search\n         * @return UserInterface\n         * @deprecated 1.6 Use $grav['accounts']->find(...) instead.\n         */\n        public static function find($query, $fields = ['username', 'email'])\n        {\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\\'accounts\\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED);\n\n            return static::getCollection()->find($query, $fields);\n        }\n\n        /**\n         * Remove user account.\n         *\n         * @param string $username\n         * @return bool True if the action was performed\n         * @deprecated 1.6 Use $grav['accounts']->delete(...) instead.\n         */\n        public static function remove($username)\n        {\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\\'accounts\\']->delete() instead', E_USER_DEPRECATED);\n\n            return static::getCollection()->delete($username);\n        }\n\n        /**\n         * @return UserCollectionInterface\n         */\n        protected static function getCollection()\n        {\n            return Grav::instance()['accounts'];\n        }\n    }\n} else {\n    /**\n     * @deprecated 1.6 Use $grav['accounts'] instead of static calls. In type hints, use UserInterface.\n     */\n    class User extends DataUser\\User\n    {\n        /**\n         * Load user account.\n         *\n         * Always creates user object. To check if user exists, use $this->exists().\n         *\n         * @param string $username\n         * @return UserInterface\n         * @deprecated 1.6 Use $grav['accounts']->load(...) instead.\n         */\n        public static function load($username)\n        {\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\\'accounts\\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED);\n\n            return static::getCollection()->load($username);\n        }\n\n        /**\n         * Find a user by username, email, etc\n         *\n         * Always creates user object. To check if user exists, use $this->exists().\n         *\n         * @param string $query the query to search for\n         * @param array $fields the fields to search\n         * @return UserInterface\n         * @deprecated 1.6 Use $grav['accounts']->find(...) instead.\n         */\n        public static function find($query, $fields = ['username', 'email'])\n        {\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\\'accounts\\']->' . __FUNCTION__ . '() instead', E_USER_DEPRECATED);\n\n            return static::getCollection()->find($query, $fields);\n        }\n\n        /**\n         * Remove user account.\n         *\n         * @param string $username\n         * @return bool True if the action was performed\n         * @deprecated 1.6 Use $grav['accounts']->delete(...) instead.\n         */\n        public static function remove($username)\n        {\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use $grav[\\'accounts\\']->delete() instead', E_USER_DEPRECATED);\n\n            return static::getCollection()->delete($username);\n        }\n\n        /**\n         * @return UserCollectionInterface\n         */\n        protected static function getCollection()\n        {\n            return Grav::instance()['accounts'];\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Utils.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse DateTime;\nuse DateTimeZone;\nuse Exception;\nuse Grav\\Common\\Flex\\Types\\Pages\\PageObject;\nuse Grav\\Common\\Helpers\\Truncator;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Markdown\\Parsedown;\nuse Grav\\Common\\Markdown\\ParsedownExtra;\nuse Grav\\Common\\Page\\Markdown\\Excerpts;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Framework\\Flex\\Flex;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Media\\Interfaces\\MediaInterface;\nuse InvalidArgumentException;\nuse Negotiation\\Accept;\nuse Negotiation\\Negotiator;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function array_key_exists;\nuse function array_slice;\nuse function count;\nuse function extension_loaded;\nuse function function_exists;\nuse function in_array;\nuse function is_array;\nuse function is_callable;\nuse function is_string;\nuse function strlen;\n\n/**\n * Class Utils\n * @package Grav\\Common\n */\nabstract class Utils\n{\n    /** @var array */\n    protected static $nonces = [];\n\n    protected const ROOTURL_REGEX = '{^((?:http[s]?:\\/\\/[^\\/]+)|(?:\\/\\/[^\\/]+))(.*)}';\n\n    // ^((?:http[s]?:)?[\\/]?(?:\\/))\n\n    /**\n     * Simple helper method to make getting a Grav URL easier\n     *\n     * @param string|object $input\n     * @param bool $domain\n     * @param bool $fail_gracefully\n     * @return string|false\n     */\n    public static function url($input, $domain = false, $fail_gracefully = false)\n    {\n        if ((!is_string($input) && !is_callable([$input, '__toString'])) || !trim($input)) {\n            if ($fail_gracefully) {\n                $input = '/';\n            } else {\n                return false;\n            }\n        }\n\n        $input = (string)$input;\n\n        if (Uri::isExternal($input)) {\n            return $input;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var Uri $uri */\n        $uri = $grav['uri'];\n\n        $resource = false;\n        if (static::contains((string)$input, '://')) {\n            // Url contains a scheme (https:// , user:// etc).\n            /** @var UniformResourceLocator $locator */\n            $locator = $grav['locator'];\n\n            $parts = Uri::parseUrl($input);\n\n            if (is_array($parts)) {\n                // Make sure we always have scheme, host, port and path.\n                $scheme = $parts['scheme'] ?? '';\n                $host = $parts['host'] ?? '';\n                $port = $parts['port'] ?? '';\n                $path = $parts['path'] ?? '';\n\n                if ($scheme && !$port) {\n                    // If URL has a scheme, we need to check if it's one of Grav streams.\n                    if (!$locator->schemeExists($scheme)) {\n                        // If scheme does not exists as a stream, assume it's external.\n                        return str_replace(' ', '%20', $input);\n                    }\n\n                    // Attempt to find the resource (because of parse_url() we need to put host back to path).\n                    $resource = $locator->findResource(\"{$scheme}://{$host}{$path}\", false);\n\n                    if ($resource === false) {\n                        if (!$fail_gracefully) {\n                            return false;\n                        }\n\n                        // Return location where the file would be if it was saved.\n                        $resource = $locator->findResource(\"{$scheme}://{$host}{$path}\", false, true);\n                    }\n                } elseif ($host || $port) {\n                    // If URL doesn't have scheme but has host or port, it is external.\n                    return str_replace(' ', '%20', $input);\n                }\n\n                if (!empty($resource)) {\n                    // Add query string back.\n                    if (isset($parts['query'])) {\n                        $resource .= '?' . $parts['query'];\n                    }\n\n                    // Add fragment back.\n                    if (isset($parts['fragment'])) {\n                        $resource .= '#' . $parts['fragment'];\n                    }\n                }\n            } else {\n                // Not a valid URL (can still be a stream).\n                $resource = $locator->findResource($input, false);\n            }\n        } else {\n            // Just a path.\n            /** @var Pages $pages */\n            $pages = $grav['pages'];\n\n            // Is this a page?\n            $page = $pages->find($input, true);\n            if ($page && $page->routable()) {\n                return $page->url($domain);\n            }\n\n            $root = preg_quote($uri->rootUrl(), '#');\n            $pattern = '#(' . $root . '$|' . $root . '/)#';\n            if (!empty($root) && preg_match($pattern, $input, $matches)) {\n                $input = static::replaceFirstOccurrence($matches[0], '', $input);\n            }\n\n            $input = ltrim($input, '/');\n            $resource = $input;\n        }\n\n        if (!$fail_gracefully && $resource === false) {\n            return false;\n        }\n\n        $domain = $domain ?: $grav['config']->get('system.absolute_urls', false);\n\n        return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?: '');\n    }\n\n    /**\n     * Helper method to find the full path to a file, be it a stream, a relative path, or\n     * already a full path\n     *\n     * @param string $path\n     * @return string\n     */\n    public static function fullPath($path)\n    {\n        $locator = Grav::instance()['locator'];\n\n        if ($locator->isStream($path)) {\n            $path = $locator->findResource($path, true);\n        } elseif (!static::startsWith($path, GRAV_ROOT)) {\n            $base_url = Grav::instance()['base_url'];\n            $path = GRAV_ROOT . '/' . ltrim(static::replaceFirstOccurrence($base_url, '', $path), '/');\n        }\n\n        return $path;\n    }\n\n\n    /**\n     * Check if the $haystack string starts with the substring $needle\n     *\n     * @param string $haystack\n     * @param string|string[] $needle\n     * @param bool $case_sensitive\n     * @return bool\n     */\n    public static function startsWith($haystack, $needle, $case_sensitive = true)\n    {\n        $status = false;\n\n        $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos';\n\n        foreach ((array)$needle as $each_needle) {\n            $status = $each_needle === '' || $compare_func((string) $haystack, $each_needle) === 0;\n            if ($status) {\n                break;\n            }\n        }\n\n        return $status;\n    }\n\n    /**\n     * Check if the $haystack string ends with the substring $needle\n     *\n     * @param string $haystack\n     * @param string|string[] $needle\n     * @param bool $case_sensitive\n     * @return bool\n     */\n    public static function endsWith($haystack, $needle, $case_sensitive = true)\n    {\n        $status = false;\n\n        $compare_func = $case_sensitive ? 'mb_strrpos' : 'mb_strripos';\n\n        foreach ((array)$needle as $each_needle) {\n            $expectedPosition = mb_strlen((string) $haystack) - mb_strlen($each_needle);\n            $status = $each_needle === '' || $compare_func((string) $haystack, $each_needle, 0) === $expectedPosition;\n            if ($status) {\n                break;\n            }\n        }\n\n        return $status;\n    }\n\n    /**\n     * Check if the $haystack string contains the substring $needle\n     *\n     * @param string $haystack\n     * @param string|string[] $needle\n     * @param bool $case_sensitive\n     * @return bool\n     */\n    public static function contains($haystack, $needle, $case_sensitive = true)\n    {\n        $status = false;\n\n        $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos';\n\n        foreach ((array)$needle as $each_needle) {\n            $status = $each_needle === '' || $compare_func((string) $haystack, $each_needle) !== false;\n            if ($status) {\n                break;\n            }\n        }\n\n        return $status;\n    }\n\n    /**\n     * Function that can match wildcards\n     *\n     * match_wildcard('foo*', $test),      // TRUE\n     * match_wildcard('bar*', $test),      // FALSE\n     * match_wildcard('*bar*', $test),     // TRUE\n     * match_wildcard('**blob**', $test),  // TRUE\n     * match_wildcard('*a?d*', $test),     // TRUE\n     * match_wildcard('*etc**', $test)     // TRUE\n     *\n     * @param string $wildcard_pattern\n     * @param string $haystack\n     * @return false|int\n     */\n    public static function matchWildcard($wildcard_pattern, $haystack)\n    {\n        $regex = str_replace(\n            array(\"\\*\", \"\\?\"), // wildcard chars\n            array('.*', '.'),   // regexp chars\n            preg_quote($wildcard_pattern, '/')\n        );\n\n        return preg_match('/^' . $regex . '$/is', $haystack);\n    }\n\n    /**\n     * Render simple template filling up the variables in it. If value is not defined, leave it as it was.\n     *\n     * @param string $template Template string\n     * @param array $variables Variables with values\n     * @param array $brackets Optional array of opening and closing brackets or symbols\n     * @return string               Final string filled with values\n     */\n    public static function simpleTemplate(string $template, array $variables, array $brackets = ['{', '}']): string\n    {\n        $opening = $brackets[0] ?? '{';\n        $closing = $brackets[1] ?? '}';\n        $expression = '/' . preg_quote($opening, '/') . '(.*?)' . preg_quote($closing, '/') . '/';\n        $callback = static function ($match) use ($variables) {\n            return $variables[$match[1]] ?? $match[0];\n        };\n\n        return preg_replace_callback($expression, $callback, $template);\n    }\n\n    /**\n     * Returns the substring of a string up to a specified needle.  if not found, return the whole haystack\n     *\n     * @param string $haystack\n     * @param string $needle\n     * @param bool $case_sensitive\n     *\n     * @return string\n     */\n    public static function substrToString($haystack, $needle, $case_sensitive = true)\n    {\n        $compare_func = $case_sensitive ? 'mb_strpos' : 'mb_stripos';\n\n        if (static::contains($haystack, $needle, $case_sensitive)) {\n            return mb_substr($haystack, 0, $compare_func($haystack, $needle, $case_sensitive));\n        }\n\n        return $haystack;\n    }\n\n    /**\n     * Utility method to replace only the first occurrence in a string\n     *\n     * @param string $search\n     * @param string $replace\n     * @param string $subject\n     *\n     * @return string\n     */\n    public static function replaceFirstOccurrence($search, $replace, $subject)\n    {\n        if (!$search) {\n            return $subject;\n        }\n\n        $pos = mb_strpos($subject, $search);\n        if ($pos !== false) {\n            $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search));\n        }\n\n\n        return $subject;\n    }\n\n    /**\n     * Utility method to replace only the last occurrence in a string\n     *\n     * @param string $search\n     * @param string $replace\n     * @param string $subject\n     * @return string\n     */\n    public static function replaceLastOccurrence($search, $replace, $subject)\n    {\n        $pos = strrpos($subject, $search);\n\n        if ($pos !== false) {\n            $subject = static::mb_substr_replace($subject, $replace, $pos, mb_strlen($search));\n        }\n\n        return $subject;\n    }\n\n    /**\n     * Multibyte compatible substr_replace\n     *\n     * @param string $original\n     * @param string $replacement\n     * @param int $position\n     * @param int $length\n     * @return string\n     */\n    public static function mb_substr_replace($original, $replacement, $position, $length)\n    {\n        $startString = mb_substr($original, 0, $position, 'UTF-8');\n        $endString = mb_substr($original, $position + $length, mb_strlen($original), 'UTF-8');\n\n        return $startString . $replacement . $endString;\n    }\n\n    /**\n     * Merge two objects into one.\n     *\n     * @param object $obj1\n     * @param object $obj2\n     *\n     * @return object\n     */\n    public static function mergeObjects($obj1, $obj2)\n    {\n        return (object)array_merge((array)$obj1, (array)$obj2);\n    }\n\n    /**\n     * @param array $array\n     * @return bool\n     */\n    public static function isAssoc(array $array)\n    {\n        return (array_values($array) !== $array);\n    }\n\n    /**\n     * Lowercase an entire array. Useful when combined with `in_array()`\n     *\n     * @param array $a\n     * @return array|false\n     */\n    public static function arrayLower(array $a)\n    {\n        return array_map('mb_strtolower', $a);\n    }\n\n    /**\n     * Simple function to remove item/s in an array by value\n     *\n     * @param array $search\n     * @param string|array $value\n     * @return array\n     */\n    public static function arrayRemoveValue(array $search, $value)\n    {\n        foreach ((array)$value as $val) {\n            $key = array_search($val, $search);\n            if ($key !== false) {\n                unset($search[$key]);\n            }\n        }\n        return $search;\n    }\n\n    /**\n     * Recursive Merge with uniqueness\n     *\n     * @param array $array1\n     * @param array $array2\n     * @return array\n     */\n    public static function arrayMergeRecursiveUnique($array1, $array2)\n    {\n        if (empty($array1)) {\n            // Optimize the base case\n            return $array2;\n        }\n\n        foreach ($array2 as $key => $value) {\n            if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) {\n                $value = static::arrayMergeRecursiveUnique($array1[$key], $value);\n            }\n            $array1[$key] = $value;\n        }\n\n        return $array1;\n    }\n\n    /**\n     * Returns an array with the differences between $array1 and $array2\n     *\n     * @param array $array1\n     * @param array $array2\n     * @return array\n     */\n    public static function arrayDiffMultidimensional($array1, $array2)\n    {\n        $result = array();\n        foreach ($array1 as $key => $value) {\n            if (!is_array($array2) || !array_key_exists($key, $array2)) {\n                $result[$key] = $value;\n                continue;\n            }\n            if (is_array($value)) {\n                $recursiveArrayDiff = static::ArrayDiffMultidimensional($value, $array2[$key]);\n                if (count($recursiveArrayDiff)) {\n                    $result[$key] = $recursiveArrayDiff;\n                }\n                continue;\n            }\n            if ($value != $array2[$key]) {\n                $result[$key] = $value;\n            }\n        }\n\n        return $result;\n    }\n\n    /**\n     * Array combine but supports different array lengths\n     *\n     * @param array $arr1\n     * @param array $arr2\n     * @return array|false\n     */\n    public static function arrayCombine($arr1, $arr2)\n    {\n        $count = min(count($arr1), count($arr2));\n\n        return array_combine(array_slice($arr1, 0, $count), array_slice($arr2, 0, $count));\n    }\n\n    /**\n     * Array is associative or not\n     *\n     * @param array $arr\n     * @return bool\n     */\n    public static function arrayIsAssociative($arr)\n    {\n        if ([] === $arr) {\n            return false;\n        }\n\n        return array_keys($arr) !== range(0, count($arr) - 1);\n    }\n\n    /**\n     * Return the Grav date formats allowed\n     *\n     * @return array\n     */\n    public static function dateFormats()\n    {\n        $now = new DateTime();\n\n        $date_formats = [\n            'd-m-Y H:i' => 'd-m-Y H:i (e.g. ' . $now->format('d-m-Y H:i') . ')',\n            'Y-m-d H:i' => 'Y-m-d H:i (e.g. ' . $now->format('Y-m-d H:i') . ')',\n            'm/d/Y h:i a' => 'm/d/Y h:i a (e.g. ' . $now->format('m/d/Y h:i a') . ')',\n            'H:i d-m-Y' => 'H:i d-m-Y (e.g. ' . $now->format('H:i d-m-Y') . ')',\n            'h:i a m/d/Y' => 'h:i a m/d/Y (e.g. ' . $now->format('h:i a m/d/Y') . ')',\n        ];\n        $default_format = Grav::instance()['config']->get('system.pages.dateformat.default');\n        if ($default_format) {\n            $date_formats = array_merge([$default_format => $default_format . ' (e.g. ' . $now->format($default_format) . ')'], $date_formats);\n        }\n\n        return $date_formats;\n    }\n\n    /**\n     * Get current date/time\n     *\n     * @param string|null $default_format\n     * @return string\n     * @throws Exception\n     */\n    public static function dateNow($default_format = null)\n    {\n        $now = new DateTime();\n\n        if (null === $default_format) {\n            $default_format = Grav::instance()['config']->get('system.pages.dateformat.default');\n        }\n\n        return $now->format($default_format);\n    }\n\n    /**\n     * Truncate text by number of characters but can cut off words.\n     *\n     * @param string $string\n     * @param int $limit Max number of characters.\n     * @param bool $up_to_break truncate up to breakpoint after char count\n     * @param string $break Break point.\n     * @param string $pad Appended padding to the end of the string.\n     * @return string\n     */\n    public static function truncate($string, $limit = 150, $up_to_break = false, $break = ' ', $pad = '&hellip;')\n    {\n        // return with no change if string is shorter than $limit\n        if (mb_strlen($string) <= $limit) {\n            return $string;\n        }\n\n        // is $break present between $limit and the end of the string?\n        if ($up_to_break && false !== ($breakpoint = mb_strpos($string, $break, $limit))) {\n            if ($breakpoint < mb_strlen($string) - 1) {\n                $string = mb_substr($string, 0, $breakpoint) . $pad;\n            }\n        } else {\n            $string = mb_substr($string, 0, $limit) . $pad;\n        }\n\n        return $string;\n    }\n\n    /**\n     * Truncate text by number of characters in a \"word-safe\" manor.\n     *\n     * @param string $string\n     * @param int $limit\n     * @return string\n     */\n    public static function safeTruncate($string, $limit = 150)\n    {\n        return static::truncate($string, $limit, true);\n    }\n\n\n    /**\n     * Truncate HTML by number of characters. not \"word-safe\"!\n     *\n     * @param string $text\n     * @param int $length in characters\n     * @param string $ellipsis\n     * @return string\n     */\n    public static function truncateHtml($text, $length = 100, $ellipsis = '...')\n    {\n        return Truncator::truncateLetters($text, $length, $ellipsis);\n    }\n\n    /**\n     * Truncate HTML by number of characters in a \"word-safe\" manor.\n     *\n     * @param string $text\n     * @param int $length in words\n     * @param string $ellipsis\n     * @return string\n     */\n    public static function safeTruncateHtml($text, $length = 25, $ellipsis = '...')\n    {\n        return Truncator::truncateWords($text, $length, $ellipsis);\n    }\n\n    /**\n     * Generate a random string of a given length\n     *\n     * @param int $length\n     * @return string\n     */\n    public static function generateRandomString($length = 5)\n    {\n        return substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length);\n    }\n\n    /**\n     * Generates a random string with configurable length, prefix and suffix.\n     * Unlike the built-in `uniqid()`, this string is non-conflicting and safe\n     *\n     * @param int $length\n     * @param array $options\n     * @return string\n     * @throws Exception\n     */\n    public static function uniqueId(int $length = 13, array $options = []): string\n    {\n        $options = array_merge(['prefix' => '', 'suffix' => ''], $options);\n        $bytes = random_bytes(ceil($length / 2));\n\n        return $options['prefix'] . substr(bin2hex($bytes), 0, $length) . $options['suffix'];\n    }\n\n    /**\n     * Provides the ability to download a file to the browser\n     *\n     * @param string $file the full path to the file to be downloaded\n     * @param bool $force_download as opposed to letting browser choose if to download or render\n     * @param int $sec Throttling, try 0.1 for some speed throttling of downloads\n     * @param int $bytes Size of chunks to send in bytes. Default is 1024\n     * @param array $options Extra options: [mime, download_name, expires]\n     * @throws Exception\n     */\n    public static function download($file, $force_download = true, $sec = 0, $bytes = 1024, array $options = [])\n    {\n        $grav = Grav::instance();\n\n        if (file_exists($file)) {\n            // fire download event\n            $grav->fireEvent('onBeforeDownload', new Event(['file' => $file, 'options' => &$options]));\n\n            $file_parts = static::pathinfo($file);\n            $mimetype = $options['mime'] ?? static::getMimeByExtension($file_parts['extension']);\n            $size = filesize($file); // File size\n\n            $grav->cleanOutputBuffers();\n\n            // required for IE, otherwise Content-Disposition may be ignored\n            if (ini_get('zlib.output_compression')) {\n                ini_set('zlib.output_compression', 'Off');\n            }\n\n            header('Content-Type: ' . $mimetype);\n            header('Accept-Ranges: bytes');\n\n            if ($force_download) {\n                // output the regular HTTP headers\n                header('Content-Disposition: attachment; filename=\"' . ($options['download_name'] ?? $file_parts['basename']) . '\"');\n            }\n\n            // multipart-download and download resuming support\n            if (isset($_SERVER['HTTP_RANGE'])) {\n                [$a, $range] = explode('=', $_SERVER['HTTP_RANGE'], 2);\n                [$range] = explode(',', $range, 2);\n                [$range, $range_end] = explode('-', $range);\n                $range = (int)$range;\n                if (!$range_end) {\n                    $range_end = $size - 1;\n                } else {\n                    $range_end = (int)$range_end;\n                }\n                $new_length = $range_end - $range + 1;\n                header('HTTP/1.1 206 Partial Content');\n                header(\"Content-Length: {$new_length}\");\n                header(\"Content-Range: bytes {$range}-{$range_end}/{$size}\");\n            } else {\n                $range = 0;\n                $new_length = $size;\n                header('Content-Length: ' . $size);\n\n                if ($grav['config']->get('system.cache.enabled')) {\n                    $expires = $options['expires'] ?? $grav['config']->get('system.pages.expires');\n                    if ($expires > 0) {\n                        $expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);\n                        header('Cache-Control: max-age=' . $expires);\n                        header('Expires: ' . $expires_date);\n                        header('Pragma: cache');\n                    }\n                    header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));\n\n                    // Return 304 Not Modified if the file is already cached in the browser\n                    if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&\n                        strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file)) {\n                        header('HTTP/1.1 304 Not Modified');\n                        exit();\n                    }\n                }\n            }\n\n            /* output the file itself */\n            $chunksize = $bytes * 8; //you may want to change this\n            $bytes_send = 0;\n\n            $fp = @fopen($file, 'rb');\n            if ($fp) {\n                if ($range) {\n                    fseek($fp, $range);\n                }\n                while (!feof($fp) && (!connection_aborted()) && ($bytes_send < $new_length)) {\n                    $buffer = fread($fp, $chunksize);\n                    echo($buffer); //echo($buffer); // is also possible\n                    flush();\n                    usleep($sec * 1000000);\n                    $bytes_send += strlen($buffer);\n                }\n                fclose($fp);\n            } else {\n                throw new RuntimeException('Error - can not open file.');\n            }\n\n            exit;\n        }\n    }\n\n    /**\n     * Returns the output render format, usually the extension provided in the URL. (e.g. `html`, `json`, `xml`, etc).\n     *\n     * @return string\n     */\n    public static function getPageFormat(): string\n    {\n        /** @var Uri $uri */\n        $uri = Grav::instance()['uri'];\n\n        // Set from uri extension\n        $uri_extension = $uri->extension();\n        if (is_string($uri_extension) && $uri->isValidExtension($uri_extension)) {\n            return ($uri_extension);\n        }\n\n        // Use content negotiation via the `accept:` header\n        $http_accept = $_SERVER['HTTP_ACCEPT'] ?? null;\n        if (is_string($http_accept)) {\n            $negotiator = new Negotiator();\n\n            $supported_types = static::getSupportPageTypes(['html', 'json']);\n            $priorities = static::getMimeTypes($supported_types);\n\n            $media_type = $negotiator->getBest($http_accept, $priorities);\n            $mimetype = $media_type instanceof Accept ? $media_type->getValue() : '';\n\n            return static::getExtensionByMime($mimetype);\n        }\n\n        return 'html';\n    }\n\n    /**\n     * Return the mimetype based on filename extension\n     *\n     * @param string $extension Extension of file (eg \"txt\")\n     * @param string $default\n     * @return string\n     */\n    public static function getMimeByExtension($extension, $default = 'application/octet-stream')\n    {\n        $extension = strtolower($extension);\n\n        // look for some standard types\n        switch ($extension) {\n            case null:\n                return $default;\n            case 'json':\n                return 'application/json';\n            case 'html':\n                return 'text/html';\n            case 'atom':\n                return 'application/atom+xml';\n            case 'rss':\n                return 'application/rss+xml';\n            case 'xml':\n                return 'application/xml';\n        }\n\n        $media_types = Grav::instance()['config']->get('media.types');\n\n        return $media_types[$extension]['mime'] ?? $default;\n    }\n\n    /**\n     * Get all the mimetypes for an array of extensions\n     *\n     * @param array $extensions\n     * @return array\n     */\n    public static function getMimeTypes(array $extensions)\n    {\n        $mimetypes = [];\n        foreach ($extensions as $extension) {\n            $mimetype = static::getMimeByExtension($extension, false);\n            if ($mimetype && !in_array($mimetype, $mimetypes)) {\n                $mimetypes[] = $mimetype;\n            }\n        }\n        return $mimetypes;\n    }\n\n    /**\n     * Return all extensions for given mimetype. The first extension is the default one.\n     *\n     * @param string $mime Mime type (eg 'image/jpeg')\n     * @return string[] List of extensions eg. ['jpg', 'jpe', 'jpeg']\n     */\n    public static function getExtensionsByMime($mime)\n    {\n        $mime = strtolower($mime);\n\n        $media_types = (array)Grav::instance()['config']->get('media.types');\n\n        $list = [];\n        foreach ($media_types as $extension => $type) {\n            if ($extension === '' || $extension === 'defaults') {\n                continue;\n            }\n\n            if (isset($type['mime']) && $type['mime'] === $mime) {\n                $list[] = $extension;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * Return the mimetype based on filename extension\n     *\n     * @param string $mime mime type (eg \"text/html\")\n     * @param string $default default value\n     * @return string\n     */\n    public static function getExtensionByMime($mime, $default = 'html')\n    {\n        $mime = strtolower($mime);\n\n        // look for some standard mime types\n        switch ($mime) {\n            case '*/*':\n            case 'text/*':\n            case 'text/html':\n                return 'html';\n            case 'application/json':\n                return 'json';\n            case 'application/atom+xml':\n                return 'atom';\n            case 'application/rss+xml':\n                return 'rss';\n            case 'application/xml':\n                return 'xml';\n        }\n\n        $media_types = (array)Grav::instance()['config']->get('media.types');\n\n        foreach ($media_types as $extension => $type) {\n            if ($extension === 'defaults') {\n                continue;\n            }\n            if (isset($type['mime']) && $type['mime'] === $mime) {\n                return $extension;\n            }\n        }\n\n        return $default;\n    }\n\n    /**\n     * Get all the extensions for an array of mimetypes\n     *\n     * @param array $mimetypes\n     * @return array\n     */\n    public static function getExtensions(array $mimetypes)\n    {\n        $extensions = [];\n        foreach ($mimetypes as $mimetype) {\n            $extension = static::getExtensionByMime($mimetype, false);\n            if ($extension && !in_array($extension, $extensions, true)) {\n                $extensions[] = $extension;\n            }\n        }\n\n        return $extensions;\n    }\n\n    /**\n     * Return the mimetype based on filename\n     *\n     * @param string $filename Filename or path to file\n     * @param string $default default value\n     * @return string\n     */\n    public static function getMimeByFilename($filename, $default = 'application/octet-stream')\n    {\n        return static::getMimeByExtension(static::pathinfo($filename, PATHINFO_EXTENSION), $default);\n    }\n\n    /**\n     * Return the mimetype based on existing local file\n     *\n     * @param string $filename Path to the file\n     * @param string $default\n     * @return string|bool\n     */\n    public static function getMimeByLocalFile($filename, $default = 'application/octet-stream')\n    {\n        $type = false;\n\n        // For local files we can detect type by the file content.\n        if (!stream_is_local($filename) || !file_exists($filename)) {\n            return false;\n        }\n\n        // Prefer using finfo if it exists.\n        if (extension_loaded('fileinfo')) {\n            $finfo = finfo_open(FILEINFO_SYMLINK | FILEINFO_MIME_TYPE);\n            $type = finfo_file($finfo, $filename);\n            finfo_close($finfo);\n        } else {\n            // Fall back to use getimagesize() if it is available (not recommended, but better than nothing)\n            $info = @getimagesize($filename);\n            if ($info) {\n                $type = $info['mime'];\n            }\n        }\n\n        return $type ?: static::getMimeByFilename($filename, $default);\n    }\n\n\n    /**\n     * Returns true if filename is considered safe.\n     *\n     * @param string $filename\n     * @return bool\n     */\n    public static function checkFilename($filename): bool\n    {\n        $dangerous_extensions = Grav::instance()['config']->get('security.uploads_dangerous_extensions', []);\n        $extension = mb_strtolower(static::pathinfo($filename, PATHINFO_EXTENSION));\n\n        return !(\n            // Empty filenames are not allowed.\n            !$filename\n            // Filename should not contain horizontal/vertical tabs, newlines, nils or back/forward slashes.\n            || strtr($filename, \"\\t\\v\\n\\r\\0\\\\/\", '_______') !== $filename\n            // Filename should not start or end with dot or space.\n            || trim($filename, '. ') !== $filename\n            // Filename should not contain path traversal\n            || str_replace('..', '', $filename) !== $filename\n            // File extension should not be part of configured dangerous extensions\n            || in_array($extension, $dangerous_extensions)\n        );\n    }\n\n    /**\n     * Unicode-safe version of PHP’s pathinfo() function.\n     *\n     * @link  https://www.php.net/manual/en/function.pathinfo.php\n     *\n     * @param string $path\n     * @param int|null $flags\n     * @return array|string\n     */\n    public static function pathinfo($path, int $flags = null)\n    {\n        $path = str_replace(['%2F', '%5C'], ['/', '\\\\'], rawurlencode($path));\n\n        if (null === $flags) {\n            $info = pathinfo($path);\n        } else {\n            $info = pathinfo($path, $flags);\n        }\n\n        if (is_array($info)) {\n            return array_map('rawurldecode', $info);\n        }\n\n        return rawurldecode($info);\n    }\n\n    /**\n     * Unicode-safe version of the PHP basename() function.\n     *\n     * @link  https://www.php.net/manual/en/function.basename.php\n     *\n     * @param string $path\n     * @param string $suffix\n     * @return string\n     */\n    public static function basename($path, string $suffix = ''): string\n    {\n        return rawurldecode(basename(str_replace(['%2F', '%5C'], '/', rawurlencode($path)), $suffix));\n    }\n\n    /**\n     * Normalize path by processing relative `.` and `..` syntax and merging path\n     *\n     * @param string $path\n     * @return string\n     */\n    public static function normalizePath($path)\n    {\n        // Resolve any streams\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        if ($locator->isStream($path)) {\n            $path = $locator->findResource($path);\n        }\n\n        // Set root properly for any URLs\n        $root = '';\n        preg_match(self::ROOTURL_REGEX, $path, $matches);\n        if ($matches) {\n            $root = $matches[1];\n            $path = $matches[2];\n        }\n\n        // Strip off leading / to ensure explode is accurate\n        if (static::startsWith($path, '/')) {\n            $root .= '/';\n            $path = ltrim($path, '/');\n        }\n\n        // If there are any relative paths (..) handle those\n        if (static::contains($path, '..')) {\n            $segments = explode('/', trim($path, '/'));\n            $ret = [];\n            foreach ($segments as $segment) {\n                if (($segment === '.') || $segment === '') {\n                    continue;\n                }\n                if ($segment === '..') {\n                    array_pop($ret);\n                } else {\n                    $ret[] = $segment;\n                }\n            }\n            $path = implode('/', $ret);\n        }\n\n        // Stick everything back together\n        $normalized = $root . $path;\n\n        return $normalized;\n    }\n\n    /**\n     * Check whether a function exists.\n     *\n     * Disabled functions count as non-existing functions, just like in PHP 8+.\n     *\n     * @param string $function the name of the function to check\n     * @return bool\n     */\n    public static function functionExists($function): bool\n    {\n        if (!function_exists($function)) {\n            return false;\n        }\n\n        // In PHP 7 we need to also exclude disabled methods.\n        return !static::isFunctionDisabled($function);\n    }\n\n    /**\n     * Check whether a function is disabled in the PHP settings\n     *\n     * @param string $function the name of the function to check\n     * @return bool\n     */\n    public static function isFunctionDisabled($function): bool\n    {\n        static $list;\n\n        if (null === $list) {\n            $str = trim(ini_get('disable_functions') . ',' . ini_get('suhosin.executor.func.blacklist'), ',');\n            $list = $str ? array_flip(preg_split('/\\s*,\\s*/', $str)) : [];\n        }\n\n        return array_key_exists($function, $list);\n    }\n\n    /**\n     * Get the formatted timezones list\n     *\n     * @return array\n     */\n    public static function timezones()\n    {\n        $timezones = DateTimeZone::listIdentifiers(DateTimeZone::ALL);\n        $offsets = [];\n        $testDate = new DateTime();\n\n        foreach ($timezones as $zone) {\n            $tz = new DateTimeZone($zone);\n            $offsets[$zone] = $tz->getOffset($testDate);\n        }\n\n        asort($offsets);\n\n        $timezone_list = [];\n        foreach ($offsets as $timezone => $offset) {\n            $offset_prefix = $offset < 0 ? '-' : '+';\n            $offset_formatted = gmdate('H:i', abs($offset));\n\n            $pretty_offset = \"UTC{$offset_prefix}{$offset_formatted}\";\n\n            $timezone_list[$timezone] = \"({$pretty_offset}) \" . str_replace('_', ' ', $timezone);\n        }\n\n        return $timezone_list;\n    }\n\n    /**\n     * Recursively filter an array, filtering values by processing them through the $fn function argument\n     *\n     * @param array $source the Array to filter\n     * @param callable $fn the function to pass through each array item\n     * @return array\n     */\n    public static function arrayFilterRecursive(array $source, $fn)\n    {\n        $result = [];\n        foreach ($source as $key => $value) {\n            if (is_array($value)) {\n                $result[$key] = static::arrayFilterRecursive($value, $fn);\n                continue;\n            }\n            if ($fn($key, $value)) {\n                $result[$key] = $value; // KEEP\n                continue;\n            }\n        }\n\n        return $result;\n    }\n\n    /**\n     * Flatten a multi-dimensional associative array into query params.\n     *\n     * @param array $array\n     * @param string $prepend\n     * @return array\n     */\n    public static function arrayToQueryParams($array, $prepend = '')\n    {\n        $results = [];\n        foreach ($array as $key => $value) {\n            $name = $prepend ? $prepend . '[' . $key . ']' : $key;\n\n            if (is_array($value)) {\n                $results = array_merge($results, static::arrayToQueryParams($value, $name));\n            } else {\n                $results[$name] = $value;\n            }\n        }\n\n        return $results;\n    }\n\n    /**\n     * Flatten an array\n     *\n     * @param array $array\n     * @return array\n     */\n    public static function arrayFlatten($array)\n    {\n        $flatten = [];\n        foreach ($array as $key => $inner) {\n            if (is_array($inner)) {\n                foreach ($inner as $inner_key => $value) {\n                    $flatten[$inner_key] = $value;\n                }\n            } else {\n                $flatten[$key] = $inner;\n            }\n        }\n\n        return $flatten;\n    }\n\n    /**\n     * Flatten a multi-dimensional associative array into dot notation\n     *\n     * @param array $array\n     * @param string $prepend\n     * @return array\n     */\n    public static function arrayFlattenDotNotation($array, $prepend = '')\n    {\n        $results = array();\n        foreach ($array as $key => $value) {\n            if (is_array($value)) {\n                $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend . $key . '.'));\n            } else {\n                $results[$prepend . $key] = $value;\n            }\n        }\n\n        return $results;\n    }\n\n    /**\n     * Opposite of flatten, convert flat dot notation array to multi dimensional array.\n     *\n     * If any of the parent has a scalar value, all children get ignored:\n     *\n     * admin.pages=true\n     * admin.pages.read=true\n     *\n     * becomes\n     *\n     * admin:\n     *   pages: true\n     *\n     * @param array $array\n     * @param string $separator\n     * @return array\n     */\n    public static function arrayUnflattenDotNotation($array, $separator = '.')\n    {\n        $newArray = [];\n        foreach ($array as $key => $value) {\n            $dots = explode($separator, $key);\n            if (count($dots) > 1) {\n                $last = &$newArray[$dots[0]];\n                foreach ($dots as $k => $dot) {\n                    if ($k === 0) {\n                        continue;\n                    }\n\n                    // Cannot use a scalar value as an array\n                    if (null !== $last && !is_array($last)) {\n                        continue 2;\n                    }\n\n                    $last = &$last[$dot];\n                }\n\n                // Cannot use a scalar value as an array\n                if (null !== $last && !is_array($last)) {\n                    continue;\n                }\n\n                $last = $value;\n            } else {\n                $newArray[$key] = $value;\n            }\n        }\n\n        return $newArray;\n    }\n\n    /**\n     * Checks if the passed path contains the language code prefix\n     *\n     * @param string $string The path\n     *\n     * @return bool|string Either false or the language\n     *\n     */\n    public static function pathPrefixedByLangCode($string)\n    {\n        $languages_enabled = Grav::instance()['config']->get('system.languages.supported', []);\n        $parts = explode('/', trim($string, '/'));\n\n        if (count($parts) > 0 && in_array($parts[0], $languages_enabled)) {\n            return $parts[0];\n        }\n        return false;\n    }\n\n    /**\n     * Get the timestamp of a date\n     *\n     * @param string $date a String expressed in the system.pages.dateformat.default format, with fallback to a\n     *                     strtotime argument\n     * @param string|null $format a date format to use if possible\n     * @return int the timestamp\n     */\n    public static function date2timestamp($date, $format = null)\n    {\n        $config = Grav::instance()['config'];\n        $dateformat = $format ?: $config->get('system.pages.dateformat.default');\n\n        // try to use DateTime and default format\n        if ($dateformat) {\n            $datetime = DateTime::createFromFormat($dateformat, $date);\n        } else {\n            try {\n                $datetime = new DateTime($date);\n            } catch (Exception $e) {\n                $datetime = false;\n            }\n        }\n\n        // fallback to strtotime() if DateTime approach failed\n        if ($datetime !== false) {\n            return $datetime->getTimestamp();\n        }\n\n        return strtotime($date);\n    }\n\n    /**\n     * @param array $array\n     * @param string $path\n     * @param null $default\n     * @return mixed\n     *\n     * @deprecated 1.5 Use ->getDotNotation() method instead.\n     */\n    public static function resolve(array $array, $path, $default = null)\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.5, use ->getDotNotation() method instead', E_USER_DEPRECATED);\n\n        return static::getDotNotation($array, $path, $default);\n    }\n\n    /**\n     * Checks if a value is positive (true)\n     *\n     * @param string $value\n     * @return bool\n     */\n    public static function isPositive($value)\n    {\n        return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true);\n    }\n\n    /**\n     * Checks if a value is negative (false)\n     *\n     * @param string $value\n     * @return bool\n     */\n    public static function isNegative($value)\n    {\n        return in_array($value, [false, 0, '0', 'no', 'off', 'false'], true);\n    }\n\n    /**\n     * Generates a nonce string to be hashed. Called by self::getNonce()\n     * We removed the IP portion in this version because it causes too many inconsistencies\n     * with reverse proxy setups.\n     *\n     * @param string $action\n     * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours)\n     * @return string the nonce string\n     */\n    private static function generateNonceString($action, $previousTick = false)\n    {\n        $grav = Grav::instance();\n\n        $username = isset($grav['user']) ? $grav['user']->username : '';\n        $token = session_id();\n        $i = self::nonceTick();\n\n        if ($previousTick) {\n            $i--;\n        }\n\n        return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . $grav['config']->get('security.salt'));\n    }\n\n    /**\n     * Get the time-dependent variable for nonce creation.\n     *\n     * Now a tick lasts a day. Once the day is passed, the nonce is not valid any more. Find a better way\n     *       to ensure nonces issued near the end of the day do not expire in that small amount of time\n     *\n     * @return int the time part of the nonce. Changes once every 24 hours\n     */\n    private static function nonceTick()\n    {\n        $secondsInHalfADay = 60 * 60 * 12;\n\n        return (int)ceil(time() / $secondsInHalfADay);\n    }\n\n    /**\n     * Creates a hashed nonce tied to the passed action. Tied to the current user and time. The nonce for a given\n     * action is the same for 12 hours.\n     *\n     * @param string $action the action the nonce is tied to (e.g. save-user-admin or move-page-homepage)\n     * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours)\n     * @return string the nonce\n     */\n    public static function getNonce($action, $previousTick = false)\n    {\n        // Don't regenerate this again if not needed\n        if (isset(static::$nonces[$action][$previousTick])) {\n            return static::$nonces[$action][$previousTick];\n        }\n        $nonce = md5(self::generateNonceString($action, $previousTick));\n        static::$nonces[$action][$previousTick] = $nonce;\n\n        return static::$nonces[$action][$previousTick];\n    }\n\n    /**\n     * Verify the passed nonce for the give action\n     *\n     * @param string|string[] $nonce the nonce to verify\n     * @param string $action the action to verify the nonce to\n     * @return boolean verified or not\n     */\n    public static function verifyNonce($nonce, $action)\n    {\n        //Safety check for multiple nonces\n        if (is_array($nonce)) {\n            $nonce = array_shift($nonce);\n        }\n\n        //Nonce generated 0-12 hours ago\n        if ($nonce === self::getNonce($action)) {\n            return true;\n        }\n\n        //Nonce generated 12-24 hours ago\n        return $nonce === self::getNonce($action, true);\n    }\n\n    /**\n     * Simple helper method to get whether or not the admin plugin is active\n     *\n     * @return bool\n     */\n    public static function isAdminPlugin()\n    {\n        return isset(Grav::instance()['admin']);\n    }\n\n    /**\n     * Get a portion of an array (passed by reference) with dot-notation key\n     *\n     * @param array $array\n     * @param string|int|null $key\n     * @param null $default\n     * @return mixed\n     */\n    public static function getDotNotation($array, $key, $default = null)\n    {\n        if (null === $key) {\n            return $array;\n        }\n\n        if (isset($array[$key])) {\n            return $array[$key];\n        }\n\n        foreach (explode('.', $key) as $segment) {\n            if (!is_array($array) || !array_key_exists($segment, $array)) {\n                return $default;\n            }\n\n            $array = $array[$segment];\n        }\n\n        return $array;\n    }\n\n    /**\n     * Set portion of array (passed by reference) for a dot-notation key\n     * and set the value\n     *\n     * @param array $array\n     * @param string|int|null $key\n     * @param mixed $value\n     * @param bool $merge\n     *\n     * @return mixed\n     */\n    public static function setDotNotation(&$array, $key, $value, $merge = false)\n    {\n        if (null === $key) {\n            return $array = $value;\n        }\n\n        $keys = explode('.', $key);\n\n        while (count($keys) > 1) {\n            $key = array_shift($keys);\n\n            if (!isset($array[$key]) || !is_array($array[$key])) {\n                $array[$key] = array();\n            }\n\n            $array =& $array[$key];\n        }\n\n        $key = array_shift($keys);\n\n        if (!$merge || !isset($array[$key])) {\n            $array[$key] = $value;\n        } else {\n            $array[$key] = array_merge($array[$key], $value);\n        }\n\n        return $array;\n    }\n\n    /**\n     * Utility method to determine if the current OS is Windows\n     *\n     * @return bool\n     */\n    public static function isWindows()\n    {\n        return strncasecmp(PHP_OS, 'WIN', 3) === 0;\n    }\n\n    /**\n     * Utility to determine if the server running PHP is Apache\n     *\n     * @return bool\n     */\n    public static function isApache()\n    {\n        return isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== false;\n    }\n\n    /**\n     * Sort a multidimensional array  by another array of ordered keys\n     *\n     * @param array $array\n     * @param array $orderArray\n     * @return array\n     */\n    public static function sortArrayByArray(array $array, array $orderArray)\n    {\n        $ordered = [];\n        foreach ($orderArray as $key) {\n            if (array_key_exists($key, $array)) {\n                $ordered[$key] = $array[$key];\n                unset($array[$key]);\n            }\n        }\n        return $ordered + $array;\n    }\n\n    /**\n     * Sort an array by a key value in the array\n     *\n     * @param mixed $array\n     * @param string|int $array_key\n     * @param int $direction\n     * @param int $sort_flags\n     * @return array\n     */\n    public static function sortArrayByKey($array, $array_key, $direction = SORT_DESC, $sort_flags = SORT_REGULAR)\n    {\n        $output = [];\n\n        if (!is_array($array) || !$array) {\n            return $output;\n        }\n\n        foreach ($array as $key => $row) {\n            $output[$key] = $row[$array_key];\n        }\n\n        array_multisort($output, $direction, $sort_flags, $array);\n\n        return $array;\n    }\n\n    /**\n     * Get relative page path based on a token.\n     *\n     * @param string $path\n     * @param PageInterface|null $page\n     * @return string\n     * @throws RuntimeException\n     */\n    public static function getPagePathFromToken($path, PageInterface $page = null)\n    {\n        return static::getPathFromToken($path, $page);\n    }\n\n    /**\n     * Get relative path based on a token.\n     *\n     * Path supports following syntaxes:\n     *\n     * 'self@', 'self@/path'\n     * 'page@:/route', 'page@:/route/filename.ext'\n     * 'theme@:', 'theme@:/path'\n     *\n     * @param string $path\n     * @param FlexObjectInterface|PageInterface|null $object\n     * @return string\n     * @throws RuntimeException\n     */\n    public static function getPathFromToken($path, $object = null)\n    {\n        $matches = static::resolveTokenPath($path);\n        if (null === $matches) {\n            return $path;\n        }\n\n        $grav = Grav::instance();\n\n        switch ($matches[0]) {\n            case 'self':\n                if (!$object instanceof MediaInterface) {\n                    throw new RuntimeException(sprintf('Page not available for self@ reference: %s', $path));\n                }\n\n                if ($matches[2] === '') {\n                    if ($object->exists()) {\n                        $route = '/' . $matches[1];\n\n                        if ($object instanceof PageInterface) {\n                            return trim($object->relativePagePath() . $route, '/');\n                        }\n\n                        $folder = $object->getMediaFolder();\n                        if ($folder) {\n                            return trim($folder . $route, '/');\n                        }\n                    } else {\n                        return '';\n                    }\n                }\n\n                break;\n            case 'page':\n                if ($matches[1] === '') {\n                    $route = '/' . $matches[2];\n\n                    // Exclude filename from the page lookup.\n                    if (static::pathinfo($route, PATHINFO_EXTENSION)) {\n                        $basename = '/' . static::basename($route);\n                        $route = \\dirname($route);\n                    } else {\n                        $basename = '';\n                    }\n\n                    $key = trim($route === '/' ? $grav['config']->get('system.home.alias') : $route, '/');\n                    if ($object instanceof PageObject) {\n                        $object = $object->getFlexDirectory()->getObject($key);\n                    } elseif (static::isAdminPlugin()) {\n                        /** @var Flex|null $flex */\n                        $flex = $grav['flex'] ?? null;\n                        $object = $flex ? $flex->getObject($key, 'pages') : null;\n                    } else {\n                        /** @var Pages $pages */\n                        $pages = $grav['pages'];\n                        $object = $pages->find($route);\n                    }\n\n                    if ($object instanceof PageInterface) {\n                        return trim($object->relativePagePath() . $basename, '/');\n                    }\n                }\n\n                break;\n            case 'theme':\n                if ($matches[1] === '') {\n                    $route = '/' . $matches[2];\n                    $theme = $grav['locator']->findResource('theme://', false);\n                    if (false !== $theme) {\n                        return trim($theme . $route, '/');\n                    }\n                }\n\n                break;\n        }\n\n        throw new RuntimeException(sprintf('Token path not found: %s', $path));\n    }\n\n    /**\n     * Returns [token, route, path] from '@token/route:/path'. Route and path are optional. If pattern does not match, return null.\n     *\n     * @param string $path\n     * @return string[]|null\n     */\n    protected static function resolveTokenPath(string $path): ?array\n    {\n        if (strpos($path, '@') !== false) {\n            $regex = '/^(@\\w+|\\w+@|@\\w+@)([^:]*)(.*)$/u';\n            if (preg_match($regex, $path, $matches)) {\n                return [\n                    trim($matches[1], '@'),\n                    trim($matches[2], '/'),\n                    trim($matches[3], ':/')\n                ];\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * @return int\n     */\n    public static function getUploadLimit()\n    {\n        static $max_size = -1;\n\n        if ($max_size < 0) {\n            $post_max_size = static::parseSize(ini_get('post_max_size'));\n            if ($post_max_size > 0) {\n                $max_size = $post_max_size;\n            } else {\n                $max_size = 0;\n            }\n\n            $upload_max = static::parseSize(ini_get('upload_max_filesize'));\n            if ($upload_max > 0 && $upload_max < $max_size) {\n                $max_size = $upload_max;\n            }\n        }\n\n        return $max_size;\n    }\n\n    /**\n     * Convert bytes to the unit specified by the $to parameter.\n     *\n     * @param int $bytes The filesize in Bytes.\n     * @param string $to The unit type to convert to. Accepts K, M, or G for Kilobytes, Megabytes, or Gigabytes, respectively.\n     * @param int $decimal_places The number of decimal places to return.\n     * @return int Returns only the number of units, not the type letter. Returns 0 if the $to unit type is out of scope.\n     *\n     */\n    public static function convertSize($bytes, $to, $decimal_places = 1)\n    {\n        $formulas = array(\n            'K' => number_format($bytes / 1024, $decimal_places),\n            'M' => number_format($bytes / 1048576, $decimal_places),\n            'G' => number_format($bytes / 1073741824, $decimal_places)\n        );\n        return $formulas[$to] ?? 0;\n    }\n\n    /**\n     * Return a pretty size based on bytes\n     *\n     * @param int $bytes\n     * @param int $precision\n     * @return string\n     */\n    public static function prettySize($bytes, $precision = 2)\n    {\n        $units = array('B', 'KB', 'MB', 'GB', 'TB');\n\n        $bytes = max($bytes, 0);\n        $pow = floor(($bytes ? log($bytes) : 0) / log(1024));\n        $pow = min($pow, count($units) - 1);\n\n        // Uncomment one of the following alternatives\n        $bytes /= 1024 ** $pow;\n        // $bytes /= (1 << (10 * $pow));\n\n        return round($bytes, $precision) . ' ' . $units[$pow];\n    }\n\n    /**\n     * Parse a readable file size and return a value in bytes\n     *\n     * @param string|int|float $size\n     * @return int\n     */\n    public static function parseSize($size)\n    {\n        $unit = preg_replace('/[^bkmgtpezy]/i', '', $size);\n        $size = (float)preg_replace('/[^0-9\\.]/', '', $size);\n\n        if ($unit) {\n            $size *= 1024 ** stripos('bkmgtpezy', $unit[0]);\n        }\n\n        return (int)abs(round($size));\n    }\n\n    /**\n     * Multibyte-safe Parse URL function\n     *\n     * @param string $url\n     * @return array\n     * @throws InvalidArgumentException\n     */\n    public static function multibyteParseUrl($url)\n    {\n        $enc_url = preg_replace_callback(\n            '%[^:/@?&=#]+%usD',\n            static function ($matches) {\n                return urlencode($matches[0]);\n            },\n            $url\n        );\n\n        $parts = parse_url($enc_url);\n\n        if ($parts === false) {\n            $parts = [];\n        }\n\n        foreach ($parts as $name => $value) {\n            $parts[$name] = urldecode($value);\n        }\n\n        return $parts;\n    }\n\n    /**\n     * Process a string as markdown\n     *\n     * @param string $string\n     * @param bool $block Block or Line processing\n     * @param PageInterface|null $page\n     * @return string\n     * @throws Exception\n     */\n    public static function processMarkdown($string, $block = true, $page = null)\n    {\n        $grav = Grav::instance();\n        $page = $page ?? $grav['page'] ?? null;\n        $defaults = [\n            'markdown' => $grav['config']->get('system.pages.markdown', []),\n            'images' => $grav['config']->get('system.images', [])\n        ];\n        $extra = $defaults['markdown']['extra'] ?? false;\n\n        $excerpts = new Excerpts($page, $defaults);\n\n        // Initialize the preferred variant of Parsedown\n        if ($extra) {\n            $parsedown = new ParsedownExtra($excerpts);\n        } else {\n            $parsedown = new Parsedown($excerpts);\n        }\n\n        if ($block) {\n            $string = $parsedown->text((string) $string);\n        } else {\n            $string = $parsedown->line((string) $string);\n        }\n\n        return $string;\n    }\n\n    public static function toAscii(String $string): String\n    {\n        return strtr(utf8_decode($string),\n            utf8_decode(\n            'ŠŒŽšœžŸ¥µÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýÿ'),\n            'SOZsozYYuAAAAAAACEEEEIIIIDNOOOOOOUUUUYsaaaaaaaceeeeiiiionoooooouuuuyy');\n    }\n\n    /**\n     * Find the subnet of an ip with CIDR prefix size\n     *\n     * @param string $ip\n     * @param int $prefix\n     * @return string\n     */\n    public static function getSubnet($ip, $prefix = 64)\n    {\n        if (!filter_var($ip, FILTER_VALIDATE_IP)) {\n            return $ip;\n        }\n\n        // Packed representation of IP\n        $ip = (string)inet_pton($ip);\n\n        // Maximum netmask length = same as packed address\n        $len = 8 * strlen($ip);\n        if ($prefix > $len) {\n            $prefix = $len;\n        }\n\n        $mask = str_repeat('f', $prefix >> 2);\n\n        switch ($prefix & 3) {\n            case 3:\n                $mask .= 'e';\n                break;\n            case 2:\n                $mask .= 'c';\n                break;\n            case 1:\n                $mask .= '8';\n                break;\n        }\n        $mask = str_pad($mask, $len >> 2, '0');\n\n        // Packed representation of netmask\n        $mask = pack('H*', $mask);\n        // Bitwise - Take all bits that are both 1 to generate subnet\n        $subnet = inet_ntop($ip & $mask);\n\n        return $subnet;\n    }\n\n    /**\n     * Wrapper to ensure html, htm in the front of the supported page types\n     *\n     * @param array|null $defaults\n     * @return array\n     */\n    public static function getSupportPageTypes(array $defaults = null)\n    {\n        $types = Grav::instance()['config']->get('system.pages.types', $defaults);\n        if (!is_array($types)) {\n            return [];\n        }\n\n        // remove html/htm\n        $types = static::arrayRemoveValue($types, ['html', 'htm']);\n\n        // put them back at the front\n        $types = array_merge(['html', 'htm'], $types);\n\n        return $types;\n    }\n\n    /**\n     * @param string|array|Closure $name\n     * @return bool\n     */\n    public static function isDangerousFunction($name): bool\n    {\n        static $commandExecutionFunctions = [\n            'exec',\n            'passthru',\n            'system',\n            'shell_exec',\n            'popen',\n            'proc_open',\n            'pcntl_exec',\n        ];\n\n        static $codeExecutionFunctions = [\n            'assert',\n            'preg_replace',\n            'create_function',\n            'include',\n            'include_once',\n            'require',\n            'require_once'\n        ];\n\n        static $callbackFunctions = [\n            'ob_start' => 0,\n            'array_diff_uassoc' => -1,\n            'array_diff_ukey' => -1,\n            'array_filter' => 1,\n            'array_intersect_uassoc' => -1,\n            'array_intersect_ukey' => -1,\n            'array_map' => 0,\n            'array_reduce' => 1,\n            'array_udiff_assoc' => -1,\n            'array_udiff_uassoc' => [-1, -2],\n            'array_udiff' => -1,\n            'array_uintersect_assoc' => -1,\n            'array_uintersect_uassoc' => [-1, -2],\n            'array_uintersect' => -1,\n            'array_walk_recursive' => 1,\n            'array_walk' => 1,\n            'assert_options' => 1,\n            'uasort' => 1,\n            'uksort' => 1,\n            'usort' => 1,\n            'preg_replace_callback' => 1,\n            'spl_autoload_register' => 0,\n            'iterator_apply' => 1,\n            'call_user_func' => 0,\n            'call_user_func_array' => 0,\n            'register_shutdown_function' => 0,\n            'register_tick_function' => 0,\n            'set_error_handler' => 0,\n            'set_exception_handler' => 0,\n            'session_set_save_handler' => [0, 1, 2, 3, 4, 5],\n            'sqlite_create_aggregate' => [2, 3],\n            'sqlite_create_function' => 2,\n        ];\n\n        static $informationDiscosureFunctions = [\n            'phpinfo',\n            'posix_mkfifo',\n            'posix_getlogin',\n            'posix_ttyname',\n            'getenv',\n            'get_current_user',\n            'proc_get_status',\n            'get_cfg_var',\n            'disk_free_space',\n            'disk_total_space',\n            'diskfreespace',\n            'getcwd',\n            'getlastmo',\n            'getmygid',\n            'getmyinode',\n            'getmypid',\n            'getmyuid'\n        ];\n\n        static $otherFunctions = [\n            'extract',\n            'parse_str',\n            'putenv',\n            'ini_set',\n            'mail',\n            'header',\n            'proc_nice',\n            'proc_terminate',\n            'proc_close',\n            'pfsockopen',\n            'fsockopen',\n            'apache_child_terminate',\n            'posix_kill',\n            'posix_mkfifo',\n            'posix_setpgid',\n            'posix_setsid',\n            'posix_setuid',\n            'unserialize',\n            'ini_alter',\n            'simplexml_load_file',\n            'simplexml_load_string',\n            'forward_static_call',\n            'forward_static_call_array',\n        ];\n\n        if (is_string($name)) {\n            $name = strtolower($name);\n        }\n\n        if ($name instanceof \\Closure) {\n            return false;\n        }\n\n        if (is_array($name) || strpos($name, \":\") !== false) {\n            return true;\n        }\n\n        if (strpos($name, \"\\\\\") !== false) {\n            return true;\n        }\n\n        if (in_array($name, $commandExecutionFunctions)) {\n            return true;\n        }\n\n        if (in_array($name, $codeExecutionFunctions)) {\n            return true;\n        }\n\n        if (isset($callbackFunctions[$name])) {\n            return true;\n        }\n\n        if (in_array($name, $informationDiscosureFunctions)) {\n            return true;\n        }\n\n        if (in_array($name, $otherFunctions)) {\n            return true;\n        }\n\n        return static::isFilesystemFunction($name);\n    }\n\n    /**\n     * @param string $name\n     * @return bool\n     */\n    public static function isFilesystemFunction(string $name): bool\n    {\n        static $fileWriteFunctions = [\n            'fopen',\n            'tmpfile',\n            'bzopen',\n            'gzopen',\n            // write to filesystem (partially in combination with reading)\n            'chgrp',\n            'chmod',\n            'chown',\n            'copy',\n            'file_put_contents',\n            'lchgrp',\n            'lchown',\n            'link',\n            'mkdir',\n            'move_uploaded_file',\n            'rename',\n            'rmdir',\n            'symlink',\n            'tempnam',\n            'touch',\n            'unlink',\n            'imagepng',\n            'imagewbmp',\n            'image2wbmp',\n            'imagejpeg',\n            'imagexbm',\n            'imagegif',\n            'imagegd',\n            'imagegd2',\n            'iptcembed',\n            'ftp_get',\n            'ftp_nb_get',\n        ];\n\n        static $fileContentFunctions = [\n            'file_get_contents',\n            'file',\n            'filegroup',\n            'fileinode',\n            'fileowner',\n            'fileperms',\n            'glob',\n            'is_executable',\n            'is_uploaded_file',\n            'parse_ini_file',\n            'readfile',\n            'readlink',\n            'realpath',\n            'gzfile',\n            'readgzfile',\n            'stat',\n            'imagecreatefromgif',\n            'imagecreatefromjpeg',\n            'imagecreatefrompng',\n            'imagecreatefromwbmp',\n            'imagecreatefromxbm',\n            'imagecreatefromxpm',\n            'ftp_put',\n            'ftp_nb_put',\n            'hash_update_file',\n            'highlight_file',\n            'show_source',\n            'php_strip_whitespace',\n        ];\n\n        static $filesystemFunctions = [\n            // read from filesystem\n            'file_exists',\n            'fileatime',\n            'filectime',\n            'filemtime',\n            'filesize',\n            'filetype',\n            'is_dir',\n            'is_file',\n            'is_link',\n            'is_readable',\n            'is_writable',\n            'is_writeable',\n            'linkinfo',\n            'lstat',\n            //'pathinfo',\n            'getimagesize',\n            'exif_read_data',\n            'read_exif_data',\n            'exif_thumbnail',\n            'exif_imagetype',\n            'hash_file',\n            'hash_hmac_file',\n            'md5_file',\n            'sha1_file',\n            'get_meta_tags',\n        ];\n\n        if (in_array($name, $fileWriteFunctions)) {\n            return true;\n        }\n\n        if (in_array($name, $fileContentFunctions)) {\n            return true;\n        }\n\n        if (in_array($name, $filesystemFunctions)) {\n            return true;\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Common/Yaml.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Common;\n\nuse Grav\\Framework\\File\\Formatter\\YamlFormatter;\n\n/**\n * Class Yaml\n * @package Grav\\Common\n */\nabstract class Yaml\n{\n    /** @var YamlFormatter|null */\n    protected static $yaml;\n\n    /**\n     * @param string $data\n     * @return array\n     */\n    public static function parse($data)\n    {\n        if (null === static::$yaml) {\n            static::init();\n        }\n\n        return static::$yaml->decode($data);\n    }\n\n    /**\n     * @param array $data\n     * @param int|null $inline\n     * @param int|null $indent\n     * @return string\n     */\n    public static function dump($data, $inline = null, $indent = null)\n    {\n        if (null === static::$yaml) {\n            static::init();\n        }\n\n        return static::$yaml->encode($data, $inline, $indent);\n    }\n\n    /**\n     * @return void\n     */\n    protected static function init()\n    {\n        $config = [\n            'inline' => 5,\n            'indent' => 2,\n            'native' => true,\n            'compat' => true\n        ];\n\n        static::$yaml = new YamlFormatter($config);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Application/Application.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Application;\n\nuse Grav\\Common\\Grav;\nuse Symfony\\Component\\Console\\ConsoleEvents;\nuse Symfony\\Component\\Console\\Event\\ConsoleCommandEvent;\nuse Symfony\\Component\\Console\\Formatter\\OutputFormatterStyle;\nuse Symfony\\Component\\Console\\Input\\InputDefinition;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\EventDispatcher\\EventDispatcher;\n\n/**\n * Class GpmApplication\n * @package Grav\\Console\\Application\n */\nclass Application extends \\Symfony\\Component\\Console\\Application\n{\n    /** @var string|null */\n    protected $environment;\n    /** @var string|null */\n    protected $language;\n    /** @var bool */\n    protected $initialized = false;\n\n    /**\n     * PluginApplication constructor.\n     * @param string $name\n     * @param string $version\n     */\n    public function __construct(string $name = 'UNKNOWN', string $version = 'UNKNOWN')\n    {\n        parent::__construct($name, $version);\n\n        // Add listener to prepare environment.\n        $dispatcher = new EventDispatcher();\n        $dispatcher->addListener(ConsoleEvents::COMMAND, [$this, 'prepareEnvironment']);\n\n        $this->setDispatcher($dispatcher);\n    }\n\n    /**\n     * @param InputInterface $input\n     * @return string|null\n     */\n    public function getCommandName(InputInterface $input): ?string\n    {\n        if ($input->hasParameterOption('--env', true)) {\n            $this->environment = $input->getParameterOption('--env');\n        }\n        if ($input->hasParameterOption('--lang', true)) {\n            $this->language = $input->getParameterOption('--lang');\n        }\n\n        $this->init();\n\n        return parent::getCommandName($input);\n    }\n\n    /**\n     * @param ConsoleCommandEvent $event\n     * @return void\n     */\n    public function prepareEnvironment(ConsoleCommandEvent $event): void\n    {\n    }\n\n    /**\n     * @return void\n     */\n    protected function init(): void\n    {\n        if ($this->initialized) {\n            return;\n        }\n\n        $this->initialized = true;\n\n        $grav = Grav::instance();\n        $grav->setup($this->environment);\n    }\n\n    /**\n     * Add global --env and --lang options.\n     *\n     * @return InputDefinition\n     */\n    protected function getDefaultInputDefinition(): InputDefinition\n    {\n        $inputDefinition = parent::getDefaultInputDefinition();\n        $inputDefinition->addOption(\n            new InputOption(\n                '--env',\n                '',\n                InputOption::VALUE_OPTIONAL,\n                'Use environment configuration (defaults to localhost)'\n            )\n        );\n        $inputDefinition->addOption(\n            new InputOption(\n                '--lang',\n                '',\n                InputOption::VALUE_OPTIONAL,\n                'Language to be used (defaults to en)'\n            )\n        );\n\n        return $inputDefinition;\n    }\n\n    /**\n     * @param InputInterface $input\n     * @param OutputInterface $output\n     * @return void\n     */\n    protected function configureIO(InputInterface $input, OutputInterface $output)\n    {\n        $formatter = $output->getFormatter();\n        $formatter->setStyle('normal', new OutputFormatterStyle('white'));\n        $formatter->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold']));\n        $formatter->setStyle('red', new OutputFormatterStyle('red', null, ['bold']));\n        $formatter->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold']));\n        $formatter->setStyle('green', new OutputFormatterStyle('green', null, ['bold']));\n        $formatter->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold']));\n        $formatter->setStyle('white', new OutputFormatterStyle('white', null, ['bold']));\n\n        parent::configureIO($input, $output);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Application/CommandLoader/PluginCommandLoader.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Application\\CommandLoader;\n\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\CommandLoader\\CommandLoaderInterface;\nuse Symfony\\Component\\Console\\Exception\\CommandNotFoundException;\n\n/**\n * Class GpmApplication\n * @package Grav\\Console\\Application\n */\nclass PluginCommandLoader implements CommandLoaderInterface\n{\n    /** @var array */\n    private $commands;\n\n    /**\n     * PluginCommandLoader constructor.\n     *\n     * @param string $name\n     */\n    public function __construct(string $name)\n    {\n        $this->commands = [];\n\n        try {\n            $path = \"plugins://{$name}/cli\";\n            $pattern = '([A-Z]\\w+Command\\.php)';\n\n            $commands = is_dir($path) ? Folder::all($path, ['compare' => 'Filename', 'pattern' => '/' . $pattern . '$/usm', 'levels' => 1]) : [];\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(\"Failed to load console commands for plugin {$name}\");\n        }\n\n        $grav = Grav::instance();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n        foreach ($commands as $command_path) {\n            $full_path = $locator->findResource(\"plugins://{$name}/cli/{$command_path}\");\n            require_once $full_path;\n\n            $command_class = 'Grav\\Plugin\\Console\\\\' . preg_replace('/.php$/', '', $command_path);\n            if (class_exists($command_class)) {\n                $command = new $command_class();\n                if ($command instanceof Command) {\n                    $this->commands[$command->getName()] = $command;\n\n                    // If the command has an alias, add that as a possible command name.\n                    $aliases = $this->commands[$command->getName()]->getAliases();\n                    if (isset($aliases)) {\n                        foreach ($aliases as $alias) {\n                            $this->commands[$alias] = $command;\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    /**\n     * @param string $name\n     * @return Command\n     */\n    public function get($name): Command\n    {\n        $command = $this->commands[$name] ?? null;\n        if (null === $command) {\n            throw new CommandNotFoundException(sprintf('The command \"%s\" does not exist.', $name));\n        }\n\n        return $command;\n    }\n\n    /**\n     * @param string $name\n     * @return bool\n     */\n    public function has($name): bool\n    {\n        return isset($this->commands[$name]);\n    }\n\n    /**\n     * @return string[]\n     */\n    public function getNames(): array\n    {\n        return array_keys($this->commands);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Application/GpmApplication.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Application;\n\nuse Grav\\Console\\Gpm\\DirectInstallCommand;\nuse Grav\\Console\\Gpm\\IndexCommand;\nuse Grav\\Console\\Gpm\\InfoCommand;\nuse Grav\\Console\\Gpm\\InstallCommand;\nuse Grav\\Console\\Gpm\\SelfupgradeCommand;\nuse Grav\\Console\\Gpm\\UninstallCommand;\nuse Grav\\Console\\Gpm\\UpdateCommand;\nuse Grav\\Console\\Gpm\\VersionCommand;\n\n/**\n * Class GpmApplication\n * @package Grav\\Console\\Application\n */\nclass GpmApplication extends Application\n{\n    public function __construct(string $name = 'UNKNOWN', string $version = 'UNKNOWN')\n    {\n        parent::__construct($name, $version);\n\n        $this->addCommands([\n            new IndexCommand(),\n            new VersionCommand(),\n            new InfoCommand(),\n            new InstallCommand(),\n            new UninstallCommand(),\n            new UpdateCommand(),\n            new SelfupgradeCommand(),\n            new DirectInstallCommand(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Application/GravApplication.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Application;\n\nuse Grav\\Console\\Cli\\BackupCommand;\nuse Grav\\Console\\Cli\\CacheCleanupCommand;\nuse Grav\\Console\\Cli\\CleanCommand;\nuse Grav\\Console\\Cli\\ClearCacheCommand;\nuse Grav\\Console\\Cli\\ComposerCommand;\nuse Grav\\Console\\Cli\\InstallCommand;\nuse Grav\\Console\\Cli\\LogViewerCommand;\nuse Grav\\Console\\Cli\\NewProjectCommand;\nuse Grav\\Console\\Cli\\PageSystemValidatorCommand;\nuse Grav\\Console\\Cli\\SandboxCommand;\nuse Grav\\Console\\Cli\\SchedulerCommand;\nuse Grav\\Console\\Cli\\SecurityCommand;\nuse Grav\\Console\\Cli\\ServerCommand;\nuse Grav\\Console\\Cli\\YamlLinterCommand;\n\n/**\n * Class GravApplication\n * @package Grav\\Console\\Application\n */\nclass GravApplication extends Application\n{\n    public function __construct(string $name = 'UNKNOWN', string $version = 'UNKNOWN')\n    {\n        parent::__construct($name, $version);\n\n        $this->addCommands([\n            new InstallCommand(),\n            new ComposerCommand(),\n            new SandboxCommand(),\n            new CleanCommand(),\n            new ClearCacheCommand(),\n            new CacheCleanupCommand(),\n            new BackupCommand(),\n            new NewProjectCommand(),\n            new SchedulerCommand(),\n            new SecurityCommand(),\n            new LogViewerCommand(),\n            new YamlLinterCommand(),\n            new ServerCommand(),\n            new PageSystemValidatorCommand(),\n        ]);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Application/PluginApplication.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Application;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Plugins;\nuse Grav\\Console\\Application\\CommandLoader\\PluginCommandLoader;\nuse Grav\\Console\\Plugin\\PluginListCommand;\nuse Symfony\\Component\\Console\\Exception\\NamespaceNotFoundException;\nuse Symfony\\Component\\Console\\Input\\ArgvInput;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Throwable;\n\n/**\n * Class PluginApplication\n * @package Grav\\Console\\Application\n */\nclass PluginApplication extends Application\n{\n    /** @var string|null */\n    protected $pluginName;\n\n    /**\n     * PluginApplication constructor.\n     * @param string $name\n     * @param string $version\n     */\n    public function __construct(string $name = 'UNKNOWN', string $version = 'UNKNOWN')\n    {\n        parent::__construct($name, $version);\n\n        $this->addCommands([\n            new PluginListCommand(),\n        ]);\n    }\n\n    /**\n     * @param string $pluginName\n     * @return void\n     */\n    public function setPluginName(string $pluginName): void\n    {\n        $this->pluginName = $pluginName;\n    }\n\n    /**\n     * @return string\n     */\n    public function getPluginName(): string\n    {\n        return $this->pluginName;\n    }\n\n    /**\n     * @param InputInterface|null $input\n     * @param OutputInterface|null $output\n     * @return int\n     * @throws Throwable\n     */\n    public function run(InputInterface $input = null, OutputInterface $output = null): int\n    {\n        if (null === $input) {\n            $argv = $_SERVER['argv'] ?? [];\n\n            $bin = array_shift($argv);\n            $this->pluginName = array_shift($argv);\n            $argv = array_merge([$bin], $argv);\n\n            $input = new ArgvInput($argv);\n        }\n\n        return parent::run($input, $output);\n    }\n\n    /**\n     * @return void\n     */\n    protected function init(): void\n    {\n        if ($this->initialized) {\n            return;\n        }\n\n        parent::init();\n\n        if (null === $this->pluginName) {\n            $this->setDefaultCommand('plugins:list');\n\n            return;\n        }\n\n        $grav = Grav::instance();\n        $grav->initializeCli();\n\n        /** @var Plugins $plugins */\n        $plugins = $grav['plugins'];\n\n        $plugin = $this->pluginName ? $plugins::get($this->pluginName) : null;\n        if (null === $plugin) {\n            throw new NamespaceNotFoundException(\"Plugin \\\"{$this->pluginName}\\\" is not installed.\");\n        }\n        if (!$plugin->enabled) {\n            throw new NamespaceNotFoundException(\"Plugin \\\"{$this->pluginName}\\\" is not enabled.\");\n        }\n\n        $this->setCommandLoader(new PluginCommandLoader($this->pluginName));\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/BackupCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Common\\Backup\\Backups;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\GravCommand;\nuse Symfony\\Component\\Console\\Helper\\ProgressBar;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Question\\ChoiceQuestion;\nuse ZipArchive;\nuse function count;\n\n/**\n * Class BackupCommand\n * @package Grav\\Console\\Cli\n */\nclass BackupCommand extends GravCommand\n{\n    /** @var string $source */\n    protected $source;\n    /** @var ProgressBar $progress */\n    protected $progress;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('backup')\n            ->addArgument(\n                'id',\n                InputArgument::OPTIONAL,\n                'The ID of the backup profile to perform without prompting'\n            )\n            ->setDescription('Creates a backup of the Grav instance')\n            ->setHelp('The <info>backup</info> creates a zipped backup.');\n\n        $this->source = getcwd();\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $this->initializeGrav();\n\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $io->title('Grav Backup');\n\n        if (!class_exists(ZipArchive::class)) {\n            $io->error('php-zip extension needs to be enabled!');\n            return 1;\n        }\n\n        ProgressBar::setFormatDefinition('zip', 'Archiving <cyan>%current%</cyan> files [<green>%bar%</green>] <white>%percent:3s%%</white> %elapsed:6s% <yellow>%message%</yellow>');\n\n        $this->progress = new ProgressBar($this->output, 100);\n        $this->progress->setFormat('zip');\n\n\n        /** @var Backups $backups */\n        $backups = Grav::instance()['backups'];\n        $backups_list = $backups::getBackupProfiles();\n        $backups_names = $backups->getBackupNames();\n\n        $id = null;\n\n        $inline_id = $input->getArgument('id');\n        if (null !== $inline_id && is_numeric($inline_id)) {\n            $id = $inline_id;\n        }\n\n        if (null === $id) {\n            if (count($backups_list) > 1) {\n                $question = new ChoiceQuestion(\n                    'Choose a backup?',\n                    $backups_names,\n                    0\n                );\n                $question->setErrorMessage('Option %s is invalid.');\n                $backup_name = $io->askQuestion($question);\n                $id = array_search($backup_name, $backups_names, true);\n\n                $io->newLine();\n                $io->note('Selected backup: ' . $backup_name);\n            } else {\n                $id = 0;\n            }\n        }\n\n        $backup = $backups::backup($id, function($args) { $this->outputProgress($args); });\n\n        $io->newline(2);\n        $io->success('Backup Successfully Created: ' . $backup);\n\n        return 0;\n    }\n\n    /**\n     * @param array $args\n     * @return void\n     */\n    public function outputProgress(array $args): void\n    {\n        switch ($args['type']) {\n            case 'count':\n                $steps = $args['steps'];\n                $freq = (int)($steps > 100 ? round($steps / 100) : $steps);\n                $this->progress->setMaxSteps($steps);\n                $this->progress->setRedrawFrequency($freq);\n                $this->progress->setMessage('Adding files...');\n                break;\n            case 'message':\n                $this->progress->setMessage($args['message']);\n                $this->progress->display();\n                break;\n            case 'progress':\n                if (isset($args['complete']) && $args['complete']) {\n                    $this->progress->finish();\n                } else {\n                    $this->progress->advance();\n                }\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/CacheCleanupCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse DirectoryIterator;\nuse Exception;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\GravCommand;\nuse RecursiveDirectoryIterator;\nuse RecursiveIteratorIterator;\nuse Symfony\\Component\\Console\\Input\\InputOption;\n\n/**\n * Class CacheCleanupCommand\n * @package Grav\\Console\\Cli\n */\nclass CacheCleanupCommand extends GravCommand\n{\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('cache-cleanup')\n            ->setAliases(['cleanup'])\n            ->setDescription('Removes orphaned cache directories that are no longer in use')\n            ->addOption('force', 'f', InputOption::VALUE_NONE, 'Actually delete orphaned caches (dry run without this)')\n            ->addOption('max-age', 'd', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N days', '1')\n            ->addOption('max-age-weeks', 'w', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N weeks')\n            ->addOption('max-age-months', 'm', InputOption::VALUE_REQUIRED, 'Delete orphaned caches older than N months')\n            ->setHelp(<<<'EOF'\nThe <info>cache-cleanup</info> command removes orphaned cache directories that are no longer in use.\nOnly keeps the current cache key directory.\n\n<comment>Dry run (shows what would be deleted):</comment>\n  <info>bin/grav cache-cleanup</info>\n\n<comment>Actually delete orphaned caches:</comment>\n  <info>bin/grav cache-cleanup --force</info>\n\n<comment>Delete orphaned caches older than 7 days:</comment>\n  <info>bin/grav cache-cleanup --force --max-age=7</info>\n\n<comment>Delete orphaned caches older than 2 weeks:</comment>\n  <info>bin/grav cache-cleanup --force --max-age-weeks=2</info>\n\n<comment>Delete orphaned caches older than 1 month:</comment>\n  <info>bin/grav cache-cleanup --force --max-age-months=1</info>\n\n<comment>Cron example (run daily at 3am):</comment>\n  <info>0 3 * * * /path/to/grav/bin/grav cache-cleanup --force >> /var/log/grav-cache-cleanup.log 2>&1</info>\nEOF\n            );\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $this->initializeGrav();\n\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $force = $input->getOption('force');\n        $maxAge = $this->calculateMaxAgeDays();\n        $maxAgeSeconds = $maxAge * 86400;\n\n        $grav = Grav::instance();\n        $cache = $grav['cache'];\n        $currentKey = $cache->getKey();\n\n        // Extract just the uniqueness part (after the prefix and dash)\n        $currentUniqueness = substr($currentKey, strpos($currentKey, '-') + 1);\n\n        $io->title('Grav Cache Cleanup');\n        $io->writeln(\"Current cache key: <info>{$currentKey}</info>\");\n        $io->writeln(\"Current uniqueness: <info>{$currentUniqueness}</info>\");\n        $io->writeln(\"Max age for orphaned caches: <info>{$maxAge} day(s)</info>\");\n        $io->writeln('Mode: ' . ($force ? '<red>FORCE (will delete)</red>' : '<yellow>DRY RUN (use --force to delete)</yellow>'));\n        $io->newLine();\n\n        $cacheDir = GRAV_ROOT . '/cache';\n\n        if (!is_dir($cacheDir)) {\n            $io->error(\"Cache directory not found: {$cacheDir}\");\n            return 1;\n        }\n\n        $now = time();\n        $totalDeleted = 0;\n        $totalSize = 0;\n        $keptCount = 0;\n        $skippedCount = 0;\n\n        // Directories that contain cache key subdirectories (8-char hex)\n        $cacheKeyDirs = [\n            $cacheDir . '/doctrine',\n            $cacheDir . '/grav',\n        ];\n\n        foreach ($cacheKeyDirs as $scanDir) {\n            if (!is_dir($scanDir)) {\n                if ($io->isVerbose()) {\n                    $io->writeln(\"Skipping (not found): {$scanDir}\");\n                }\n                continue;\n            }\n\n            $io->writeln(\"Scanning: <cyan>{$scanDir}</cyan>\");\n            $iterator = new DirectoryIterator($scanDir);\n\n            foreach ($iterator as $file) {\n                if ($file->isDot() || !$file->isDir()) {\n                    continue;\n                }\n\n                $dirName = $file->getBasename();\n                $dirPath = $file->getPathname();\n\n                // Only process directories that look like cache keys (8-char hex)\n                if (!preg_match('/^[a-f0-9]{8}$/', $dirName)) {\n                    if ($io->isVerbose()) {\n                        $io->writeln(\"[SKIP] {$dirName} (not a cache key directory)\");\n                    }\n                    continue;\n                }\n\n                $dirAge = $now - $file->getMTime();\n                $dirAgeDays = round($dirAge / 86400, 1);\n\n                // Get directory size\n                $size = $this->getDirectorySize($dirPath);\n                $sizeFormatted = $this->formatBytes($size);\n\n                if ($dirName === $currentUniqueness) {\n                    $io->writeln(\"<green>[KEEP]</green> {$dirName} (CURRENT - {$sizeFormatted})\");\n                    $keptCount++;\n                    continue;\n                }\n\n                // Check if old enough to delete\n                if ($dirAge < $maxAgeSeconds) {\n                    $io->writeln(\"<yellow>[SKIP]</yellow> {$dirName} (only {$dirAgeDays} days old, waiting for {$maxAge} days - {$sizeFormatted})\");\n                    $skippedCount++;\n                    continue;\n                }\n\n                $io->writeln(\"<red>[DELETE]</red> {$dirName} ({$dirAgeDays} days old - {$sizeFormatted})\");\n\n                if ($force) {\n                    try {\n                        Folder::delete($dirPath);\n                        $totalDeleted++;\n                        $totalSize += $size;\n                        if ($io->isVerbose()) {\n                            $io->writeln('  -> Deleted successfully');\n                        }\n                    } catch (Exception $e) {\n                        $io->writeln('  -> <red>ERROR:</red> ' . $e->getMessage());\n                    }\n                } else {\n                    $totalDeleted++;\n                    $totalSize += $size;\n                }\n            }\n        }\n\n        $io->newLine();\n        $io->section('Summary');\n        $io->writeln(\"Current cache kept: <green>{$keptCount}</green>\");\n        $io->writeln(\"Orphaned caches skipped (too new): <yellow>{$skippedCount}</yellow>\");\n\n        if ($force) {\n            $io->writeln(\"Orphaned caches deleted: <red>{$totalDeleted}</red>\");\n            $io->writeln('Space freed: <info>' . $this->formatBytes($totalSize) . '</info>');\n        } else {\n            $io->writeln(\"Orphaned caches to delete: <red>{$totalDeleted}</red>\");\n            $io->writeln('Space to free: <info>' . $this->formatBytes($totalSize) . '</info>');\n            if ($totalDeleted > 0) {\n                $io->newLine();\n                $io->note('Run with --force to actually delete these directories.');\n            }\n        }\n\n        return 0;\n    }\n\n    /**\n     * Calculate max age in days from the various options\n     *\n     * @return int\n     */\n    private function calculateMaxAgeDays(): int\n    {\n        $input = $this->getInput();\n\n        // Check for months first (highest priority)\n        $months = $input->getOption('max-age-months');\n        if ($months !== null) {\n            return (int)$months * 30;\n        }\n\n        // Check for weeks\n        $weeks = $input->getOption('max-age-weeks');\n        if ($weeks !== null) {\n            return (int)$weeks * 7;\n        }\n\n        // Default to days\n        return (int)$input->getOption('max-age');\n    }\n\n    /**\n     * Get directory size recursively\n     *\n     * @param string $path\n     * @return int\n     */\n    private function getDirectorySize(string $path): int\n    {\n        $size = 0;\n\n        try {\n            $iterator = new RecursiveIteratorIterator(\n                new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),\n                RecursiveIteratorIterator::LEAVES_ONLY\n            );\n\n            foreach ($iterator as $file) {\n                if ($file->isFile()) {\n                    $size += $file->getSize();\n                }\n            }\n        } catch (Exception $e) {\n            // Ignore errors, return what we have\n        }\n\n        return $size;\n    }\n\n    /**\n     * Format bytes to human readable\n     *\n     * @param int $bytes\n     * @param int $precision\n     * @return string\n     */\n    private function formatBytes(int $bytes, int $precision = 2): string\n    {\n        $units = ['B', 'KB', 'MB', 'GB', 'TB'];\n\n        for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {\n            $bytes /= 1024;\n        }\n\n        return round($bytes, $precision) . ' ' . $units[$i];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/CleanCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Common\\Filesystem\\Folder;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Formatter\\OutputFormatterStyle;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n/**\n * Class CleanCommand\n * @package Grav\\Console\\Cli\n */\nclass CleanCommand extends Command\n{\n    /** @var InputInterface */\n    protected $input;\n    /** @var SymfonyStyle */\n    protected $io;\n\n    /** @var array */\n    protected $paths_to_remove = [\n        'codeception.yml',\n        'tests/',\n        'user/plugins/admin/vendor/bacon/bacon-qr-code/tests',\n        'user/plugins/admin/vendor/bacon/bacon-qr-code/.gitignore',\n        'user/plugins/admin/vendor/bacon/bacon-qr-code/.travis.yml',\n        'user/plugins/admin/vendor/bacon/bacon-qr-code/composer.json',\n        'user/plugins/admin/vendor/robthree/twofactorauth/demo',\n        'user/plugins/admin/vendor/robthree/twofactorauth/.vs',\n        'user/plugins/admin/vendor/robthree/twofactorauth/tests',\n        'user/plugins/admin/vendor/robthree/twofactorauth/.gitignore',\n        'user/plugins/admin/vendor/robthree/twofactorauth/.travis.yml',\n        'user/plugins/admin/vendor/robthree/twofactorauth/composer.json',\n        'user/plugins/admin/vendor/robthree/twofactorauth/composer.lock',\n        'user/plugins/admin/vendor/robthree/twofactorauth/logo.png',\n        'user/plugins/admin/vendor/robthree/twofactorauth/multifactorauthforeveryone.png',\n        'user/plugins/admin/vendor/robthree/twofactorauth/TwoFactorAuth.phpproj',\n        'user/plugins/admin/vendor/robthree/twofactorauth/TwoFactorAuth.sin',\n        'user/plugins/admin/vendor/zendframework/zendxml/tests',\n        'user/plugins/admin/vendor/zendframework/zendxml/.gitignore',\n        'user/plugins/admin/vendor/zendframework/zendxml/.travis.yml',\n        'user/plugins/admin/vendor/zendframework/zendxml/composer.json',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/.travis.yml',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/build.xml',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/composer.json',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/create_pear_package.php',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/package.xml.tpl',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/.gitattributes',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/.gitignore',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/README.git',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/tests',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/test-suite',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/notes',\n        'user/plugins/email/vendor/swiftmailer/swiftmailer/doc',\n        'user/themes/antimatter/.sass-cache',\n        'vendor/antoligy/dom-string-iterators/composer.json',\n        'vendor/composer/ca-bundle/composer.json',\n        'vendor/composer/ca-bundle/phpstan.neon.dist',\n        'vendor/composer/semver/CHANGELOG.md',\n        'vendor/composer/semver/composer.json',\n        'vendor/composer/semver/phpstan.neon.dist',\n        'vendor/doctrine/cache/.travis.yml',\n        'vendor/doctrine/cache/build.properties',\n        'vendor/doctrine/cache/build.xml',\n        'vendor/doctrine/cache/composer.json',\n        'vendor/doctrine/cache/phpunit.xml.dist',\n        'vendor/doctrine/cache/.coveralls.yml',\n        'vendor/doctrine/cache/.gitignore',\n        'vendor/doctrine/cache/.git',\n        'vendor/doctrine/cache/tests',\n        'vendor/doctrine/cache/UPGRADE.md',\n        'vendor/doctrine/collections/docs',\n        'vendor/doctrine/collections/.doctrine-project.json',\n        'vendor/doctrine/collections/CONTRIBUTING.md',\n        'vendor/doctrine/collections/psalm.xml.dist',\n        'vendor/doctrine/collections/composer.json',\n        'vendor/doctrine/collections/phpunit.xml.dist',\n        'vendor/doctrine/collections/tests',\n        'vendor/donatj/phpuseragentparser/.git',\n        'vendor/donatj/phpuseragentparser/.github',\n        'vendor/donatj/phpuseragentparser/.gitignore',\n        'vendor/donatj/phpuseragentparser/.editorconfig',\n        'vendor/donatj/phpuseragentparser/.travis.yml',\n        'vendor/donatj/phpuseragentparser/composer.json',\n        'vendor/donatj/phpuseragentparser/phpunit.xml.dist',\n        'vendor/donatj/phpuseragentparser/tests',\n        'vendor/donatj/phpuseragentparser/Tools',\n        'vendor/donatj/phpuseragentparser/CONTRIBUTING.md',\n        'vendor/donatj/phpuseragentparser/Makefile',\n        'vendor/donatj/phpuseragentparser/.mddoc.xml',\n        'vendor/dragonmantank/cron-expression/.editorconfig',\n        'vendor/dragonmantank/cron-expression/composer.json',\n        'vendor/dragonmantank/cron-expression/tests',\n        'vendor/dragonmantank/cron-expression/CHANGELOG.md',\n        'vendor/rhukster/dom-sanitizer/tests',\n        'vendor/rhukster/dom-sanitizer/.gitignore',\n        'vendor/rhukster/dom-sanitizer/composer.json',\n        'vendor/rhukster/dom-sanitizer/composer.lock',\n        'vendor/erusev/parsedown/composer.json',\n        'vendor/erusev/parsedown/phpunit.xml.dist',\n        'vendor/erusev/parsedown/.travis.yml',\n        'vendor/erusev/parsedown/.git',\n        'vendor/erusev/parsedown/test',\n        'vendor/erusev/parsedown-extra/composer.json',\n        'vendor/erusev/parsedown-extra/phpunit.xml.dist',\n        'vendor/erusev/parsedown-extra/.travis.yml',\n        'vendor/erusev/parsedown-extra/.git',\n        'vendor/erusev/parsedown-extra/test',\n        'vendor/filp/whoops/composer.json',\n        'vendor/filp/whoops/docs',\n        'vendor/filp/whoops/examples',\n        'vendor/filp/whoops/tests',\n        'vendor/filp/whoops/.git',\n        'vendor/filp/whoops/.github',\n        'vendor/filp/whoops/.gitignore',\n        'vendor/filp/whoops/.scrutinizer.yml',\n        'vendor/filp/whoops/.travis.yml',\n        'vendor/filp/whoops/phpunit.xml.dist',\n        'vendor/filp/whoops/CHANGELOG.md',\n        'vendor/gregwar/image/Gregwar/Image/composer.json',\n        'vendor/gregwar/image/Gregwar/Image/phpunit.xml',\n        'vendor/gregwar/image/Gregwar/Image/phpunit.xml.dist',\n        'vendor/gregwar/image/Gregwar/Image/Makefile',\n        'vendor/gregwar/image/Gregwar/Image/.editorconfig',\n        'vendor/gregwar/image/Gregwar/Image/.php_cs',\n        'vendor/gregwar/image/Gregwar/Image/.styleci.yml',\n        'vendor/gregwar/image/Gregwar/Image/.travis.yml',\n        'vendor/gregwar/image/Gregwar/Image/.gitignore',\n        'vendor/gregwar/image/Gregwar/Image/.git',\n        'vendor/gregwar/image/Gregwar/Image/doc',\n        'vendor/gregwar/image/Gregwar/Image/demo',\n        'vendor/gregwar/image/Gregwar/Image/tests',\n        'vendor/gregwar/cache/Gregwar/Cache/composer.json',\n        'vendor/gregwar/cache/Gregwar/Cache/phpunit.xml',\n        'vendor/gregwar/cache/Gregwar/Cache/.travis.yml',\n        'vendor/gregwar/cache/Gregwar/Cache/.gitignore',\n        'vendor/gregwar/cache/Gregwar/Cache/.git',\n        'vendor/gregwar/cache/Gregwar/Cache/demo',\n        'vendor/gregwar/cache/Gregwar/Cache/tests',\n        'vendor/guzzlehttp/psr7/composer.json',\n        'vendor/guzzlehttp/psr7/.editorconfig',\n        'vendor/guzzlehttp/psr7/CHANGELOG.md',\n        'vendor/itsgoingd/clockwork/.gitattributes',\n        'vendor/itsgoingd/clockwork/CHANGELOG.md',\n        'vendor/itsgoingd/clockwork/composer.json',\n        'vendor/league/climate/composer.json',\n        'vendor/league/climate/CHANGELOG.md',\n        'vendor/league/climate/CONTRIBUTING.md',\n        'vendor/league/climate/Dockerfile',\n        'vendor/league/climate/CODE_OF_CONDUCT.md',\n        'vendor/matthiasmullie/minify/.github',\n        'vendor/matthiasmullie/minify/bin',\n        'vendor/matthiasmullie/minify/composer.json',\n        'vendor/matthiasmullie/minify/docker-compose.yml',\n        'vendor/matthiasmullie/minify/Dockerfile',\n        'vendor/matthiasmullie/minify/CONTRIBUTING.md',\n        'vendor/matthiasmullie/path-converter/composer.json',\n        'vendor/maximebf/debugbar/.github',\n        'vendor/maximebf/debugbar/bower.json',\n        'vendor/maximebf/debugbar/composer.json',\n        'vendor/maximebf/debugbar/.bowerrc',\n        'vendor/maximebf/debugbar/src/DebugBar/Resources/vendor',\n        'vendor/maximebf/debugbar/demo',\n        'vendor/maximebf/debugbar/docs',\n        'vendor/maximebf/debugbar/tests',\n        'vendor/maximebf/debugbar/phpunit.xml.dist',\n        'vendor/miljar/php-exif/.coveralls.yml',\n        'vendor/miljar/php-exif/.gitignore',\n        'vendor/miljar/php-exif/.travis.yml',\n        'vendor/miljar/php-exif/composer.json',\n        'vendor/miljar/php-exif/composer.lock',\n        'vendor/miljar/php-exif/phpunit.xml.dist',\n        'vendor/miljar/php-exif/Resources',\n        'vendor/miljar/php-exif/tests',\n        'vendor/miljar/php-exif/CHANGELOG.rst',\n        'vendor/monolog/monolog/composer.json',\n        'vendor/monolog/monolog/doc',\n        'vendor/monolog/monolog/phpunit.xml.dist',\n        'vendor/monolog/monolog/.php_cs',\n        'vendor/monolog/monolog/tests',\n        'vendor/monolog/monolog/CHANGELOG.md',\n        'vendor/monolog/monolog/phpstan.neon.dist',\n        'vendor/nyholm/psr7/composer.json',\n        'vendor/nyholm/psr7/phpstan.neon.dist',\n        'vendor/nyholm/psr7/CHANGELOG.md',\n        'vendor/nyholm/psr7/psalm.xml',\n        'vendor/nyholm/psr7-server/.github',\n        'vendor/nyholm/psr7-server/composer.json',\n        'vendor/nyholm/psr7-server/CHANGELOG.md',\n        'vendor/phive/twig-extensions-deferred/.gitignore',\n        'vendor/phive/twig-extensions-deferred/.travis.yml',\n        'vendor/phive/twig-extensions-deferred/composer.json',\n        'vendor/phive/twig-extensions-deferred/phpunit.xml.dist',\n        'vendor/phive/twig-extensions-deferred/tests',\n        'vendor/php-http/message-factory/composer.json',\n        'vendor/php-http/message-factory/puli.json',\n        'vendor/php-http/message-factory/CHANGELOG.md',\n        'vendor/pimple/pimple/.gitignore',\n        'vendor/pimple/pimple/.travis.yml',\n        'vendor/pimple/pimple/composer.json',\n        'vendor/pimple/pimple/ext',\n        'vendor/pimple/pimple/phpunit.xml.dist',\n        'vendor/pimple/pimple/src/Pimple/Tests',\n        'vendor/pimple/pimple/.php_cs.dist',\n        'vendor/pimple/pimple/CHANGELOG',\n        'vendor/psr/cache/CHANGELOG.md',\n        'vendor/psr/cache/composer.json',\n        'vendor/psr/container/composer.json',\n        'vendor/psr/container/.gitignore',\n        'vendor/psr/http-factory/.gitignore',\n        'vendor/psr/http-factory/.pullapprove.yml',\n        'vendor/psr/http-factory/composer.json',\n        'vendor/psr/http-message/composer.json',\n        'vendor/psr/http-message/CHANGELOG.md',\n        'vendor/psr/http-server-handler/composer.json',\n        'vendor/psr/http-server-middleware/composer.json',\n        'vendor/psr/simple-cache/.editorconfig',\n        'vendor/psr/simple-cache/composer.json',\n        'vendor/psr/log/composer.json',\n        'vendor/psr/log/.gitignore',\n        'vendor/ralouphie/getallheaders/.gitignore',\n        'vendor/ralouphie/getallheaders/.travis.yml',\n        'vendor/ralouphie/getallheaders/composer.json',\n        'vendor/ralouphie/getallheaders/phpunit.xml',\n        'vendor/ralouphie/getallheaders/tests/',\n        'vendor/rockettheme/toolbox/.git',\n        'vendor/rockettheme/toolbox/.gitignore',\n        'vendor/rockettheme/toolbox/.scrutinizer.yml',\n        'vendor/rockettheme/toolbox/.travis.yml',\n        'vendor/rockettheme/toolbox/composer.json',\n        'vendor/rockettheme/toolbox/phpunit.xml',\n        'vendor/rockettheme/toolbox/CHANGELOG.md',\n        'vendor/rockettheme/toolbox/Blueprints/tests',\n        'vendor/rockettheme/toolbox/ResourceLocator/tests',\n        'vendor/rockettheme/toolbox/Session/tests',\n        'vendor/rockettheme/toolbox/tests',\n        'vendor/seld/cli-prompt/composer.json',\n        'vendor/seld/cli-prompt/.gitignore',\n        'vendor/seld/cli-prompt/.github',\n        'vendor/seld/cli-prompt/phpstan.neon.dist',\n        'vendor/symfony/console/composer.json',\n        'vendor/symfony/console/phpunit.xml.dist',\n        'vendor/symfony/console/.gitignore',\n        'vendor/symfony/console/.git',\n        'vendor/symfony/console/Tester',\n        'vendor/symfony/console/Tests',\n        'vendor/symfony/console/CHANGELOG.md',\n        'vendor/symfony/contracts/Cache/.gitignore',\n        'vendor/symfony/contracts/Cache/composer.json',\n        'vendor/symfony/contracts/EventDispatcher/.gitignore',\n        'vendor/symfony/contracts/EventDispatcher/composer.json',\n        'vendor/symfony/contracts/HttpClient/.gitignore',\n        'vendor/symfony/contracts/HttpClient/composer.json',\n        'vendor/symfony/contracts/HttpClient/Test',\n        'vendor/symfony/contracts/Service/.gitignore',\n        'vendor/symfony/contracts/Service/composer.json',\n        'vendor/symfony/contracts/Service/Test',\n        'vendor/symfony/contracts/Tests',\n        'vendor/symfony/contracts/Translation/.gitignore',\n        'vendor/symfony/contracts/Translation/composer.json',\n        'vendor/symfony/contracts/Translation/Test',\n        'vendor/symfony/contracts/.gitignore',\n        'vendor/symfony/contracts/composer.json',\n        'vendor/symfony/contracts/phpunit.xml.dist',\n        'vendor/symfony/event-dispatcher/.git',\n        'vendor/symfony/event-dispatcher/.gitignore',\n        'vendor/symfony/event-dispatcher/composer.json',\n        'vendor/symfony/event-dispatcher/phpunit.xml.dist',\n        'vendor/symfony/event-dispatcher/Tests',\n        'vendor/symfony/event-dispatcher/CHANGELOG.md',\n        'vendor/symfony/http-client/CHANGELOG.md',\n        'vendor/symfony/http-client/composer.json',\n        'vendor/symfony/polyfill-ctype/composer.json',\n        'vendor/symfony/polyfill-iconv/.git',\n        'vendor/symfony/polyfill-iconv/.gitignore',\n        'vendor/symfony/polyfill-iconv/composer.json',\n        'vendor/symfony/polyfill-mbstring/.git',\n        'vendor/symfony/polyfill-mbstring/.gitignore',\n        'vendor/symfony/polyfill-mbstring/composer.json',\n        'vendor/symfony/polyfill-php72/composer.json',\n        'vendor/symfony/polyfill-php73/composer.json',\n        'vendor/symfony/process/.gitignore',\n        'vendor/symfony/process/composer.json',\n        'vendor/symfony/process/phpunit.xml.dist',\n        'vendor/symfony/process/Tests',\n        'vendor/symfony/process/CHANGELOG.md',\n        'vendor/symfony/var-dumper/.git',\n        'vendor/symfony/var-dumper/.gitignore',\n        'vendor/symfony/var-dumper/composer.json',\n        'vendor/symfony/var-dumper/phpunit.xml.dist',\n        'vendor/symfony/var-dumper/Test',\n        'vendor/symfony/var-dumper/Tests',\n        'vendor/symfony/var-dumper/CHANGELOG.md',\n        'vendor/symfony/yaml/composer.json',\n        'vendor/symfony/yaml/phpunit.xml.dist',\n        'vendor/symfony/yaml/.gitignore',\n        'vendor/symfony/yaml/.git',\n        'vendor/symfony/yaml/Tests',\n        'vendor/symfony/yaml/CHANGELOG.md',\n        'vendor/twig/twig/.editorconfig',\n        'vendor/twig/twig/.php_cs.dist',\n        'vendor/twig/twig/.travis.yml',\n        'vendor/twig/twig/.gitignore',\n        'vendor/twig/twig/.git',\n        'vendor/twig/twig/.github',\n        'vendor/twig/twig/composer.json',\n        'vendor/twig/twig/phpunit.xml.dist',\n        'vendor/twig/twig/doc',\n        'vendor/twig/twig/ext',\n        'vendor/twig/twig/test',\n        'vendor/twig/twig/.gitattributes',\n        'vendor/twig/twig/CHANGELOG',\n        'vendor/twig/twig/drupal_test.sh',\n        'vendor/willdurand/negotiation/.gitignore',\n        'vendor/willdurand/negotiation/.travis.yml',\n        'vendor/willdurand/negotiation/appveyor.yml',\n        'vendor/willdurand/negotiation/composer.json',\n        'vendor/willdurand/negotiation/phpunit.xml.dist',\n        'vendor/willdurand/negotiation/tests',\n        'vendor/willdurand/negotiation/CONTRIBUTING.md',\n        'user/config/security.yaml',\n        'cache/compiled/',\n    ];\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('clean')\n            ->setDescription('Handles cleaning chores for Grav distribution')\n            ->setHelp('The <info>clean</info> clean extraneous folders and data');\n    }\n\n    /**\n     * @param InputInterface  $input\n     * @param OutputInterface $output\n     * @return int\n     */\n    protected function execute(InputInterface $input, OutputInterface $output): int\n    {\n        $this->setupConsole($input, $output);\n\n        return $this->cleanPaths() ? 0 : 1;\n    }\n\n    /**\n     * @return bool\n     */\n    private function cleanPaths(): bool\n    {\n        $success = true;\n\n        $this->io->writeln('');\n        $this->io->writeln('<red>DELETING</red>');\n        $anything = false;\n        foreach ($this->paths_to_remove as $path) {\n            $path = GRAV_ROOT . DS . $path;\n            try {\n                if (is_dir($path) && Folder::delete($path)) {\n                    $anything = true;\n                    $this->io->writeln('<red>dir:  </red>' . $path);\n                } elseif (is_file($path) && @unlink($path)) {\n                    $anything = true;\n                    $this->io->writeln('<red>file: </red>' . $path);\n                }\n            } catch (\\Exception $e) {\n                $success = false;\n                $this->io->error(sprintf('Failed to delete %s: %s', $path, $e->getMessage()));\n            }\n        }\n        if (!$anything) {\n            $this->io->writeln('');\n            $this->io->writeln('<green>Nothing to clean...</green>');\n        }\n\n        return $success;\n    }\n\n    /**\n     * Set colors style definition for the formatter.\n     *\n     * @param InputInterface  $input\n     * @param OutputInterface $output\n     * @return void\n     */\n    public function setupConsole(InputInterface $input, OutputInterface $output): void\n    {\n        $this->input = $input;\n        $this->io = new SymfonyStyle($input, $output);\n\n        $this->io->getFormatter()->setStyle('normal', new OutputFormatterStyle('white'));\n        $this->io->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold']));\n        $this->io->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, ['bold']));\n        $this->io->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold']));\n        $this->io->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, ['bold']));\n        $this->io->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold']));\n        $this->io->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, ['bold']));\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/ClearCacheCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Common\\Cache;\nuse Grav\\Console\\GravCommand;\nuse Symfony\\Component\\Console\\Input\\InputOption;\n\n/**\n * Class ClearCacheCommand\n * @package Grav\\Console\\Cli\n */\nclass ClearCacheCommand extends GravCommand\n{\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('cache')\n            ->setAliases(['clearcache', 'cache-clear'])\n            ->setDescription('Clears Grav cache')\n            ->addOption('invalidate', null, InputOption::VALUE_NONE, 'Invalidate cache, but do not remove any files')\n            ->addOption('purge', null, InputOption::VALUE_NONE, 'If set purge old caches')\n            ->addOption('all', null, InputOption::VALUE_NONE, 'If set will remove all including compiled, twig, doctrine caches')\n            ->addOption('assets-only', null, InputOption::VALUE_NONE, 'If set will remove only assets/*')\n            ->addOption('images-only', null, InputOption::VALUE_NONE, 'If set will remove only images/*')\n            ->addOption('cache-only', null, InputOption::VALUE_NONE, 'If set will remove only cache/*')\n            ->addOption('tmp-only', null, InputOption::VALUE_NONE, 'If set will remove only tmp/*')\n\n            ->setHelp('The <info>cache</info> command allows you to interact with Grav cache');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        // Old versions of Grav called this command after grav upgrade.\n        // We need make this command to work with older GravCommand instance:\n        if (!method_exists($this, 'initializePlugins')) {\n            Cache::clearCache('all');\n\n            return 0;\n        }\n\n        $this->initializePlugins();\n        $this->cleanPaths();\n\n        return 0;\n    }\n\n    /**\n     * loops over the array of paths and deletes the files/folders\n     *\n     * @return void\n     */\n    private function cleanPaths(): void\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $io->newLine();\n\n        if ($input->getOption('purge')) {\n            $io->writeln('<magenta>Purging old cache</magenta>');\n            $io->newLine();\n\n            $msg = Cache::purgeJob();\n            $io->writeln($msg);\n        } else {\n            $io->writeln('<magenta>Clearing cache</magenta>');\n            $io->newLine();\n\n            if ($input->getOption('all')) {\n                $remove = 'all';\n            } elseif ($input->getOption('assets-only')) {\n                $remove = 'assets-only';\n            } elseif ($input->getOption('images-only')) {\n                $remove = 'images-only';\n            } elseif ($input->getOption('cache-only')) {\n                $remove = 'cache-only';\n            } elseif ($input->getOption('tmp-only')) {\n                $remove = 'tmp-only';\n            } elseif ($input->getOption('invalidate')) {\n                $remove = 'invalidate';\n            } else {\n                $remove = 'standard';\n            }\n\n            foreach (Cache::clearCache($remove) as $result) {\n                $io->writeln($result);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/ComposerCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Console\\GravCommand;\nuse Symfony\\Component\\Console\\Input\\InputOption;\n\n/**\n * Class ComposerCommand\n * @package Grav\\Console\\Cli\n */\nclass ComposerCommand extends GravCommand\n{\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('composer')\n            ->addOption(\n                'install',\n                'i',\n                InputOption::VALUE_NONE,\n                'install the dependencies'\n            )\n            ->addOption(\n                'update',\n                'u',\n                InputOption::VALUE_NONE,\n                'update the dependencies'\n            )\n            ->setDescription('Updates the composer vendor dependencies needed by Grav.')\n            ->setHelp('The <info>composer</info> command updates the composer vendor dependencies needed by Grav');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $action = $input->getOption('install') ? 'install' : ($input->getOption('update') ? 'update' : 'install');\n\n        if ($input->getOption('install')) {\n            $action = 'install';\n        }\n\n        // Updates composer first\n        $io->writeln(\"\\nInstalling vendor dependencies\");\n        $io->writeln($this->composerUpdate(GRAV_ROOT, $action));\n\n        return 0;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/InstallCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Console\\GravCommand;\nuse Grav\\Framework\\File\\Formatter\\JsonFormatter;\nuse Grav\\Framework\\File\\JsonFile;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse function is_array;\n\n/**\n * Class InstallCommand\n * @package Grav\\Console\\Cli\n */\nclass InstallCommand extends GravCommand\n{\n    /** @var array */\n    protected $config;\n    /** @var string */\n    protected $destination;\n    /** @var string */\n    protected $user_path;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('install')\n            ->addOption(\n                'symlink',\n                's',\n                InputOption::VALUE_NONE,\n                'Symlink the required bits'\n            )\n            ->addOption(\n                'plugin',\n                'p',\n                InputOption::VALUE_REQUIRED,\n                'Install plugin (symlink)'\n            )\n            ->addOption(\n                'theme',\n                't',\n                InputOption::VALUE_REQUIRED,\n                'Install theme (symlink)'\n            )\n            ->addArgument(\n                'destination',\n                InputArgument::OPTIONAL,\n                'Where to install the required bits (default to current project)'\n            )\n            ->setDescription('Installs the dependencies needed by Grav. Optionally can create symbolic links')\n            ->setHelp('The <info>install</info> command installs the dependencies needed by Grav. Optionally can create symbolic links');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $dependencies_file = '.dependencies';\n        $this->destination = $input->getArgument('destination') ?: GRAV_WEBROOT;\n\n        // fix trailing slash\n        $this->destination = rtrim($this->destination, DS) . DS;\n        $this->user_path = $this->destination . GRAV_USER_PATH . DS;\n        if ($local_config_file = $this->loadLocalConfig()) {\n            $io->writeln('Read local config from <cyan>' . $local_config_file . '</cyan>');\n        }\n\n        // Look for dependencies file in ROOT and USER dir\n        if (file_exists($this->user_path . $dependencies_file)) {\n            $file = YamlFile::instance($this->user_path . $dependencies_file);\n        } elseif (file_exists($this->destination . $dependencies_file)) {\n            $file = YamlFile::instance($this->destination . $dependencies_file);\n        } else {\n            $io->writeln('<red>ERROR</red> Missing .dependencies file in <cyan>user/</cyan> folder');\n            if ($input->getArgument('destination')) {\n                $io->writeln('<yellow>HINT</yellow> <info>Are you trying to install a plugin or a theme? Make sure you use <cyan>bin/gpm install <something></cyan>, not <cyan>bin/grav install</cyan>. This command is only used to install Grav skeletons.');\n            } else {\n                $io->writeln('<yellow>HINT</yellow> <info>Are you trying to install Grav? Grav is already installed. You need to run this command only if you download a skeleton from GitHub directly.');\n            }\n\n            return 1;\n        }\n\n        $this->config = $file->content();\n        $file->free();\n\n        // If no config, fail.\n        if (!$this->config) {\n            $io->writeln('<red>ERROR</red> invalid YAML in ' . $dependencies_file);\n\n            return 1;\n        }\n\n        $plugin = $input->getOption('plugin');\n        $theme = $input->getOption('theme');\n        $name = $plugin ?? $theme;\n        $symlink = $name || $input->getOption('symlink');\n\n        if (!$symlink) {\n            // Updates composer first\n            $io->writeln(\"\\nInstalling vendor dependencies\");\n            $io->writeln($this->composerUpdate(GRAV_ROOT, 'install'));\n\n            $error = $this->gitclone();\n        } else {\n            $type = $name ? ($plugin ? 'plugin' : 'theme') : null;\n\n            $error = $this->symlink($name, $type);\n        }\n\n        return $error;\n    }\n\n    /**\n     * Clones from Git\n     *\n     * @return int\n     */\n    private function gitclone(): int\n    {\n        $io = $this->getIO();\n\n        $io->newLine();\n        $io->writeln('<green>Cloning Bits</green>');\n        $io->writeln('============');\n        $io->newLine();\n\n        $error = 0;\n        $this->destination = rtrim($this->destination, DS);\n        foreach ($this->config['git'] as $repo => $data) {\n            $path = $this->destination . DS . $data['path'];\n            if (!file_exists($path)) {\n                exec('cd ' . escapeshellarg($this->destination) . ' && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return);\n\n                if (!$return) {\n                    $io->writeln('<green>SUCCESS</green> cloned <magenta>' . $data['url'] . '</magenta> -> <cyan>' . $path . '</cyan>');\n                } else {\n                    $io->writeln('<red>ERROR</red> cloning <magenta>' . $data['url']);\n                    $error = 1;\n                }\n\n                $io->newLine();\n            } else {\n                $io->writeln('<yellow>' . $path . ' already exists, skipping...</yellow>');\n                $io->newLine();\n            }\n        }\n\n        return $error;\n    }\n\n    /**\n     * Symlinks\n     *\n     * @param string|null $name\n     * @param string|null $type\n     * @return int\n     */\n    private function symlink(string $name = null, string $type = null): int\n    {\n        $io = $this->getIO();\n\n        $io->newLine();\n        $io->writeln('<green>Symlinking Bits</green>');\n        $io->writeln('===============');\n        $io->newLine();\n\n        if (!$this->local_config) {\n            $io->writeln('<red>No local configuration available, aborting...</red>');\n            $io->newLine();\n\n            return 1;\n        }\n\n        $error = 0;\n        $this->destination = rtrim($this->destination, DS);\n\n        if ($name) {\n            $src = \"grav-{$type}-{$name}\";\n            $links = [\n                $name => [\n                    'scm' => 'github', // TODO: make configurable\n                    'src' => $src,\n                    'path' => \"user/{$type}s/{$name}\"\n                ]\n            ];\n        } else {\n            $links = $this->config['links'];\n        }\n\n        foreach ($links as $name => $data) {\n            $scm = $data['scm'] ?? null;\n            $src = $data['src'] ?? null;\n            $path = $data['path'] ?? null;\n            if (!isset($scm, $src, $path)) {\n                $io->writeln(\"<red>Dependency '$name' has broken configuration, skipping...</red>\");\n                $io->newLine();\n                $error = 1;\n\n                continue;\n            }\n\n            $locations = (array) $this->local_config[\"{$scm}_repos\"];\n            $to = $this->destination . DS . $path;\n\n            $from = null;\n            foreach ($locations as $location) {\n                $test = rtrim($location, '\\\\/') . DS . $src;\n                if (file_exists($test)) {\n                    $from = $test;\n                    continue;\n                }\n            }\n\n            if (is_link($to) && !realpath($to)) {\n                $io->writeln('<yellow>Removed broken symlink '. $path .'</yellow>');\n                unlink($to);\n            }\n            if (null === $from) {\n                $io->writeln('<red>source for ' . $src . ' does not exists, skipping...</red>');\n                $io->newLine();\n                $error = 1;\n            } elseif (!file_exists($to)) {\n                $error = $this->addSymlinks($from, $to, ['name' => $name, 'src' => $src, 'path' => $path]);\n                $io->newLine();\n            } else {\n                $io->writeln('<yellow>destination: ' . $path . ' already exists, skipping...</yellow>');\n                $io->newLine();\n            }\n        }\n\n        return $error;\n    }\n\n    private function addSymlinks(string $from, string $to, array $options): int\n    {\n        $io = $this->getIO();\n\n        $hebe = $this->readHebe($from);\n        if (null === $hebe) {\n            symlink($from, $to);\n\n            $io->writeln('<green>SUCCESS</green> symlinked <magenta>' . $options['src'] . '</magenta> -> <cyan>' . $options['path'] . '</cyan>');\n        } else {\n            $to = GRAV_ROOT;\n            $name = $options['name'];\n            $io->writeln(\"Processing <magenta>{$name}</magenta>\");\n            foreach ($hebe as $section => $symlinks) {\n                foreach ($symlinks as $symlink) {\n                    $src = trim($symlink['source'], '/');\n                    $dst = trim($symlink['destination'], '/');\n                    $s = \"{$from}/{$src}\";\n                    $d = \"{$to}/{$dst}\";\n\n                    if (is_link($d) && !realpath($d)) {\n                        unlink($d);\n                        $io->writeln('    <yellow>Removed broken symlink '. $dst .'</yellow>');\n                    }\n                    if (!file_exists($d)) {\n                        symlink($s, $d);\n                        $io->writeln('    symlinked <magenta>' . $src . '</magenta> -> <cyan>' . $dst . '</cyan>');\n                    }\n                }\n            }\n            $io->writeln('<green>SUCCESS</green>');\n        }\n\n        return 0;\n    }\n\n    private function readHebe(string $folder): ?array\n    {\n        $filename = \"{$folder}/hebe.json\";\n        if (!is_file($filename)) {\n            return null;\n        }\n\n        $formatter = new JsonFormatter();\n        $file = new JsonFile($filename, $formatter);\n        $hebe = $file->load();\n        $paths = $hebe['platforms']['grav']['nodes'] ?? null;\n\n        return is_array($paths) ? $paths : null;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/LogViewerCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse DateTime;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Helpers\\LogViewer;\nuse Grav\\Console\\GravCommand;\nuse Symfony\\Component\\Console\\Input\\InputOption;\n\n/**\n * Class LogViewerCommand\n * @package Grav\\Console\\Cli\n */\nclass LogViewerCommand extends GravCommand\n{\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('logviewer')\n            ->addOption(\n                'file',\n                'f',\n                InputOption::VALUE_OPTIONAL,\n                'custom log file location (default = grav.log)'\n            )\n            ->addOption(\n                'lines',\n                'l',\n                InputOption::VALUE_OPTIONAL,\n                'number of lines (default = 10)'\n            )\n            ->setDescription('Display the last few entries of Grav log')\n            ->setHelp('Display the last few entries of Grav log');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $file = $input->getOption('file') ?? 'grav.log';\n        $lines = $input->getOption('lines') ?? 20;\n        $verbose = $input->getOption('verbose') ?? false;\n\n        $io->title('Log Viewer');\n\n        $io->writeln(sprintf('viewing last %s entries in <white>%s</white>', $lines, $file));\n        $io->newLine();\n\n        $viewer = new LogViewer();\n\n        $grav = Grav::instance();\n\n        $logfile = $grav['locator']->findResource('log://' . $file);\n        if (!$logfile) {\n            $io->error('cannot find the log file: logs/' . $file);\n\n            return 1;\n        }\n\n        $rows = $viewer->objectTail($logfile, $lines, true);\n        foreach ($rows as $log) {\n            $date = $log['date'];\n            $level_color = LogViewer::levelColor($log['level']);\n\n            if ($date instanceof DateTime) {\n                $output = \"<yellow>{$log['date']->format('Y-m-d H:i:s')}</yellow> [<{$level_color}>{$log['level']}</{$level_color}>]\";\n                if ($log['trace'] && $verbose) {\n                    $output .= \" <white>{$log['message']}</white>\\n\";\n                    foreach ((array) $log['trace'] as $index => $tracerow) {\n                        $output .= \"<white>{$index}</white>{$tracerow}\\n\";\n                    }\n                } else {\n                    $output .= \" {$log['message']}\";\n                }\n                $io->writeln($output);\n            }\n        }\n\n        return 0;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/NewProjectCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Console\\GravCommand;\nuse Symfony\\Component\\Console\\Input\\ArrayInput;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\n\n/**\n * Class NewProjectCommand\n * @package Grav\\Console\\Cli\n */\nclass NewProjectCommand extends GravCommand\n{\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('new-project')\n            ->setAliases(['newproject'])\n            ->addArgument(\n                'destination',\n                InputArgument::REQUIRED,\n                'The destination directory of your new Grav project'\n            )\n            ->addOption(\n                'symlink',\n                's',\n                InputOption::VALUE_NONE,\n                'Symlink the required bits'\n            )\n            ->setDescription('Creates a new Grav project with all the dependencies installed')\n            ->setHelp(\"The <info>new-project</info> command is a combination of the `setup` and `install` commands.\\nCreates a new Grav instance and performs the installation of all the required dependencies.\");\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $io = $this->getIO();\n\n        $sandboxCommand = $this->getApplication()->find('sandbox');\n        $installCommand = $this->getApplication()->find('install');\n\n        $sandboxArguments = new ArrayInput([\n            'command'     => 'sandbox',\n            'destination' => $this->input->getArgument('destination'),\n            '-s'          => $this->input->getOption('symlink')\n        ]);\n\n        $installArguments = new ArrayInput([\n            'command'     => 'install',\n            'destination' => $this->input->getArgument('destination'),\n            '-s'          => $this->input->getOption('symlink')\n        ]);\n\n        $error = $sandboxCommand->run($sandboxArguments, $io);\n        if ($error === 0) {\n            $error = $installCommand->run($installArguments, $io);\n        }\n\n        return $error;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/PageSystemValidatorCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Console\\GravCommand;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse function in_array;\nuse function is_object;\n\n/**\n * Class PageSystemValidatorCommand\n * @package Grav\\Console\\Cli\n */\nclass PageSystemValidatorCommand extends GravCommand\n{\n    /** @var array */\n    protected $tests = [\n        // Content\n        'header' => [[]],\n        'summary' => [[], [200], [200, true]],\n        'content' => [[]],\n        'getRawContent' => [[]],\n        'rawMarkdown' => [[]],\n        'value' => [['content'], ['route'], ['order'], ['ordering'], ['folder'], ['slug'], ['name'], /*['frontmatter'],*/ ['header.menu'], ['header.slug']],\n        'title' => [[]],\n        'menu' => [[]],\n        'visible' => [[]],\n        'published' => [[]],\n        'publishDate' => [[]],\n        'unpublishDate' => [[]],\n        'process' => [[]],\n        'slug' => [[]],\n        'order' => [[]],\n        //'id' => [[]],\n        'modified' => [[]],\n        'lastModified' => [[]],\n        'folder' => [[]],\n        'date' => [[]],\n        'dateformat' => [[]],\n        'taxonomy' => [[]],\n        'shouldProcess' => [['twig'], ['markdown']],\n        'isPage' => [[]],\n        'isDir' => [[]],\n        'exists' => [[]],\n\n        // Forms\n        'forms' => [[]],\n\n        // Routing\n        'urlExtension' => [[]],\n        'routable' => [[]],\n        'link' => [[], [false], [true]],\n        'permalink' => [[]],\n        'canonical' => [[], [false], [true]],\n        'url' => [[], [true], [true, true], [true, true, false], [false, false, true, false]],\n        'route' => [[]],\n        'rawRoute' => [[]],\n        'routeAliases' => [[]],\n        'routeCanonical' => [[]],\n        'redirect' => [[]],\n        'relativePagePath' => [[]],\n        'path' => [[]],\n        //'folder' => [[]],\n        'parent' => [[]],\n        'topParent' => [[]],\n        'currentPosition' => [[]],\n        'active' => [[]],\n        'activeChild' => [[]],\n        'home' => [[]],\n        'root' => [[]],\n\n        // Translations\n        'translatedLanguages' => [[], [false], [true]],\n        'untranslatedLanguages' => [[], [false], [true]],\n        'language' => [[]],\n\n        // Legacy\n        'raw' => [[]],\n        'frontmatter' => [[]],\n        'httpResponseCode' => [[]],\n        'httpHeaders' => [[]],\n        'blueprintName' => [[]],\n        'name' => [[]],\n        'childType' => [[]],\n        'template' => [[]],\n        'templateFormat' => [[]],\n        'extension' => [[]],\n        'expires' => [[]],\n        'cacheControl' => [[]],\n        'ssl' => [[]],\n        'metadata' => [[]],\n        'eTag' => [[]],\n        'filePath' => [[]],\n        'filePathClean' => [[]],\n        'orderDir' => [[]],\n        'orderBy' => [[]],\n        'orderManual' => [[]],\n        'maxCount' => [[]],\n        'modular' => [[]],\n        'modularTwig' => [[]],\n        //'children' => [[]],\n        'isFirst' => [[]],\n        'isLast' => [[]],\n        'prevSibling' => [[]],\n        'nextSibling' => [[]],\n        'adjacentSibling' => [[]],\n        'ancestor' => [[]],\n        //'inherited' => [[]],\n        //'inheritedField' => [[]],\n        'find' => [['/']],\n        //'collection' => [[]],\n        //'evaluate' => [[]],\n        'folderExists' => [[]],\n        //'getOriginal' => [[]],\n        //'getAction' => [[]],\n    ];\n\n    /** @var Grav */\n    protected $grav;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('page-system-validator')\n            ->setDescription('Page validator can be used to compare site before/after update and when migrating to Flex Pages.')\n            ->addOption('record', 'r', InputOption::VALUE_NONE, 'Record results')\n            ->addOption('check', 'c', InputOption::VALUE_NONE, 'Compare site against previously recorded results')\n            ->setHelp('The <info>page-system-validator</info> command can be used to test the pages before and after upgrade');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $this->setLanguage('en');\n        $this->initializePages();\n\n        $io->newLine();\n\n        $this->grav = $grav = Grav::instance();\n\n        $grav->fireEvent('onPageInitialized', new Event(['page' => $grav['page']]));\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        if ($input->getOption('record')) {\n            $io->writeln('Pages: ' . $config->get('system.pages.type', 'page'));\n\n            $io->writeln('<magenta>Record tests</magenta>');\n            $io->newLine();\n\n            $results = $this->record();\n            $file = $this->getFile('pages-old');\n            $file->save($results);\n\n            $io->writeln('Recorded tests to ' . $file->filename());\n        } elseif ($input->getOption('check')) {\n            $io->writeln('Pages: ' . $config->get('system.pages.type', 'page'));\n\n            $io->writeln('<magenta>Run tests</magenta>');\n            $io->newLine();\n\n            $new = $this->record();\n            $file = $this->getFile('pages-new');\n            $file->save($new);\n            $io->writeln('Recorded tests to ' . $file->filename());\n\n            $file = $this->getFile('pages-old');\n            $old = $file->content();\n\n            $results = $this->check($old, $new);\n            $file = $this->getFile('diff');\n            $file->save($results);\n            $io->writeln('Recorded results to ' . $file->filename());\n        } else {\n            $io->writeln('<green>page-system-validator [-r|--record] [-c|--check]</green>');\n        }\n        $io->newLine();\n\n        return 0;\n    }\n\n    /**\n     * @return array\n     */\n    private function record(): array\n    {\n        $io = $this->getIO();\n\n        /** @var Pages $pages */\n        $pages = $this->grav['pages'];\n        $all = $pages->all();\n\n        $results = [];\n        $results[''] = $this->recordRow($pages->root());\n        foreach ($all as $path => $page) {\n            if (null === $page) {\n                $io->writeln('<red>Error on page ' . $path . '</red>');\n                continue;\n            }\n\n            $results[$page->rawRoute()] = $this->recordRow($page);\n        }\n\n        return json_decode(json_encode($results), true);\n    }\n\n    /**\n     * @param PageInterface $page\n     * @return array\n     */\n    private function recordRow(PageInterface $page): array\n    {\n        $results = [];\n\n        foreach ($this->tests as $method => $params) {\n            $params = $params ?: [[]];\n            foreach ($params as $p) {\n                $result = $page->$method(...$p);\n                if (in_array($method, ['summary', 'content', 'getRawContent'], true)) {\n                    $result = preg_replace('/name=\"(form-nonce|__unique_form_id__)\" value=\"[^\"]+\"/',\n                        'name=\"\\\\1\" value=\"DYNAMIC\"', $result);\n                    $result = preg_replace('`src=(\"|\\'|&quot;)/images/./././././[^\"]+\\\\1`',\n                        'src=\"\\\\1images/GENERATED\\\\1', $result);\n                    $result = preg_replace('/\\?\\d{10}/', '?1234567890', $result);\n                } elseif ($method === 'httpHeaders' && isset($result['Expires'])) {\n                    $result['Expires'] = 'Thu, 19 Sep 2019 13:10:24 GMT (REPLACED AS DYNAMIC)';\n                } elseif ($result instanceof PageInterface) {\n                    $result = $result->rawRoute();\n                } elseif (is_object($result)) {\n                    $result = json_decode(json_encode($result), true);\n                }\n\n                $ps = [];\n                foreach ($p as $val) {\n                    $ps[] = (string)var_export($val, true);\n                }\n                $pstr = implode(', ', $ps);\n                $call = \"->{$method}({$pstr})\";\n                $results[$call] = $result;\n            }\n        }\n\n        return $results;\n    }\n\n    /**\n     * @param array $old\n     * @param array $new\n     * @return array\n     */\n    private function check(array $old, array $new): array\n    {\n        $errors = [];\n        foreach ($old as $path => $page) {\n            if (!isset($new[$path])) {\n                $errors[$path] = 'PAGE REMOVED';\n                continue;\n            }\n            foreach ($page as $method => $test) {\n                if (($new[$path][$method] ?? null) !== $test) {\n                    $errors[$path][$method] = ['old' => $test, 'new' => $new[$path][$method]];\n                }\n            }\n        }\n\n        return $errors;\n    }\n\n    /**\n     * @param string $name\n     * @return CompiledYamlFile\n     */\n    private function getFile(string $name): CompiledYamlFile\n    {\n        return CompiledYamlFile::instance('cache://tests/' . $name . '.yaml');\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/SandboxCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Utils;\nuse Grav\\Console\\GravCommand;\nuse RuntimeException;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse function count;\n\n/**\n * Class SandboxCommand\n * @package Grav\\Console\\Cli\n */\nclass SandboxCommand extends GravCommand\n{\n    /** @var array */\n    protected $directories = [\n        '/assets',\n        '/backup',\n        '/cache',\n        '/images',\n        '/logs',\n        '/tmp',\n        '/user/accounts',\n        '/user/config',\n        '/user/data',\n        '/user/pages',\n        '/user/plugins',\n        '/user/themes',\n    ];\n\n    /** @var array */\n    protected $files = [\n        '/.dependencies',\n        '/.htaccess',\n        '/user/config/site.yaml',\n        '/user/config/system.yaml',\n    ];\n\n    /** @var array */\n    protected $mappings = [\n        '/.gitignore'           => '/.gitignore',\n        '/.editorconfig'        => '/.editorconfig',\n        '/CHANGELOG.md'         => '/CHANGELOG.md',\n        '/LICENSE.txt'          => '/LICENSE.txt',\n        '/README.md'            => '/README.md',\n        '/CONTRIBUTING.md'      => '/CONTRIBUTING.md',\n        '/index.php'            => '/index.php',\n        '/composer.json'        => '/composer.json',\n        '/bin'                  => '/bin',\n        '/system'               => '/system',\n        '/vendor'               => '/vendor',\n        '/webserver-configs'    => '/webserver-configs',\n    ];\n\n    /** @var string */\n    protected $source;\n    /** @var string */\n    protected $destination;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('sandbox')\n            ->setDescription('Setup of a base Grav system in your webroot, good for development, playing around or starting fresh')\n            ->addArgument(\n                'destination',\n                InputArgument::REQUIRED,\n                'The destination directory to symlink into'\n            )\n            ->addOption(\n                'symlink',\n                's',\n                InputOption::VALUE_NONE,\n                'Symlink the base grav system'\n            )\n            ->setHelp(\"The <info>sandbox</info> command help create a development environment that can optionally use symbolic links to link the core of grav to the git cloned repository.\\nGood for development, playing around or starting fresh\");\n\n        $source = getcwd();\n        if ($source === false) {\n            throw new RuntimeException('Internal Error');\n        }\n        $this->source = $source;\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n\n        $this->destination = $input->getArgument('destination');\n\n        // Create Some core stuff if it doesn't exist\n        $error = $this->createDirectories();\n        if ($error) {\n            return $error;\n        }\n\n        // Copy files or create symlinks\n        $error = $input->getOption('symlink') ? $this->symlink() : $this->copy();\n        if ($error) {\n            return $error;\n        }\n\n        $error = $this->pages();\n        if ($error) {\n            return $error;\n        }\n\n        $error = $this->initFiles();\n        if ($error) {\n            return $error;\n        }\n\n        $error = $this->perms();\n        if ($error) {\n            return $error;\n        }\n\n        return 0;\n    }\n\n    /**\n     * @return int\n     */\n    private function createDirectories(): int\n    {\n        $io = $this->getIO();\n\n        $io->newLine();\n        $io->writeln('<comment>Creating Directories</comment>');\n        $dirs_created = false;\n\n        if (!file_exists($this->destination)) {\n            Folder::create($this->destination);\n        }\n\n        foreach ($this->directories as $dir) {\n            if (!file_exists($this->destination . $dir)) {\n                $dirs_created = true;\n                $io->writeln('    <cyan>' . $dir . '</cyan>');\n                Folder::create($this->destination . $dir);\n            }\n        }\n\n        if (!$dirs_created) {\n            $io->writeln('    <red>Directories already exist</red>');\n        }\n\n        return 0;\n    }\n\n    /**\n     * @return int\n     */\n    private function copy(): int\n    {\n        $io = $this->getIO();\n\n        $io->newLine();\n        $io->writeln('<comment>Copying Files</comment>');\n\n\n        foreach ($this->mappings as $source => $target) {\n            if ((string)(int)$source === (string)$source) {\n                $source = $target;\n            }\n\n            $from = $this->source . $source;\n            $to = $this->destination . $target;\n\n            $io->writeln('    <cyan>' . $source . '</cyan> <comment>-></comment> ' . $to);\n            @Folder::rcopy($from, $to);\n        }\n\n        return 0;\n    }\n\n    /**\n     * @return int\n     */\n    private function symlink(): int\n    {\n        $io = $this->getIO();\n\n        $io->newLine();\n        $io->writeln('<comment>Resetting Symbolic Links</comment>');\n\n        // Symlink also tests if using git.\n        if (is_dir($this->source . '/tests')) {\n            $this->mappings['/tests'] = '/tests';\n        }\n\n        foreach ($this->mappings as $source => $target) {\n            if ((string)(int)$source === (string)$source) {\n                $source = $target;\n            }\n\n            $from = $this->source . $source;\n            $to = $this->destination . $target;\n\n            $io->writeln('    <cyan>' . $source . '</cyan> <comment>-></comment> ' . $to);\n\n            if (is_dir($to)) {\n                @Folder::delete($to);\n            } else {\n                @unlink($to);\n            }\n            symlink($from, $to);\n        }\n\n        return 0;\n    }\n\n    /**\n     * @return int\n     */\n    private function pages(): int\n    {\n        $io = $this->getIO();\n\n        $io->newLine();\n        $io->writeln('<comment>Pages Initializing</comment>');\n\n        // get pages files and initialize if no pages exist\n        $pages_dir = $this->destination . '/user/pages';\n        $pages_files = array_diff(scandir($pages_dir), ['..', '.']);\n\n        if (count($pages_files) === 0) {\n            $destination = $this->source . '/user/pages';\n            Folder::rcopy($destination, $pages_dir);\n            $io->writeln('    <cyan>' . $destination . '</cyan> <comment>-></comment> Created');\n        }\n\n        return 0;\n    }\n\n    /**\n     * @return int\n     */\n    private function initFiles(): int\n    {\n        if (!$this->check()) {\n            return 1;\n        }\n\n        $io = $this->getIO();\n        $io->newLine();\n        $io->writeln('<comment>File Initializing</comment>');\n        $files_init = false;\n\n        // Copy files if they do not exist\n        foreach ($this->files as $source => $target) {\n            if ((string)(int)$source === (string)$source) {\n                $source = $target;\n            }\n\n            $from = $this->source . $source;\n            $to = $this->destination . $target;\n\n            if (!file_exists($to)) {\n                $files_init = true;\n                copy($from, $to);\n                $io->writeln('    <cyan>' . $target . '</cyan> <comment>-></comment> Created');\n            }\n        }\n\n        if (!$files_init) {\n            $io->writeln('    <red>Files already exist</red>');\n        }\n\n        return 0;\n    }\n\n    /**\n     * @return int\n     */\n    private function perms(): int\n    {\n        $io = $this->getIO();\n        $io->newLine();\n        $io->writeln('<comment>Permissions Initializing</comment>');\n\n        $dir_perms = 0755;\n\n        $binaries = glob($this->destination . DS . 'bin' . DS . '*');\n\n        foreach ($binaries as $bin) {\n            chmod($bin, $dir_perms);\n            $io->writeln('    <cyan>bin/' . Utils::basename($bin) . '</cyan> permissions reset to ' . decoct($dir_perms));\n        }\n\n        $io->newLine();\n\n        return 0;\n    }\n\n    /**\n     * @return bool\n     */\n    private function check(): bool\n    {\n        $success = true;\n        $io = $this->getIO();\n\n        if (!file_exists($this->destination)) {\n            $io->writeln('    file: <red>' . $this->destination . '</red> does not exist!');\n            $success = false;\n        }\n\n        foreach ($this->directories as $dir) {\n            if (!file_exists($this->destination . $dir)) {\n                $io->writeln('    directory: <red>' . $dir . '</red> does not exist!');\n                $success = false;\n            }\n        }\n\n        foreach ($this->mappings as $target => $link) {\n            if (!file_exists($this->destination . $target)) {\n                $io->writeln('    mappings: <red>' . $target . '</red> does not exist!');\n                $success = false;\n            }\n        }\n\n        if (!$success) {\n            $io->newLine();\n            $io->writeln('<comment>install should be run with --symlink|--s to symlink first</comment>');\n        }\n\n        return $success;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/SchedulerCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Cron\\CronExpression;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse Grav\\Common\\Scheduler\\Scheduler;\nuse Grav\\Console\\GravCommand;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse Symfony\\Component\\Console\\Helper\\Table;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse function is_null;\n\n/**\n * Class SchedulerCommand\n * @package Grav\\Console\\Cli\n */\nclass SchedulerCommand extends GravCommand\n{\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('scheduler')\n            ->addOption(\n                'install',\n                'i',\n                InputOption::VALUE_NONE,\n                'Show Install Command'\n            )\n            ->addOption(\n                'jobs',\n                'j',\n                InputOption::VALUE_NONE,\n                'Show Jobs Summary'\n            )\n            ->addOption(\n                'details',\n                'd',\n                InputOption::VALUE_NONE,\n                'Show Job Details'\n            )\n            ->addOption(\n                'run',\n                'r',\n                InputOption::VALUE_OPTIONAL,\n                'Force run all jobs or a specific job if you specify a specific Job ID',\n                false\n            )\n            ->addOption(\n                'force',\n                'f',\n                InputOption::VALUE_NONE,\n                'Force all due jobs to run regardless of their schedule'\n            )\n            ->setDescription('Run the Grav Scheduler.  Best when integrated with system cron')\n            ->setHelp(\"Running without any options will process the Scheduler jobs based on their cron schedule. Use --force to run all jobs immediately.\");\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $this->initializePlugins();\n\n        $grav = Grav::instance();\n        $grav['backups']->init();\n        $this->initializePages();\n        $this->initializeThemes();\n\n        /** @var Scheduler $scheduler */\n        $scheduler = $grav['scheduler'];\n        $grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler]));\n\n        $input = $this->getInput();\n        $io = $this->getIO();\n        $error = 0;\n\n        $run = $input->getOption('run');\n        $showDetails = $input->getOption('details');\n        $showJobs = $input->getOption('jobs');\n        $forceRun = $input->getOption('force');\n\n        // Handle running jobs first if -r flag is present\n        if ($run !== false) {\n            if ($run === null || $run === '') {\n                // Run all jobs when -r is provided without a specific job ID\n                $io->title('Force Run All Jobs');\n                \n                $jobs = $scheduler->getAllJobs();\n                $hasOutput = false;\n                \n                foreach ($jobs as $job) {\n                    if ($job->getEnabled()) {\n                        $io->section('Running: ' . $job->getId());\n                        $job->inForeground()->run();\n                        \n                        if ($job->isSuccessful()) {\n                            $io->success('Job ' . $job->getId() . ' ran successfully');\n                        } else {\n                            $error = 1;\n                            $io->error('Job ' . $job->getId() . ' failed to run');\n                        }\n                        \n                        $output = $job->getOutput();\n                        if ($output) {\n                            $io->write($output);\n                            $hasOutput = true;\n                        }\n                    }\n                }\n                \n                if (!$hasOutput) {\n                    $io->note('All enabled jobs completed');\n                }\n            } else {\n                // Run specific job\n                $io->title('Force Run Job: ' . $run);\n\n                $job = $scheduler->getJob($run);\n\n                if ($job) {\n                    $job->inForeground()->run();\n\n                    if ($job->isSuccessful()) {\n                        $io->success('Job ran successfully...');\n                    } else {\n                        $error = 1;\n                        $io->error('Job failed to run successfully...');\n                    }\n\n                    $output = $job->getOutput();\n\n                    if ($output) {\n                        $io->write($output);\n                    }\n                } else {\n                    $error = 1;\n                    $io->error('Could not find a job with id: ' . $run);\n                }\n            }\n\n            // Add separator if we're going to show details after\n            if ($showDetails) {\n                $io->newLine();\n            }\n        }\n\n        if ($showJobs) {\n            // Show jobs list\n\n            $jobs = $scheduler->getAllJobs();\n            $job_states = (array)$scheduler->getJobStates()->content();\n            $rows = [];\n\n            $table = new Table($io);\n            $table->setStyle('box');\n            $headers = ['Job ID', 'Command', 'Run At', 'Status', 'Last Run', 'State'];\n\n            $io->title('Scheduler Jobs Listing');\n\n            foreach ($jobs as $job) {\n                $job_status = ucfirst($job_states[$job->getId()]['state'] ?? 'ready');\n                $last_run = $job_states[$job->getId()]['last-run'] ?? 0;\n                $status = $job_status === 'Failure' ? \"<red>{$job_status}</red>\" : \"<green>{$job_status}</green>\";\n                $state = $job->getEnabled() ? '<cyan>Enabled</cyan>' : '<red>Disabled</red>';\n                $row = [\n                    $job->getId(),\n                    \"<white>{$job->getCommand()}</white>\",\n                    \"<magenta>{$job->getAt()}</magenta>\",\n                    $status,\n                    '<yellow>' . ($last_run === 0 ? 'Never' : date('Y-m-d H:i', $last_run)) . '</yellow>',\n                    $state,\n\n                ];\n                $rows[] = $row;\n            }\n\n            if (!empty($rows)) {\n                $table->setHeaders($headers);\n                $table->setRows($rows);\n                $table->render();\n            } else {\n                $io->text('no jobs found...');\n            }\n\n            $io->newLine();\n            $io->note('For error details run \"bin/grav scheduler -d\"');\n            $io->newLine();\n        }\n        \n        if ($showDetails) {\n            $jobs = $scheduler->getAllJobs();\n            $job_states = (array)$scheduler->getJobStates()->content();\n\n            $io->title('Job Details');\n\n            $table = new Table($io);\n            $table->setStyle('box');\n            $table->setHeaders(['Job ID', 'Last Run', 'Next Run', 'Errors']);\n            $rows = [];\n\n            foreach ($jobs as $job) {\n                $job_state = $job_states[$job->getId()];\n                $error = isset($job_state['error']) ? trim($job_state['error']) : false;\n\n                /** @var CronExpression $expression */\n                $expression = $job->getCronExpression();\n                $next_run = $expression->getNextRunDate();\n\n                $row = [];\n                $row[] = $job->getId();\n                if (!is_null($job_state['last-run'])) {\n                    $row[] = '<yellow>' . date('Y-m-d H:i', $job_state['last-run']) . '</yellow>';\n                } else {\n                    $row[] = '<yellow>Never</yellow>';\n                }\n                $row[] = '<yellow>' . $next_run->format('Y-m-d H:i') . '</yellow>';\n\n                if ($error) {\n                    $row[] = \"<error>{$error}</error>\";\n                } else {\n                    $row[] = '<green>None</green>';\n                }\n                $rows[] = $row;\n            }\n\n            $table->setRows($rows);\n            $table->render();\n        }\n        \n        if ($input->getOption('install')) {\n            $io->title('Install Scheduler');\n\n            $verb = 'install';\n\n            if ($scheduler->isCrontabSetup()) {\n                $io->success('All Ready! You have already set up Grav\\'s Scheduler in your crontab. You can validate this by running \"crontab -l\" to list your current crontab entries.');\n                $verb = 'reinstall';\n            } else {\n                $user = $scheduler->whoami();\n                $error = 1;\n                $io->error('Can\\'t find a crontab for ' . $user . '. You need to set up Grav\\'s Scheduler in your crontab');\n            }\n            if (!Utils::isWindows()) {\n                $io->note(\"To $verb, run the following command from your terminal:\");\n                $io->newLine();\n                $io->text(trim($scheduler->getCronCommand()));\n            } else {\n                $io->note(\"To $verb, create a scheduled task in Windows.\");\n                $io->text('Learn more at https://learn.getgrav.org/advanced/scheduler');\n            }\n        } elseif (!$showJobs && !$showDetails && $run === false) {\n            // Run scheduler only if no other options were provided\n            $scheduler->run(null, $forceRun);\n\n            if ($input->getOption('verbose')) {\n                $io->title('Running Scheduled Jobs');\n                $io->text($scheduler->getVerboseOutput());\n            }\n        }\n\n        return $error;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/SecurityCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Security;\nuse Grav\\Console\\GravCommand;\nuse Symfony\\Component\\Console\\Helper\\ProgressBar;\nuse function count;\n\n/**\n * Class SecurityCommand\n * @package Grav\\Console\\Cli\n */\nclass SecurityCommand extends GravCommand\n{\n    /** @var ProgressBar $progress */\n    protected $progress;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('security')\n            ->setDescription('Capable of running various Security checks')\n            ->setHelp('The <info>security</info> runs various security checks on your Grav site');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $this->initializePages();\n\n        $io = $this->getIO();\n\n        /** @var Grav $grav */\n        $grav = Grav::instance();\n        $this->progress = new ProgressBar($this->output, count($grav['pages']->routes()) - 1);\n        $this->progress->setFormat('Scanning <cyan>%current%</cyan> pages [<green>%bar%</green>] <white>%percent:3s%%</white> %elapsed:6s%');\n        $this->progress->setBarWidth(100);\n\n        $io->title('Grav Security Check');\n        $io->newline(2);\n\n        $output = Security::detectXssFromPages($grav['pages'], false, [$this, 'outputProgress']);\n\n        $error = 0;\n        if (!empty($output)) {\n            $counter = 1;\n            foreach ($output as $route => $results) {\n                $results_parts = array_map(static function ($value, $key) {\n                    return $key.': \\''.$value . '\\'';\n                }, array_values($results), array_keys($results));\n\n                $io->writeln($counter++ .' - <cyan>' . $route . '</cyan> → <red>' . implode(', ', $results_parts) . '</red>');\n            }\n\n            $error = 1;\n            $io->error('Security Scan complete: ' . count($output) . ' potential XSS issues found...');\n        } else {\n            $io->success('Security Scan complete: No issues found...');\n        }\n\n        $io->newline(1);\n\n        return $error;\n    }\n\n    /**\n     * @param array $args\n     * @return void\n     */\n    public function outputProgress(array $args): void\n    {\n        switch ($args['type']) {\n            case 'count':\n                $steps = $args['steps'];\n                $freq = (int)($steps > 100 ? round($steps / 100) : $steps);\n                $this->progress->setMaxSteps($steps);\n                $this->progress->setRedrawFrequency($freq);\n                break;\n            case 'progress':\n                if (isset($args['complete']) && $args['complete']) {\n                    $this->progress->finish();\n                } else {\n                    $this->progress->advance();\n                }\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/ServerCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Common\\Utils;\nuse Grav\\Console\\GravCommand;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Process\\PhpExecutableFinder;\nuse Symfony\\Component\\Process\\Process;\n\n/**\n * Class ServerCommand\n * @package Grav\\Console\\Cli\n */\nclass ServerCommand extends GravCommand\n{\n    const SYMFONY_SERVER = 'Symfony Server';\n    const PHP_SERVER = 'Built-in PHP Server';\n\n    /** @var string */\n    protected $ip;\n    /** @var int */\n    protected $port;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('server')\n            ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Preferred HTTP port rather than auto-find (default is 8000-9000')\n            ->addOption('symfony', null, InputOption::VALUE_NONE, 'Force using Symfony server')\n            ->addOption('php', null, InputOption::VALUE_NONE, 'Force using built-in PHP server')\n            ->setDescription(\"Runs built-in web-server, Symfony first, then tries PHP's\")\n            ->setHelp(\"Runs built-in web-server, Symfony first, then tries PHP's\");\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $io->title('Grav Web Server');\n\n        // Ensure CLI colors are on\n        ini_set('cli_server.color', 'on');\n\n        // Options\n        $force_symfony = $input->getOption('symfony');\n        $force_php = $input->getOption('php');\n\n        // Find PHP\n        $executableFinder = new PhpExecutableFinder();\n        $php = $executableFinder->find(false);\n\n        $this->ip = '127.0.0.1';\n        $this->port = (int)($input->getOption('port') ?? 8000);\n\n        // Get an open port\n        while (!$this->portAvailable($this->ip, $this->port)) {\n            $this->port++;\n        }\n\n        // Setup the commands\n        $symfony_cmd = ['symfony', 'server:start', '--ansi', '--port=' . $this->port];\n        $php_cmd = [$php, '-S', $this->ip.':'.$this->port, 'system/router.php'];\n\n        $commands = [\n            self::SYMFONY_SERVER => $symfony_cmd,\n            self::PHP_SERVER => $php_cmd\n        ];\n\n        if ($force_symfony) {\n            unset($commands[self::PHP_SERVER]);\n        } elseif ($force_php) {\n            unset($commands[self::SYMFONY_SERVER]);\n        }\n\n        $error = 0;\n        foreach ($commands as $name => $command) {\n            $process = $this->runProcess($name, $command);\n            if (!$process) {\n                $io->note('Starting ' . $name . '...');\n            }\n\n            // Should only get here if there's an error running\n            if (!$process->isRunning() && (($name === self::SYMFONY_SERVER && $force_symfony) || ($name === self::PHP_SERVER))) {\n                $error = 1;\n                $io->error('Could not start ' . $name);\n            }\n        }\n\n        return $error;\n    }\n\n    /**\n     * @param string $name\n     * @param array $cmd\n     * @return Process\n     */\n    protected function runProcess(string $name, array $cmd): Process\n    {\n        $io = $this->getIO();\n\n        $process = new Process($cmd);\n        $process->setTimeout(0);\n        $process->start();\n\n        if ($name === self::SYMFONY_SERVER && Utils::contains($process->getErrorOutput(), 'symfony: not found')) {\n            $io->error('The symfony binary could not be found, please install the CLI tools: https://symfony.com/download');\n            $io->warning('Falling back to PHP web server...');\n        }\n\n        if ($name === self::PHP_SERVER) {\n            $io->success('Built-in PHP web server listening on http://' . $this->ip . ':' . $this->port . ' (PHP v' . PHP_VERSION . ')');\n        }\n\n        $process->wait(function ($type, $buffer) {\n            $this->getIO()->write($buffer);\n        });\n\n        return $process;\n    }\n\n    /**\n     * Simple function test the port\n     *\n     * @param string $ip\n     * @param int $port\n     * @return bool\n     */\n    protected function portAvailable(string $ip, int $port): bool\n    {\n        $fp = @fsockopen($ip, $port, $errno, $errstr, 0.1);\n        if (!$fp) {\n            return true;\n        }\n\n        fclose($fp);\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Cli/YamlLinterCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Cli\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Cli;\n\nuse Grav\\Common\\Helpers\\YamlLinter;\nuse Grav\\Console\\GravCommand;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n/**\n * Class YamlLinterCommand\n * @package Grav\\Console\\Cli\n */\nclass YamlLinterCommand extends GravCommand\n{\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('yamllinter')\n            ->addOption(\n                'all',\n                'a',\n                InputOption::VALUE_NONE,\n                'Go through the whole Grav installation'\n            )\n            ->addOption(\n                'folder',\n                'f',\n                InputOption::VALUE_OPTIONAL,\n                'Go through specific folder'\n            )\n            ->setDescription('Checks various files for YAML errors')\n            ->setHelp('Checks various files for YAML errors');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $io->title('Yaml Linter');\n\n        $error = 0;\n        if ($input->getOption('all')) {\n            $io->section('All');\n            $errors = YamlLinter::lint('');\n\n            if (empty($errors)) {\n                $io->success('No YAML Linting issues found');\n            } else {\n                $error = 1;\n                $this->displayErrors($errors, $io);\n            }\n        } elseif ($folder = $input->getOption('folder')) {\n            $io->section($folder);\n            $errors = YamlLinter::lint($folder);\n\n            if (empty($errors)) {\n                $io->success('No YAML Linting issues found');\n            } else {\n                $error = 1;\n                $this->displayErrors($errors, $io);\n            }\n        } else {\n            $io->section('User Configuration');\n            $errors = YamlLinter::lintConfig();\n\n            if (empty($errors)) {\n                $io->success('No YAML Linting issues with configuration');\n            } else {\n                $error = 1;\n                $this->displayErrors($errors, $io);\n            }\n\n            $io->section('Pages Frontmatter');\n            $errors = YamlLinter::lintPages();\n\n            if (empty($errors)) {\n                $io->success('No YAML Linting issues with pages');\n            } else {\n                $error = 1;\n                $this->displayErrors($errors, $io);\n            }\n\n            $io->section('Page Blueprints');\n            $errors = YamlLinter::lintBlueprints();\n\n            if (empty($errors)) {\n                $io->success('No YAML Linting issues with blueprints');\n            } else {\n                $error = 1;\n                $this->displayErrors($errors, $io);\n            }\n        }\n\n        return $error;\n    }\n\n    /**\n     * @param array $errors\n     * @param SymfonyStyle $io\n     * @return void\n     */\n    protected function displayErrors(array $errors, SymfonyStyle $io): void\n    {\n        $io->error('YAML Linting issues found...');\n        foreach ($errors as $path => $error) {\n            $io->writeln(\"<yellow>{$path}</yellow> - {$error}\");\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/ConsoleCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console;\n\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n/**\n * Class ConsoleCommand\n * @package Grav\\Console\n */\nclass ConsoleCommand extends Command\n{\n    use ConsoleTrait;\n\n    /**\n     * @param InputInterface  $input\n     * @param OutputInterface $output\n     * @return int\n     */\n    protected function execute(InputInterface $input, OutputInterface $output)\n    {\n        $this->setupConsole($input, $output);\n\n        return $this->serve();\n    }\n\n    /**\n     * Override with your implementation.\n     *\n     * @return int\n     */\n    protected function serve()\n    {\n        // Return error.\n        return 1;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/ConsoleTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console;\n\nuse Exception;\nuse Grav\\Common\\Cache;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Composer;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Processors\\InitializeProcessor;\nuse Grav\\Console\\Cli\\ClearCacheCommand;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse Symfony\\Component\\Console\\Exception\\InvalidArgumentException;\nuse Symfony\\Component\\Console\\Input\\ArrayInput;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\nuse Symfony\\Component\\Console\\Style\\SymfonyStyle;\n\n/**\n * Trait ConsoleTrait\n * @package Grav\\Console\n */\ntrait ConsoleTrait\n{\n    /** @var string */\n    protected $argv;\n    /** @var InputInterface */\n    protected $input;\n    /** @var SymfonyStyle */\n    protected $output;\n    /** @var array */\n    protected $local_config;\n\n    /** @var bool */\n    private $plugins_initialized = false;\n    /** @var bool */\n    private $themes_initialized = false;\n    /** @var bool */\n    private $pages_initialized = false;\n\n    /**\n     * Set colors style definition for the formatter.\n     *\n     * @param InputInterface  $input\n     * @param OutputInterface $output\n     * @return void\n     */\n    public function setupConsole(InputInterface $input, OutputInterface $output)\n    {\n        $this->argv = $_SERVER['argv'][0];\n        $this->input = $input;\n        $this->output = new SymfonyStyle($input, $output);\n\n        $this->setupGrav();\n    }\n\n    public function getInput(): InputInterface\n    {\n        return $this->input;\n    }\n\n    /**\n     * @return SymfonyStyle\n     */\n    public function getIO(): SymfonyStyle\n    {\n        return $this->output;\n    }\n\n    /**\n     * Adds an option.\n     *\n     * @param string                        $name        The option name\n     * @param string|array|null             $shortcut    The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts\n     * @param int|null                      $mode        The option mode: One of the InputOption::VALUE_* constants\n     * @param string                        $description A description text\n     * @param string|string[]|int|bool|null $default     The default value (must be null for InputOption::VALUE_NONE)\n     * @return $this\n     * @throws InvalidArgumentException If option mode is invalid or incompatible\n     */\n    public function addOption($name, $shortcut = null, $mode = null, $description = '', $default = null)\n    {\n        if ($name !== 'env' && $name !== 'lang') {\n            parent::addOption($name, $shortcut, $mode, $description, $default);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @return void\n     */\n    final protected function setupGrav(): void\n    {\n        try {\n            $language = $this->input->getOption('lang');\n            if ($language) {\n                // Set used language.\n                $this->setLanguage($language);\n            }\n        } catch (InvalidArgumentException $e) {}\n\n        // Initialize cache with CLI compatibility\n        $grav = Grav::instance();\n        $grav['config']->set('system.cache.cli_compatibility', true);\n    }\n\n    /**\n     * Initialize Grav.\n     *\n     * - Load configuration\n     * - Initialize logger\n     * - Disable debugger\n     * - Set timezone, locale\n     * - Load plugins (call PluginsLoadedEvent)\n     * - Set Pages and Users type to be used in the site\n     *\n     * Safe to be called multiple times.\n     *\n     * @return $this\n     */\n    final protected function initializeGrav()\n    {\n        InitializeProcessor::initializeCli(Grav::instance());\n\n        return $this;\n    }\n\n    /**\n     * Set language to be used in CLI.\n     *\n     * @param string|null $code\n     * @return $this\n     */\n    final protected function setLanguage(string $code = null)\n    {\n        $this->initializeGrav();\n\n        $grav = Grav::instance();\n        /** @var Language $language */\n        $language = $grav['language'];\n        if ($language->enabled()) {\n            if ($code && $language->validate($code)) {\n                $language->setActive($code);\n            } else {\n                $language->setActive($language->getDefault());\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * Properly initialize plugins.\n     *\n     * - call $this->initializeGrav()\n     * - call onPluginsInitialized event\n     *\n     * Safe to be called multiple times.\n     *\n     * @return $this\n     */\n    final protected function initializePlugins()\n    {\n        if (!$this->plugins_initialized) {\n            $this->plugins_initialized = true;\n\n            $this->initializeGrav();\n\n            // Initialize plugins.\n            $grav = Grav::instance();\n            $grav['plugins']->init();\n            $grav->fireEvent('onPluginsInitialized');\n        }\n\n        return $this;\n    }\n\n    /**\n     * Properly initialize themes.\n     *\n     * - call $this->initializePlugins()\n     * - initialize theme (call onThemeInitialized event)\n     *\n     * Safe to be called multiple times.\n     *\n     * @return $this\n     */\n    final protected function initializeThemes()\n    {\n        if (!$this->themes_initialized) {\n            $this->themes_initialized = true;\n\n            $this->initializePlugins();\n\n            // Initialize themes.\n            $grav = Grav::instance();\n            $grav['themes']->init();\n        }\n\n        return $this;\n    }\n\n    /**\n     * Properly initialize pages.\n     *\n     * - call $this->initializeThemes()\n     * - initialize assets (call onAssetsInitialized event)\n     * - initialize twig (calls the twig events)\n     * - initialize pages (calls onPagesInitialized event)\n     *\n     * Safe to be called multiple times.\n     *\n     * @return $this\n     */\n    final protected function initializePages()\n    {\n        if (!$this->pages_initialized) {\n            $this->pages_initialized = true;\n\n            $this->initializeThemes();\n\n            $grav = Grav::instance();\n\n            // Initialize assets.\n            $grav['assets']->init();\n            $grav->fireEvent('onAssetsInitialized');\n\n            // Initialize twig.\n            $grav['twig']->init();\n\n            // Initialize pages.\n            $pages = $grav['pages'];\n            $pages->init();\n            $grav->fireEvent('onPagesInitialized', new Event(['pages' => $pages]));\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $path\n     * @return void\n     */\n    public function isGravInstance($path)\n    {\n        $io = $this->getIO();\n\n        if (!file_exists($path)) {\n            $io->writeln('');\n            $io->writeln(\"<red>ERROR</red>: Destination doesn't exist:\");\n            $io->writeln(\"       <white>$path</white>\");\n            $io->writeln('');\n            exit;\n        }\n\n        if (!is_dir($path)) {\n            $io->writeln('');\n            $io->writeln(\"<red>ERROR</red>: Destination chosen to install is not a directory:\");\n            $io->writeln(\"       <white>$path</white>\");\n            $io->writeln('');\n            exit;\n        }\n\n        if (!file_exists($path . DS . 'index.php') || !file_exists($path . DS . '.dependencies') || !file_exists($path . DS . 'system' . DS . 'config' . DS . 'system.yaml')) {\n            $io->writeln('');\n            $io->writeln('<red>ERROR</red>: Destination chosen to install does not appear to be a Grav instance:');\n            $io->writeln(\"       <white>$path</white>\");\n            $io->writeln('');\n            exit;\n        }\n    }\n\n    /**\n     * @param string $path\n     * @param string $action\n     * @return string|false\n     */\n    public function composerUpdate($path, $action = 'install')\n    {\n        $composer = Composer::getComposerExecutor();\n\n        return system($composer . ' --working-dir=' . escapeshellarg($path) . ' --no-interaction --no-dev --prefer-dist -o '. $action);\n    }\n\n    /**\n     * @param array $all\n     * @return int\n     * @throws Exception\n     */\n    public function clearCache($all = [])\n    {\n        if ($all) {\n            $all = ['--all' => true];\n        }\n\n        $command = new ClearCacheCommand();\n        $input = new ArrayInput($all);\n        return $command->run($input, $this->output);\n    }\n\n    /**\n     * @return void\n     */\n    public function invalidateCache()\n    {\n        Cache::invalidateCache();\n    }\n\n    /**\n     * Load the local config file\n     *\n     * @return string|false The local config file name. false if local config does not exist\n     */\n    public function loadLocalConfig()\n    {\n        $home_folder = getenv('HOME') ?: getenv('HOMEDRIVE') . getenv('HOMEPATH');\n        $local_config_file = $home_folder . '/.grav/config';\n\n        if (file_exists($local_config_file)) {\n            $file = YamlFile::instance($local_config_file);\n            $this->local_config = $file->content();\n            $file->free();\n\n            return $local_config_file;\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Gpm/DirectInstallCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Gpm\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Gpm;\n\nuse Exception;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\HTTP\\Response;\nuse Grav\\Common\\GPM\\GPM;\nuse Grav\\Common\\GPM\\Installer;\nuse Grav\\Console\\GpmCommand;\nuse RuntimeException;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Question\\ConfirmationQuestion;\nuse ZipArchive;\nuse function is_array;\nuse function is_callable;\n\n/**\n * Class DirectInstallCommand\n * @package Grav\\Console\\Gpm\n */\nclass DirectInstallCommand extends GpmCommand\n{\n    /** @var string */\n    protected $all_yes;\n    /** @var string */\n    protected $destination;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('direct-install')\n            ->setAliases(['directinstall'])\n            ->addArgument(\n                'package-file',\n                InputArgument::REQUIRED,\n                'Installable package local <path> or remote <URL>. Can install specific version'\n            )\n            ->addOption(\n                'all-yes',\n                'y',\n                InputOption::VALUE_NONE,\n                'Assumes yes (or best approach) instead of prompting'\n            )\n            ->addOption(\n                'destination',\n                'd',\n                InputOption::VALUE_OPTIONAL,\n                'The destination where the package should be installed at. By default this would be where the grav instance has been launched from',\n                GRAV_ROOT\n            )\n            ->setDescription('Installs Grav, plugin, or theme directly from a file or a URL')\n            ->setHelp('The <info>direct-install</info> command installs Grav, plugin, or theme directly from a file or a URL');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        if (!class_exists(ZipArchive::class)) {\n            $io->title('Direct Install');\n            $io->error('php-zip extension needs to be enabled!');\n\n            return 1;\n        }\n\n        // Making sure the destination is usable\n        $this->destination = realpath($input->getOption('destination'));\n\n        if (!Installer::isGravInstance($this->destination) ||\n            !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK])\n        ) {\n            $io->writeln('<red>ERROR</red>: ' . Installer::lastErrorMsg());\n\n            return 1;\n        }\n\n        $this->all_yes = $input->getOption('all-yes');\n\n        $package_file = $input->getArgument('package-file');\n\n        $question = new ConfirmationQuestion(\"Are you sure you want to direct-install <cyan>{$package_file}</cyan> [y|N] \", false);\n\n        $answer = $this->all_yes ? true : $io->askQuestion($question);\n\n        if (!$answer) {\n            $io->writeln('exiting...');\n            $io->newLine();\n\n            return 1;\n        }\n\n        $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);\n        $tmp_zip = $tmp_dir . uniqid('/Grav-', false);\n\n        $io->newLine();\n        $io->writeln(\"Preparing to install <cyan>{$package_file}</cyan>\");\n\n        $zip = null;\n        if (Response::isRemote($package_file)) {\n            $io->write('  |- Downloading package...     0%');\n            try {\n                $zip = GPM::downloadPackage($package_file, $tmp_zip);\n            } catch (RuntimeException $e) {\n                $io->newLine();\n                $io->writeln(\"  `- <red>ERROR: {$e->getMessage()}</red>\");\n                $io->newLine();\n\n                return 1;\n            }\n\n            if ($zip) {\n                $io->write(\"\\x0D\");\n                $io->write('  |- Downloading package...   100%');\n                $io->newLine();\n            }\n        } elseif (is_file($package_file)) {\n            $io->write('  |- Copying package...         0%');\n            $zip = GPM::copyPackage($package_file, $tmp_zip);\n            if ($zip) {\n                $io->write(\"\\x0D\");\n                $io->write('  |- Copying package...       100%');\n                $io->newLine();\n            }\n        }\n\n        if ($zip && file_exists($zip)) {\n            $tmp_source = $tmp_dir . uniqid('/Grav-', false);\n\n            $io->write('  |- Extracting package...    ');\n            $extracted = Installer::unZip($zip, $tmp_source);\n\n            if (!$extracted) {\n                $io->write(\"\\x0D\");\n                $io->writeln('  |- Extracting package...    <red>failed</red>');\n                Folder::delete($tmp_source);\n                Folder::delete($tmp_zip);\n\n                return 1;\n            }\n\n            $io->write(\"\\x0D\");\n            $io->writeln('  |- Extracting package...    <green>ok</green>');\n\n\n            $type = GPM::getPackageType($extracted);\n\n            if (!$type) {\n                $io->writeln(\"  '- <red>ERROR: Not a valid Grav package</red>\");\n                $io->newLine();\n                Folder::delete($tmp_source);\n                Folder::delete($tmp_zip);\n\n                return 1;\n            }\n\n            $blueprint = GPM::getBlueprints($extracted);\n            if ($blueprint) {\n                if (isset($blueprint['dependencies'])) {\n                    $dependencies = [];\n                    foreach ($blueprint['dependencies'] as $dependency) {\n                        if (is_array($dependency)) {\n                            if (isset($dependency['name'])) {\n                                $dependencies[] = $dependency['name'];\n                            }\n                            if (isset($dependency['github'])) {\n                                $dependencies[] = $dependency['github'];\n                            }\n                        } else {\n                            $dependencies[] = $dependency;\n                        }\n                    }\n                    $io->writeln('  |- Dependencies found...    <cyan>[' . implode(',', $dependencies) . ']</cyan>');\n\n                    $question = new ConfirmationQuestion(\"  |  '- Dependencies will not be satisfied. Continue ? [y|N] \", false);\n                    $answer = $this->all_yes ? true : $io->askQuestion($question);\n\n                    if (!$answer) {\n                        $io->writeln('exiting...');\n                        $io->newLine();\n                        Folder::delete($tmp_source);\n                        Folder::delete($tmp_zip);\n\n                        return 1;\n                    }\n                }\n            }\n\n            if ($type === 'grav') {\n                $io->write('  |- Checking destination...  ');\n                Installer::isValidDestination(GRAV_ROOT . '/system');\n                if (Installer::IS_LINK === Installer::lastErrorCode()) {\n                    $io->write(\"\\x0D\");\n                    $io->writeln('  |- Checking destination...  <yellow>symbolic link</yellow>');\n                    $io->writeln(\"  '- <red>ERROR: symlinks found...</red> <yellow>\" . GRAV_ROOT . '</yellow>');\n                    $io->newLine();\n                    Folder::delete($tmp_source);\n                    Folder::delete($tmp_zip);\n\n                    return 1;\n                }\n\n                $io->write(\"\\x0D\");\n                $io->writeln('  |- Checking destination...  <green>ok</green>');\n\n                $io->write('  |- Installing package...  ');\n\n                $this->upgradeGrav($zip, $extracted);\n            } else {\n                $name = GPM::getPackageName($extracted);\n\n                if (!$name) {\n                    $io->writeln('<red>ERROR: Name could not be determined.</red> Please specify with --name|-n');\n                    $io->newLine();\n                    Folder::delete($tmp_source);\n                    Folder::delete($tmp_zip);\n\n                    return 1;\n                }\n\n                $install_path = GPM::getInstallPath($type, $name);\n                $is_update = file_exists($install_path);\n\n                $io->write('  |- Checking destination...  ');\n\n                Installer::isValidDestination(GRAV_ROOT . DS . $install_path);\n                if (Installer::lastErrorCode() === Installer::IS_LINK) {\n                    $io->write(\"\\x0D\");\n                    $io->writeln('  |- Checking destination...  <yellow>symbolic link</yellow>');\n                    $io->writeln(\"  '- <red>ERROR: symlink found...</red>  <yellow>\" . GRAV_ROOT . DS . $install_path . '</yellow>');\n                    $io->newLine();\n                    Folder::delete($tmp_source);\n                    Folder::delete($tmp_zip);\n\n                    return 1;\n                }\n\n                $io->write(\"\\x0D\");\n                $io->writeln('  |- Checking destination...  <green>ok</green>');\n\n                $io->write('  |- Installing package...  ');\n\n                Installer::install(\n                    $zip,\n                    $this->destination,\n                    $options = [\n                        'install_path' => $install_path,\n                        'theme' => (($type === 'theme')),\n                        'is_update' => $is_update\n                    ],\n                    $extracted\n                );\n\n                // clear cache after successful upgrade\n                $this->clearCache();\n            }\n\n            Folder::delete($tmp_source);\n\n            $io->write(\"\\x0D\");\n\n            if (Installer::lastErrorCode()) {\n                $io->writeln(\"  '- <red>\" . Installer::lastErrorMsg() . '</red>');\n                $io->newLine();\n            } else {\n                $io->writeln('  |- Installing package...    <green>ok</green>');\n                $io->writeln(\"  '- <green>Success!</green>  \");\n                $io->newLine();\n            }\n        } else {\n            $io->writeln(\"  '- <red>ERROR: ZIP package could not be found</red>\");\n            Folder::delete($tmp_zip);\n\n            return 1;\n        }\n\n        Folder::delete($tmp_zip);\n\n        return 0;\n    }\n\n    /**\n     * @param string $zip\n     * @param string $folder\n     * @return void\n     */\n    private function upgradeGrav(string $zip, string $folder): void\n    {\n        if (!is_dir($folder)) {\n            Installer::setError('Invalid source folder');\n        }\n\n        try {\n            $script = $folder . '/system/install.php';\n            /** Install $installer */\n            if ((file_exists($script) && $install = include $script) && is_callable($install)) {\n                $install($zip);\n            } else {\n                throw new RuntimeException('Uploaded archive file is not a valid Grav update package');\n            }\n        } catch (Exception $e) {\n            Installer::setError($e->getMessage());\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Gpm/IndexCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Gpm\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Gpm;\n\nuse Grav\\Common\\GPM\\Remote\\AbstractPackageCollection;\nuse Grav\\Common\\GPM\\Remote\\Package;\nuse Grav\\Common\\GPM\\GPM;\nuse Grav\\Common\\GPM\\Remote\\Packages;\nuse Grav\\Common\\GPM\\Remote\\Plugins;\nuse Grav\\Common\\GPM\\Remote\\Themes;\nuse Grav\\Common\\Utils;\nuse Grav\\Console\\GpmCommand;\nuse Symfony\\Component\\Console\\Helper\\Table;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse function count;\n\n/**\n * Class IndexCommand\n * @package Grav\\Console\\Gpm\n */\nclass IndexCommand extends GpmCommand\n{\n    /** @var Packages */\n    protected $data;\n    /** @var GPM */\n    protected $gpm;\n    /** @var array */\n    protected $options;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('index')\n            ->addOption(\n                'force',\n                'f',\n                InputOption::VALUE_NONE,\n                'Force re-fetching the data from remote'\n            )\n            ->addOption(\n                'filter',\n                'F',\n                InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,\n                'Allows to limit the results based on one or multiple filters input. This can be either portion of a name/slug or a regex'\n            )\n            ->addOption(\n                'themes-only',\n                'T',\n                InputOption::VALUE_NONE,\n                'Filters the results to only Themes'\n            )\n            ->addOption(\n                'plugins-only',\n                'P',\n                InputOption::VALUE_NONE,\n                'Filters the results to only Plugins'\n            )\n            ->addOption(\n                'updates-only',\n                'U',\n                InputOption::VALUE_NONE,\n                'Filters the results to Updatable Themes and Plugins only'\n            )\n            ->addOption(\n                'installed-only',\n                'I',\n                InputOption::VALUE_NONE,\n                'Filters the results to only the Themes and Plugins you have installed'\n            )\n            ->addOption(\n                'sort',\n                's',\n                InputOption::VALUE_REQUIRED,\n                'Allows to sort (ASC) the results. SORT can be either \"name\", \"slug\", \"author\", \"date\"',\n                'date'\n            )\n            ->addOption(\n                'desc',\n                'D',\n                InputOption::VALUE_NONE,\n                'Reverses the order of the output.'\n            )\n            ->addOption(\n                'enabled',\n                'e',\n                InputOption::VALUE_NONE,\n                'Filters the results to only enabled Themes and Plugins.'\n            )\n            ->addOption(\n                'disabled',\n                'd',\n                InputOption::VALUE_NONE,\n                'Filters the results to only disabled Themes and Plugins.'\n            )\n            ->setDescription('Lists the plugins and themes available for installation')\n            ->setHelp('The <info>index</info> command lists the plugins and themes available for installation')\n        ;\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $this->options = $input->getOptions();\n        $this->gpm = new GPM($this->options['force']);\n        $this->displayGPMRelease();\n        $this->data = $this->gpm->getRepository();\n\n        $data = $this->filter($this->data);\n\n        $io = $this->getIO();\n\n        if (count($data) === 0) {\n            $io->writeln('No data was found in the GPM repository stored locally.');\n            $io->writeln('Please try clearing cache and running the <green>bin/gpm index -f</green> command again');\n            $io->writeln('If this doesn\\'t work try tweaking your GPM system settings.');\n            $io->newLine();\n            $io->writeln('For more help go to:');\n            $io->writeln(' -> <yellow>https://learn.getgrav.org/troubleshooting/common-problems#cannot-connect-to-the-gpm</yellow>');\n\n            return 1;\n        }\n\n        foreach ($data as $type => $packages) {\n            $io->writeln('<green>' . strtoupper($type) . '</green> [ ' . count($packages) . ' ]');\n\n            $packages = $this->sort($packages);\n\n            if (!empty($packages)) {\n                $io->section('Packages table');\n                $table = new Table($io);\n                $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Installed', 'Enabled']);\n\n                $index = 0;\n                foreach ($packages as $slug => $package) {\n                    $row = [\n                        'Count' => $index++ + 1,\n                        'Name' => '<cyan>' . Utils::truncate($package->name, 20, false, ' ', '...') . '</cyan> ',\n                        'Slug' => $slug,\n                        'Version'=> $this->version($package),\n                        'Installed' => $this->installed($package),\n                        'Enabled' => $this->enabled($package),\n                    ];\n\n                    $table->addRow($row);\n                }\n\n                $table->render();\n            }\n\n            $io->newLine();\n        }\n\n        $io->writeln('You can either get more informations about a package by typing:');\n        $io->writeln(\"    <green>{$this->argv} info <cyan><package></cyan></green>\");\n        $io->newLine();\n        $io->writeln('Or you can install a package by typing:');\n        $io->writeln(\"    <green>{$this->argv} install <cyan><package></cyan></green>\");\n        $io->newLine();\n\n        return 0;\n    }\n\n    /**\n     * @param Package $package\n     * @return string\n     */\n    private function version(Package $package): string\n    {\n        $list      = $this->gpm->{'getUpdatable' . ucfirst($package->package_type)}();\n        $package   = $list[$package->slug] ?? $package;\n        $type      = ucfirst(preg_replace('/s$/', '', $package->package_type));\n        $updatable = $this->gpm->{'is' . $type . 'Updatable'}($package->slug);\n        $installed = $this->gpm->{'is' . $type . 'Installed'}($package->slug);\n        $local     = $this->gpm->{'getInstalled' . $type}($package->slug);\n\n        if (!$installed || !$updatable) {\n            $version   = $installed ? $local->version : $package->version;\n            return \"v<green>{$version}</green>\";\n        }\n\n        return \"v<red>{$package->version}</red> <cyan>-></cyan> v<green>{$package->available}</green>\";\n    }\n\n    /**\n     * @param Package $package\n     * @return string\n     */\n    private function installed(Package $package): string\n    {\n        $type      = ucfirst(preg_replace('/s$/', '', $package->package_type));\n        $method = 'is' . $type . 'Installed';\n        $installed = $this->gpm->{$method}($package->slug);\n\n        return !$installed ? '<magenta>not installed</magenta>' : '<cyan>installed</cyan>';\n    }\n\n    /**\n     * @param Package $package\n     * @return string\n     */\n    private function enabled(Package $package): string\n    {\n        $type      = ucfirst(preg_replace('/s$/', '', $package->package_type));\n        $method = 'is' . $type . 'Installed';\n        $installed = $this->gpm->{$method}($package->slug);\n\n        $result = '';\n        if ($installed) {\n            $method = 'is' . $type . 'Enabled';\n            $enabled = $this->gpm->{$method}($package->slug);\n            if ($enabled === true) {\n                $result = '<cyan>enabled</cyan>';\n            } elseif ($enabled === false) {\n                $result = '<red>disabled</red>';\n            }\n        }\n\n        return $result;\n    }\n\n    /**\n     * @param Packages $data\n     * @return Packages\n     */\n    public function filter(Packages $data): Packages\n    {\n        // filtering and sorting\n        if ($this->options['plugins-only']) {\n            unset($data['themes']);\n        }\n        if ($this->options['themes-only']) {\n            unset($data['plugins']);\n        }\n\n        $filter = [\n            $this->options['desc'],\n            $this->options['disabled'],\n            $this->options['enabled'],\n            $this->options['filter'],\n            $this->options['installed-only'],\n            $this->options['updates-only'],\n        ];\n\n        if (count(array_filter($filter))) {\n            foreach ($data as $type => $packages) {\n                foreach ($packages as $slug => $package) {\n                    $filter = true;\n\n                    // Filtering by string\n                    if ($this->options['filter']) {\n                        $filter = preg_grep('/(' . implode('|', $this->options['filter']) . ')/i', [$slug, $package->name]);\n                    }\n\n                    // Filtering updatables only\n                    if ($filter && ($this->options['installed-only'] || $this->options['enabled'] || $this->options['disabled'])) {\n                        $method = ucfirst(preg_replace('/s$/', '', $package->package_type));\n                        $function = 'is' . $method . 'Installed';\n                        $filter = $this->gpm->{$function}($package->slug);\n                    }\n\n                    // Filtering updatables only\n                    if ($filter && $this->options['updates-only']) {\n                        $method = ucfirst(preg_replace('/s$/', '', $package->package_type));\n                        $function = 'is' . $method . 'Updatable';\n                        $filter = $this->gpm->{$function}($package->slug);\n                    }\n\n                    // Filtering enabled only\n                    if ($filter && $this->options['enabled']) {\n                        $method = ucfirst(preg_replace('/s$/', '', $package->package_type));\n\n                        // Check if packaged is enabled.\n                        $function = 'is' . $method . 'Enabled';\n                        $filter = $this->gpm->{$function}($package->slug);\n                    }\n\n                    // Filtering disabled only\n                    if ($filter && $this->options['disabled']) {\n                        $method = ucfirst(preg_replace('/s$/', '', $package->package_type));\n\n                        // Check if package is disabled.\n                        $function = 'is' . $method . 'Enabled';\n                        $enabled_filter = $this->gpm->{$function}($package->slug);\n\n                        // Apply filtering results.\n                        if (!( $enabled_filter === false)) {\n                            $filter = false;\n                        }\n                    }\n\n                    if (!$filter) {\n                        unset($data[$type][$slug]);\n                    }\n                }\n            }\n        }\n\n        return $data;\n    }\n\n    /**\n     * @param AbstractPackageCollection|Plugins|Themes $packages\n     * @return array\n     */\n    public function sort(AbstractPackageCollection $packages): array\n    {\n        $key = $this->options['sort'];\n\n        // Sorting only works once.\n        return $packages->sort(\n            function ($a, $b) use ($key) {\n                switch ($key) {\n                    case 'author':\n                        return strcmp($a->{$key}['name'], $b->{$key}['name']);\n                    default:\n                        return strcmp($a->$key, $b->$key);\n                }\n            },\n            $this->options['desc'] ? true : false\n        );\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Gpm/InfoCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Gpm\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Gpm;\n\nuse Grav\\Common\\GPM\\GPM;\nuse Grav\\Console\\GpmCommand;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Question\\ConfirmationQuestion;\nuse function strlen;\n\n/**\n * Class InfoCommand\n * @package Grav\\Console\\Gpm\n */\nclass InfoCommand extends GpmCommand\n{\n    /** @var array */\n    protected $data;\n    /** @var GPM */\n    protected $gpm;\n    /** @var string */\n    protected $all_yes;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('info')\n            ->addOption(\n                'force',\n                'f',\n                InputOption::VALUE_NONE,\n                'Force fetching the new data remotely'\n            )\n            ->addOption(\n                'all-yes',\n                'y',\n                InputOption::VALUE_NONE,\n                'Assumes yes (or best approach) instead of prompting'\n            )\n            ->addArgument(\n                'package',\n                InputArgument::REQUIRED,\n                'The package of which more informations are desired. Use the \"index\" command for a list of packages'\n            )\n            ->setDescription('Shows more informations about a package')\n            ->setHelp('The <info>info</info> shows more information about a package');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $this->gpm = new GPM($input->getOption('force'));\n\n        $this->all_yes = $input->getOption('all-yes');\n\n        $this->displayGPMRelease();\n\n        $foundPackage = $this->gpm->findPackage($input->getArgument('package'));\n\n        if (!$foundPackage) {\n            $io->writeln(\"The package <cyan>'{$input->getArgument('package')}'</cyan> was not found in the Grav repository.\");\n            $io->newLine();\n            $io->writeln('You can list all the available packages by typing:');\n            $io->writeln(\"    <green>{$this->argv} index</green>\");\n            $io->newLine();\n\n            return 1;\n        }\n\n        $io->writeln(\"Found package <cyan>'{$input->getArgument('package')}'</cyan> under the '<green>\" . ucfirst($foundPackage->package_type) . \"</green>' section\");\n        $io->newLine();\n        $io->writeln(\"<cyan>{$foundPackage->name}</cyan> [{$foundPackage->slug}]\");\n        $io->writeln(str_repeat('-', strlen($foundPackage->name) + strlen($foundPackage->slug) + 3));\n        $io->writeln('<white>' . strip_tags($foundPackage->description_plain) . '</white>');\n        $io->newLine();\n\n        $packageURL = '';\n        if (isset($foundPackage->author['url'])) {\n            $packageURL = '<' . $foundPackage->author['url'] . '>';\n        }\n\n        $io->writeln('<green>' . str_pad(\n            'Author',\n            12\n        ) . ':</green> ' . $foundPackage->author['name'] . ' <' . $foundPackage->author['email'] . '> ' . $packageURL);\n\n        foreach ([\n                     'version',\n                     'keywords',\n                     'date',\n                     'homepage',\n                     'demo',\n                     'docs',\n                     'guide',\n                     'repository',\n                     'bugs',\n                     'zipball_url',\n                     'license'\n                 ] as $info) {\n            if (isset($foundPackage->{$info})) {\n                $name = ucfirst($info);\n                $data = $foundPackage->{$info};\n\n                if ($info === 'zipball_url') {\n                    $name = 'Download';\n                }\n\n                if ($info === 'date') {\n                    $name = 'Last Update';\n                    $data = date('D, j M Y, H:i:s, P ', strtotime($data));\n                }\n\n                $name = str_pad($name, 12);\n                $io->writeln(\"<green>{$name}:</green> {$data}\");\n            }\n        }\n\n        $type = rtrim($foundPackage->package_type, 's');\n        $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug);\n        $installed = $this->gpm->{'is' . $type . 'Installed'}($foundPackage->slug);\n\n        // display current version if installed and different\n        if ($installed && $updatable) {\n            $local = $this->gpm->{'getInstalled'. $type}($foundPackage->slug);\n            $io->newLine();\n            $io->writeln(\"Currently installed version: <magenta>{$local->version}</magenta>\");\n            $io->newLine();\n        }\n\n        // display changelog information\n        $question = new ConfirmationQuestion(\n            'Would you like to read the changelog? [y|N] ',\n            false\n        );\n        $answer = $this->all_yes ? true : $io->askQuestion($question);\n\n        if ($answer) {\n            $changelog = $foundPackage->changelog;\n\n            $io->newLine();\n            foreach ($changelog as $version => $log) {\n                $title = $version . ' [' . $log['date'] . ']';\n                $content = preg_replace_callback('/\\d\\.\\s\\[\\]\\(#(.*)\\)/', static function ($match) {\n                    return \"\\n\" . ucfirst($match[1]) . ':';\n                }, $log['content']);\n\n                $io->writeln(\"<cyan>{$title}</cyan>\");\n                $io->writeln(str_repeat('-', strlen($title)));\n                $io->writeln($content);\n                $io->newLine();\n\n                $question = new ConfirmationQuestion('Press [ENTER] to continue or [q] to quit ', true);\n                $answer = $this->all_yes ? false : $io->askQuestion($question);\n                if (!$answer) {\n                    break;\n                }\n                $io->newLine();\n            }\n        }\n\n        $io->newLine();\n\n        if ($installed && $updatable) {\n            $io->writeln('You can update this package by typing:');\n            $io->writeln(\"    <green>{$this->argv} update</green> <cyan>{$foundPackage->slug}</cyan>\");\n        } else {\n            $io->writeln('You can install this package by typing:');\n            $io->writeln(\"    <green>{$this->argv} install</green> <cyan>{$foundPackage->slug}</cyan>\");\n        }\n\n        $io->newLine();\n\n        return 0;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Gpm/InstallCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Gpm\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Gpm;\n\nuse Exception;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\HTTP\\Response;\nuse Grav\\Common\\GPM\\GPM;\nuse Grav\\Common\\GPM\\Installer;\nuse Grav\\Common\\GPM\\Licenses;\nuse Grav\\Common\\GPM\\Remote\\Package;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse Grav\\Console\\GpmCommand;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Question\\ConfirmationQuestion;\nuse ZipArchive;\nuse function array_key_exists;\nuse function count;\nuse function define;\n\ndefine('GIT_REGEX', '/http[s]?:\\/\\/(?:.*@)?(github|bitbucket)(?:.org|.com)\\/.*\\/(.*)/');\n\n/**\n * Class InstallCommand\n * @package Grav\\Console\\Gpm\n */\nclass InstallCommand extends GpmCommand\n{\n    /** @var array */\n    protected $data;\n    /** @var GPM */\n    protected $gpm;\n    /** @var string */\n    protected $destination;\n    /** @var string */\n    protected $file;\n    /** @var string */\n    protected $tmp;\n    /** @var bool */\n    protected $use_symlinks;\n    /** @var array */\n    protected $demo_processing = [];\n    /** @var string */\n    protected $all_yes;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('install')\n            ->addOption(\n                'force',\n                'f',\n                InputOption::VALUE_NONE,\n                'Force re-fetching the data from remote'\n            )\n            ->addOption(\n                'all-yes',\n                'y',\n                InputOption::VALUE_NONE,\n                'Assumes yes (or best approach) instead of prompting'\n            )\n            ->addOption(\n                'destination',\n                'd',\n                InputOption::VALUE_OPTIONAL,\n                'The destination where the package should be installed at. By default this would be where the grav instance has been launched from',\n                GRAV_ROOT\n            )\n            ->addArgument(\n                'package',\n                InputArgument::IS_ARRAY | InputArgument::REQUIRED,\n                'Package(s) to install. Use \"bin/gpm index\" to list packages. Use \"bin/gpm direct-install\" to install a specific version'\n            )\n            ->setDescription('Performs the installation of plugins and themes')\n            ->setHelp('The <info>install</info> command allows to install plugins and themes');\n    }\n\n    /**\n     * Allows to set the GPM object, used for testing the class\n     *\n     * @param GPM $gpm\n     */\n    public function setGpm(GPM $gpm): void\n    {\n        $this->gpm = $gpm;\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        if (!class_exists(ZipArchive::class)) {\n            $io->title('GPM Install');\n            $io->error('php-zip extension needs to be enabled!');\n\n            return 1;\n        }\n\n        $this->gpm = new GPM($input->getOption('force'));\n\n        $this->all_yes = $input->getOption('all-yes');\n\n        $this->displayGPMRelease();\n\n        $this->destination = realpath($input->getOption('destination'));\n\n        $packages = array_map('strtolower', $input->getArgument('package'));\n        $this->data = $this->gpm->findPackages($packages);\n        $this->loadLocalConfig();\n\n        if (!Installer::isGravInstance($this->destination) ||\n            !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK])\n        ) {\n            $io->writeln('<red>ERROR</red>: ' . Installer::lastErrorMsg());\n\n            return 1;\n        }\n\n        $io->newLine();\n\n        if (!$this->data['total']) {\n            $io->writeln('Nothing to install.');\n            $io->newLine();\n\n            return 0;\n        }\n\n        if (count($this->data['not_found'])) {\n            $io->writeln('These packages were not found on Grav: <red>' . implode(\n                '</red>, <red>',\n                array_keys($this->data['not_found'])\n            ) . '</red>');\n        }\n\n        unset($this->data['not_found'], $this->data['total']);\n\n        if (null !== $this->local_config) {\n            // Symlinks available, ask if Grav should use them\n            $this->use_symlinks = false;\n            $question = new ConfirmationQuestion('Should Grav use the symlinks if available? [y|N] ', false);\n\n            $answer = $this->all_yes ? false : $io->askQuestion($question);\n\n            if ($answer) {\n                $this->use_symlinks = true;\n            }\n        }\n\n        $io->newLine();\n\n        try {\n            $dependencies = $this->gpm->getDependencies($packages);\n        } catch (Exception $e) {\n            //Error out if there are incompatible packages requirements and tell which ones, and what to do\n            //Error out if there is any error in parsing the dependencies and their versions, and tell which one is broken\n            $io->writeln(\"<red>{$e->getMessage()}</red>\");\n\n            return 1;\n        }\n\n        if ($dependencies) {\n            try {\n                $this->installDependencies($dependencies, 'install', 'The following dependencies need to be installed...');\n                $this->installDependencies($dependencies, 'update', 'The following dependencies need to be updated...');\n                $this->installDependencies($dependencies, 'ignore', \"The following dependencies can be updated as there is a newer version, but it's not mandatory...\", false);\n            } catch (Exception $e) {\n                $io->writeln('<red>Installation aborted</red>');\n\n                return 1;\n            }\n\n            $io->writeln('<green>Dependencies are OK</green>');\n            $io->newLine();\n        }\n\n\n        //We're done installing dependencies. Install the actual packages\n        foreach ($this->data as $data) {\n            foreach ($data as $package_name => $package) {\n                if (array_key_exists($package_name, $dependencies)) {\n                    $io->writeln(\"<green>Package {$package_name} already installed as dependency</green>\");\n                } else {\n                    $is_valid_destination = Installer::isValidDestination($this->destination . DS . $package->install_path);\n                    if ($is_valid_destination || Installer::lastErrorCode() == Installer::NOT_FOUND) {\n                        $this->processPackage($package, false);\n                    } else {\n                        if (Installer::lastErrorCode() == Installer::EXISTS) {\n                            try {\n                                $this->askConfirmationIfMajorVersionUpdated($package);\n                                $this->gpm->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package->slug, $package->available, array_keys($data));\n                            } catch (Exception $e) {\n                                $io->writeln(\"<red>{$e->getMessage()}</red>\");\n\n                                return 1;\n                            }\n\n                            $question = new ConfirmationQuestion(\"The package <cyan>{$package_name}</cyan> is already installed, overwrite? [y|N] \", false);\n                            $answer = $this->all_yes ? true : $io->askQuestion($question);\n\n                            if ($answer) {\n                                $is_update = true;\n                                $this->processPackage($package, $is_update);\n                            } else {\n                                $io->writeln(\"<yellow>Package {$package_name} not overwritten</yellow>\");\n                            }\n                        } else {\n                            if (Installer::lastErrorCode() == Installer::IS_LINK) {\n                                $io->writeln(\"<red>Cannot overwrite existing symlink for </red><cyan>{$package_name}</cyan>\");\n                                $io->newLine();\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        if (count($this->demo_processing) > 0) {\n            foreach ($this->demo_processing as $package) {\n                $this->installDemoContent($package);\n            }\n        }\n\n        // clear cache after successful upgrade\n        $this->clearCache();\n\n        return 0;\n    }\n\n    /**\n     * If the package is updated from an older major release, show warning and ask confirmation\n     *\n     * @param Package $package\n     * @return void\n     */\n    public function askConfirmationIfMajorVersionUpdated(Package $package): void\n    {\n        $io = $this->getIO();\n        $package_name = $package->name;\n        $new_version = $package->available ?: $this->gpm->getLatestVersionOfPackage($package->slug);\n        $old_version = $package->version;\n\n        $major_version_changed = explode('.', $new_version)[0] !== explode('.', $old_version)[0];\n\n        if ($major_version_changed) {\n            if ($this->all_yes) {\n                $io->writeln(\"The package <cyan>{$package_name}</cyan> will be updated to a new major version <green>{$new_version}</green>, from <magenta>{$old_version}</magenta>\");\n                return;\n            }\n\n            $question = new ConfirmationQuestion(\"The package <cyan>{$package_name}</cyan> will be updated to a new major version <green>{$new_version}</green>, from <magenta>{$old_version}</magenta>. Be sure to read what changed with the new major release. Continue? [y|N] \", false);\n\n            if (!$io->askQuestion($question)) {\n                $io->writeln(\"<yellow>Package {$package_name} not updated</yellow>\");\n                exit;\n            }\n        }\n    }\n\n    /**\n     * Given a $dependencies list, filters their type according to $type and\n     * shows $message prior to listing them to the user. Then asks the user a confirmation prior\n     * to installing them.\n     *\n     * @param array  $dependencies The dependencies array\n     * @param string $type         The type of dependency to show: install, update, ignore\n     * @param string $message      A message to be shown prior to listing the dependencies\n     * @param bool   $required     A flag that determines if the installation is required or optional\n     * @return void\n     * @throws Exception\n     */\n    public function installDependencies(array $dependencies, string $type, string $message, bool $required = true): void\n    {\n        $io = $this->getIO();\n        $packages = array_filter($dependencies, static function ($action) use ($type) {\n            return $action === $type;\n        });\n        if (count($packages) > 0) {\n            $io->writeln($message);\n\n            foreach ($packages as $dependencyName => $dependencyVersion) {\n                $io->writeln(\"  |- Package <cyan>{$dependencyName}</cyan>\");\n            }\n\n            $io->newLine();\n\n            if ($type === 'install') {\n                $questionAction = 'Install';\n            } else {\n                $questionAction = 'Update';\n            }\n\n            if (count($packages) === 1) {\n                $questionArticle = 'this';\n            } else {\n                $questionArticle = 'these';\n            }\n\n            if (count($packages) === 1) {\n                $questionNoun = 'package';\n            } else {\n                $questionNoun = 'packages';\n            }\n\n            $question = new ConfirmationQuestion(\"{$questionAction} {$questionArticle} {$questionNoun}? [Y|n] \", true);\n            $answer = $this->all_yes ? true : $io->askQuestion($question);\n\n            if ($answer) {\n                foreach ($packages as $dependencyName => $dependencyVersion) {\n                    $package = $this->gpm->findPackage($dependencyName);\n                    $this->processPackage($package, $type === 'update');\n                }\n                $io->newLine();\n            } elseif ($required) {\n                throw new Exception();\n            }\n        }\n    }\n\n    /**\n     * @param Package|null $package\n     * @param bool    $is_update      True if the package is an update\n     * @return void\n     */\n    private function processPackage(?Package $package, bool $is_update = false): void\n    {\n        $io = $this->getIO();\n\n        if (!$package) {\n            $io->writeln('<red>Package not found on the GPM!</red>');\n            $io->newLine();\n            return;\n        }\n\n        $symlink = false;\n        if ($this->use_symlinks) {\n            if (!isset($package->version) || $this->getSymlinkSource($package)) {\n                $symlink = true;\n            }\n        }\n\n        $symlink ? $this->processSymlink($package) : $this->processGpm($package, $is_update);\n\n        $this->processDemo($package);\n    }\n\n    /**\n     * Add package to the queue to process the demo content, if demo content exists\n     *\n     * @param Package $package\n     * @return void\n     */\n    private function processDemo(Package $package): void\n    {\n        $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo';\n        if (file_exists($demo_dir)) {\n            $this->demo_processing[] = $package;\n        }\n    }\n\n    /**\n     * Prompt to install the demo content of a package\n     *\n     * @param Package $package\n     * @return void\n     */\n    private function installDemoContent(Package $package): void\n    {\n        $io = $this->getIO();\n        $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo';\n\n        if (file_exists($demo_dir)) {\n            $dest_dir = $this->destination . DS . 'user';\n            $pages_dir = $dest_dir . DS . 'pages';\n\n            // Demo content exists, prompt to install it.\n            $io->writeln(\"<white>Attention: </white><cyan>{$package->name}</cyan> contains demo content\");\n\n            $question = new ConfirmationQuestion('Do you wish to install this demo content? [y|N] ', false);\n\n            $answer = $io->askQuestion($question);\n\n            if (!$answer) {\n                $io->writeln(\"  '- <red>Skipped!</red>  \");\n                $io->newLine();\n\n                return;\n            }\n\n            // if pages folder exists in demo\n            if (file_exists($demo_dir . DS . 'pages')) {\n                $pages_backup = 'pages.' . date('m-d-Y-H-i-s');\n                $question = new ConfirmationQuestion('This will backup your current `user/pages` folder to `user/' . $pages_backup . '`, continue? [y|N]', false);\n                $answer = $this->all_yes ? true : $io->askQuestion($question);\n\n                if (!$answer) {\n                    $io->writeln(\"  '- <red>Skipped!</red>  \");\n                    $io->newLine();\n\n                    return;\n                }\n\n                // backup current pages folder\n                if (file_exists($dest_dir)) {\n                    if (rename($pages_dir, $dest_dir . DS . $pages_backup)) {\n                        $io->writeln('  |- Backing up pages...    <green>ok</green>');\n                    } else {\n                        $io->writeln('  |- Backing up pages...    <red>failed</red>');\n                    }\n                }\n            }\n\n            // Confirmation received, copy over the data\n            $io->writeln('  |- Installing demo content...    <green>ok</green>                             ');\n            Folder::rcopy($demo_dir, $dest_dir);\n            $io->writeln(\"  '- <green>Success!</green>  \");\n            $io->newLine();\n        }\n    }\n\n    /**\n     * @param Package $package\n     * @return array|false\n     */\n    private function getGitRegexMatches(Package $package)\n    {\n        if (isset($package->repository)) {\n            $repository = $package->repository;\n        } else {\n            return false;\n        }\n\n        preg_match(GIT_REGEX, $repository, $matches);\n\n        return $matches;\n    }\n\n    /**\n     * @param Package $package\n     * @return string|false\n     */\n    private function getSymlinkSource(Package $package)\n    {\n        $matches = $this->getGitRegexMatches($package);\n\n        foreach ($this->local_config as $paths) {\n            if (Utils::endsWith($matches[2], '.git')) {\n                $repo_dir = preg_replace('/\\.git$/', '', $matches[2]);\n            } else {\n                $repo_dir = $matches[2];\n            }\n\n            $paths = (array) $paths;\n            foreach ($paths as $repo) {\n                $path = rtrim($repo, '/') . '/' . $repo_dir;\n                if (file_exists($path)) {\n                    return $path;\n                }\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * @param Package $package\n     * @return void\n     */\n    private function processSymlink(Package $package): void\n    {\n        $io = $this->getIO();\n\n        exec('cd ' . escapeshellarg($this->destination));\n\n        $to = $this->destination . DS . $package->install_path;\n        $from = $this->getSymlinkSource($package);\n\n        $io->writeln(\"Preparing to Symlink <cyan>{$package->name}</cyan>\");\n        $io->write('  |- Checking source...  ');\n\n        if (file_exists($from)) {\n            $io->writeln('<green>ok</green>');\n\n            $io->write('  |- Checking destination...  ');\n            $checks = $this->checkDestination($package);\n\n            if (!$checks) {\n                $io->writeln(\"  '- <red>Installation failed or aborted.</red>\");\n                $io->newLine();\n            } elseif (file_exists($to)) {\n                $io->writeln(\"  '- <red>Symlink cannot overwrite an existing package, please remove first</red>\");\n                $io->newLine();\n            } else {\n                symlink($from, $to);\n\n                // extra white spaces to clear out the buffer properly\n                $io->writeln('  |- Symlinking package...    <green>ok</green>                             ');\n                $io->writeln(\"  '- <green>Success!</green>  \");\n                $io->newLine();\n            }\n\n            return;\n        }\n\n        $io->writeln('<red>not found!</red>');\n        $io->writeln(\"  '- <red>Installation failed or aborted.</red>\");\n    }\n\n    /**\n     * @param Package $package\n     * @param bool $is_update\n     * @return bool\n     */\n    private function processGpm(Package $package, bool $is_update = false)\n    {\n        $io = $this->getIO();\n\n        $version = $package->available ?? $package->version;\n        $license = Licenses::get($package->slug);\n\n        $io->writeln(\"Preparing to install <cyan>{$package->name}</cyan> [v{$version}]\");\n\n        $io->write('  |- Downloading package...     0%');\n        $this->file = $this->downloadPackage($package, $license);\n\n        if (!$this->file) {\n            $io->writeln(\"  '- <red>Installation failed or aborted.</red>\");\n            $io->newLine();\n\n            return false;\n        }\n\n        $io->write('  |- Checking destination...  ');\n        $checks = $this->checkDestination($package);\n\n        if (!$checks) {\n            $io->writeln(\"  '- <red>Installation failed or aborted.</red>\");\n            $io->newLine();\n        } else {\n            $io->write('  |- Installing package...  ');\n            $installation = $this->installPackage($package, $is_update);\n            if (!$installation) {\n                $io->writeln(\"  '- <red>Installation failed or aborted.</red>\");\n                $io->newLine();\n            } else {\n                $io->writeln(\"  '- <green>Success!</green>  \");\n                $io->newLine();\n\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * @param Package $package\n     * @param string|null $license\n     * @return string|null\n     */\n    private function downloadPackage(Package $package, string $license = null)\n    {\n        $io = $this->getIO();\n\n        $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);\n        $this->tmp = $tmp_dir . '/Grav-' . uniqid();\n        $filename = $package->slug . Utils::basename($package->zipball_url);\n        $filename = preg_replace('/[\\\\\\\\\\/:\"*?&<>|]+/m', '-', $filename);\n        $query = '';\n\n        if (!empty($package->premium)) {\n            $query = json_encode(array_merge(\n                $package->premium,\n                [\n                    'slug' => $package->slug,\n                    'filename' => $package->premium['filename'],\n                    'license_key' => $license,\n                    'sid' => md5(GRAV_ROOT)\n                ]\n            ));\n\n            $query = '?d=' . base64_encode($query);\n        }\n\n        try {\n            $output = Response::get($package->zipball_url . $query, [], [$this, 'progress']);\n        } catch (Exception $e) {\n            if (!empty($package->premium) && $e->getCode() === 401) {\n                $message = '<yellow>Unauthorized Premium License Key</yellow>';\n            } else {\n                $message = $e->getMessage();\n            }\n\n            $error = str_replace(\"\\n\", \"\\n  |  '- \", $message);\n            $io->write(\"\\x0D\");\n            // extra white spaces to clear out the buffer properly\n            $io->writeln('  |- Downloading package...    <red>error</red>                             ');\n            $io->writeln(\"  |  '- \" . $error);\n\n            return null;\n        }\n\n        Folder::create($this->tmp);\n\n        $io->write(\"\\x0D\");\n        $io->write('  |- Downloading package...   100%');\n        $io->newLine();\n\n        file_put_contents($this->tmp . DS . $filename, $output);\n\n        return $this->tmp . DS . $filename;\n    }\n\n    /**\n     * @param Package $package\n     * @return bool\n     */\n    private function checkDestination(Package $package): bool\n    {\n        $io = $this->getIO();\n\n        Installer::isValidDestination($this->destination . DS . $package->install_path);\n\n        if (Installer::lastErrorCode() === Installer::IS_LINK) {\n            $io->write(\"\\x0D\");\n            $io->writeln('  |- Checking destination...  <yellow>symbolic link</yellow>');\n\n            if ($this->all_yes) {\n                $io->writeln(\"  |     '- <yellow>Skipped automatically.</yellow>\");\n\n                return false;\n            }\n\n            $question = new ConfirmationQuestion(\n                \"  |  '- Destination has been detected as symlink, delete symbolic link first? [y|N] \",\n                false\n            );\n            $answer = $io->askQuestion($question);\n\n            if (!$answer) {\n                $io->writeln(\"  |     '- <red>You decided to not delete the symlink automatically.</red>\");\n\n                return false;\n            }\n\n            unlink($this->destination . DS . $package->install_path);\n        }\n\n        $io->write(\"\\x0D\");\n        $io->writeln('  |- Checking destination...  <green>ok</green>');\n\n        return true;\n    }\n\n    /**\n     * Install a package\n     *\n     * @param Package $package\n     * @param bool $is_update True if it's an update. False if it's an install\n     * @return bool\n     */\n    private function installPackage(Package $package, bool $is_update = false): bool\n    {\n        $io = $this->getIO();\n\n        $type = $package->package_type;\n\n        Installer::install($this->file, $this->destination, ['install_path' => $package->install_path, 'theme' => $type === 'themes', 'is_update' => $is_update]);\n        $error_code = Installer::lastErrorCode();\n        Folder::delete($this->tmp);\n\n        if ($error_code) {\n            $io->write(\"\\x0D\");\n            // extra white spaces to clear out the buffer properly\n            $io->writeln('  |- Installing package...    <red>error</red>                             ');\n            $io->writeln(\"  |  '- \" . Installer::lastErrorMsg());\n\n            return false;\n        }\n\n        $message = Installer::getMessage();\n        if ($message) {\n            $io->write(\"\\x0D\");\n            // extra white spaces to clear out the buffer properly\n            $io->writeln(\"  |- {$message}\");\n        }\n\n        $io->write(\"\\x0D\");\n        // extra white spaces to clear out the buffer properly\n        $io->writeln('  |- Installing package...    <green>ok</green>                             ');\n\n        return true;\n    }\n\n    /**\n     * @param array $progress\n     * @return void\n     */\n    public function progress(array $progress): void\n    {\n        $io = $this->getIO();\n\n        $io->write(\"\\x0D\");\n        $io->write('  |- Downloading package... ' . str_pad(\n            $progress['percent'],\n            5,\n            ' ',\n            STR_PAD_LEFT\n        ) . '%');\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Gpm/SelfupgradeCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Gpm\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Gpm;\n\nuse Exception;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\HTTP\\Response;\nuse Grav\\Common\\GPM\\Installer;\nuse Grav\\Common\\GPM\\Upgrader;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\GpmCommand;\nuse Grav\\Installer\\Install;\nuse RuntimeException;\nuse Symfony\\Component\\Console\\Input\\ArrayInput;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Question\\ConfirmationQuestion;\nuse ZipArchive;\nuse function is_callable;\nuse function strlen;\n\n/**\n * Class SelfupgradeCommand\n * @package Grav\\Console\\Gpm\n */\nclass SelfupgradeCommand extends GpmCommand\n{\n    /** @var array */\n    protected $data;\n    /** @var string */\n    protected $file;\n    /** @var array */\n    protected $types = ['plugins', 'themes'];\n    /** @var string|null */\n    private $tmp;\n    /** @var Upgrader */\n    private $upgrader;\n\n    /** @var string */\n    protected $all_yes;\n    /** @var string */\n    protected $overwrite;\n    /** @var int */\n    protected $timeout;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('self-upgrade')\n            ->setAliases(['selfupgrade', 'selfupdate'])\n            ->addOption(\n                'force',\n                'f',\n                InputOption::VALUE_NONE,\n                'Force re-fetching the data from remote'\n            )\n            ->addOption(\n                'all-yes',\n                'y',\n                InputOption::VALUE_NONE,\n                'Assumes yes (or best approach) instead of prompting'\n            )\n            ->addOption(\n                'overwrite',\n                'o',\n                InputOption::VALUE_NONE,\n                'Option to overwrite packages if they already exist'\n            )\n            ->addOption(\n                'timeout',\n                't',\n                InputOption::VALUE_OPTIONAL,\n                'Option to set the timeout in seconds when downloading the update (0 for no timeout)',\n                30\n            )\n            ->setDescription('Detects and performs an update of Grav itself when available')\n            ->setHelp('The <info>update</info> command updates Grav itself when a new version is available');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        if (!class_exists(ZipArchive::class)) {\n            $io->title('GPM Self Upgrade');\n            $io->error('php-zip extension needs to be enabled!');\n\n            return 1;\n        }\n\n        $this->upgrader = new Upgrader($input->getOption('force'));\n        $this->all_yes = $input->getOption('all-yes');\n        $this->overwrite = $input->getOption('overwrite');\n        $this->timeout = (int) $input->getOption('timeout');\n\n        $this->displayGPMRelease();\n\n        $update = $this->upgrader->getAssets()['grav-update'];\n\n        $local = $this->upgrader->getLocalVersion();\n        $remote = $this->upgrader->getRemoteVersion();\n        $release = strftime('%c', strtotime($this->upgrader->getReleaseDate()));\n\n        if (!$this->upgrader->meetsRequirements()) {\n            $io->writeln('<red>ATTENTION:</red>');\n            $io->writeln('   Grav has increased the minimum PHP requirement.');\n            $io->writeln('   You are currently running PHP <red>' . phpversion() . '</red>, but PHP <green>' . $this->upgrader->minPHPVersion() . '</green> is required.');\n            $io->writeln('   Additional information: <white>http://getgrav.org/blog/changing-php-requirements</white>');\n            $io->newLine();\n            $io->writeln('Selfupgrade aborted.');\n            $io->newLine();\n\n            return 1;\n        }\n\n        if (!$this->overwrite && !$this->upgrader->isUpgradable()) {\n            $io->writeln(\"You are already running the latest version of <green>Grav v{$local}</green>\");\n            $io->writeln(\"which was released on {$release}\");\n\n            $config = Grav::instance()['config'];\n            $schema = $config->get('versions.core.grav.schema');\n            if ($schema !== GRAV_SCHEMA && version_compare($schema, GRAV_SCHEMA, '<')) {\n                $io->newLine();\n                $io->writeln('However post-install scripts have not been run.');\n                if (!$this->all_yes) {\n                    $question = new ConfirmationQuestion(\n                        'Would you like to run the scripts? [Y|n] ',\n                        true\n                    );\n                    $answer = $io->askQuestion($question);\n                } else {\n                    $answer = true;\n                }\n\n                if ($answer) {\n                    // Finalize installation.\n                    Install::instance()->finalize();\n\n                    $io->write('  |- Running post-install scripts...  ');\n                    $io->writeln(\"  '- <green>Success!</green>  \");\n                    $io->newLine();\n                }\n            }\n\n            return 0;\n        }\n\n        Installer::isValidDestination(GRAV_ROOT . '/system');\n        if (Installer::IS_LINK === Installer::lastErrorCode()) {\n            $io->writeln('<red>ATTENTION:</red> Grav is symlinked, cannot upgrade, aborting...');\n            $io->newLine();\n            $io->writeln(\"You are currently running a symbolically linked Grav v{$local}. Latest available is v{$remote}.\");\n\n            return 1;\n        }\n\n        // not used but preloaded just in case!\n        new ArrayInput([]);\n\n        $io->writeln(\"Grav v<cyan>{$remote}</cyan> is now available [release date: {$release}].\");\n        $io->writeln('You are currently using v<cyan>' . GRAV_VERSION . '</cyan>.');\n\n        if (!$this->all_yes) {\n            $question = new ConfirmationQuestion(\n                'Would you like to read the changelog before proceeding? [y|N] ',\n                false\n            );\n            $answer = $io->askQuestion($question);\n\n            if ($answer) {\n                $changelog = $this->upgrader->getChangelog(GRAV_VERSION);\n\n                $io->newLine();\n                foreach ($changelog as $version => $log) {\n                    $title = $version . ' [' . $log['date'] . ']';\n                    $content = preg_replace_callback('/\\d\\.\\s\\[\\]\\(#(.*)\\)/', static function ($match) {\n                        return \"\\n\" . ucfirst($match[1]) . ':';\n                    }, $log['content']);\n\n                    $io->writeln($title);\n                    $io->writeln(str_repeat('-', strlen($title)));\n                    $io->writeln($content);\n                    $io->newLine();\n                }\n\n                $question = new ConfirmationQuestion('Press [ENTER] to continue.', true);\n                $io->askQuestion($question);\n            }\n\n            $question = new ConfirmationQuestion('Would you like to upgrade now? [y|N] ', false);\n            $answer = $io->askQuestion($question);\n\n            if (!$answer) {\n                $io->writeln('Aborting...');\n\n                return 1;\n            }\n        }\n\n        $io->newLine();\n        $io->writeln(\"Preparing to upgrade to v<cyan>{$remote}</cyan>..\");\n\n        $io->write(\"  |- Downloading upgrade [{$this->formatBytes($update['size'])}]...     0%\");\n        $this->file = $this->download($update);\n\n        $io->write('  |- Installing upgrade...  ');\n        $installation = $this->upgrade();\n\n        $error = 0;\n        if (!$installation) {\n            $io->writeln(\"  '- <red>Installation failed or aborted.</red>\");\n            $io->newLine();\n            $error = 1;\n        } else {\n            $io->writeln(\"  '- <green>Success!</green>  \");\n            $io->newLine();\n        }\n\n        if ($this->tmp && is_dir($this->tmp)) {\n            Folder::delete($this->tmp);\n        }\n\n        return $error;\n    }\n\n    /**\n     * @param array $package\n     * @return string\n     */\n    private function download(array $package): string\n    {\n        $io = $this->getIO();\n\n        $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);\n        $this->tmp = $tmp_dir . '/grav-update-' . uniqid('', false);\n        $options = [\n            'timeout' => $this->timeout,\n        ];\n\n        $output = Response::get($package['download'], $options, [$this, 'progress']);\n\n        Folder::create($this->tmp);\n\n        $io->write(\"\\x0D\");\n        $io->write(\"  |- Downloading upgrade [{$this->formatBytes($package['size'])}]...   100%\");\n        $io->newLine();\n\n        file_put_contents($this->tmp . DS . $package['name'], $output);\n\n        return $this->tmp . DS . $package['name'];\n    }\n\n    /**\n     * @return bool\n     */\n    private function upgrade(): bool\n    {\n        $io = $this->getIO();\n\n        $this->upgradeGrav($this->file);\n\n        $errorCode = Installer::lastErrorCode();\n        if ($errorCode) {\n            $io->write(\"\\x0D\");\n            // extra white spaces to clear out the buffer properly\n            $io->writeln('  |- Installing upgrade...    <red>error</red>                             ');\n            $io->writeln(\"  |  '- \" . Installer::lastErrorMsg());\n\n            return false;\n        }\n\n        $io->write(\"\\x0D\");\n        // extra white spaces to clear out the buffer properly\n        $io->writeln('  |- Installing upgrade...    <green>ok</green>                             ');\n\n        return true;\n    }\n\n    /**\n     * @param array $progress\n     * @return void\n     */\n    public function progress(array $progress): void\n    {\n        $io = $this->getIO();\n\n        $io->write(\"\\x0D\");\n        $io->write(\"  |- Downloading upgrade [{$this->formatBytes($progress['filesize']) }]... \" . str_pad(\n            $progress['percent'],\n            5,\n            ' ',\n            STR_PAD_LEFT\n        ) . '%');\n    }\n\n    /**\n     * @param int|float $size\n     * @param int $precision\n     * @return string\n     */\n    public function formatBytes($size, int $precision = 2): string\n    {\n        $base = log($size) / log(1024);\n        $suffixes = array('', 'k', 'M', 'G', 'T');\n\n        return round(1024 ** ($base - floor($base)), $precision) . $suffixes[(int)floor($base)];\n    }\n\n    /**\n     * @param string $zip\n     * @return void\n     */\n    private function upgradeGrav(string $zip): void\n    {\n        try {\n            $folder = Installer::unZip($zip, $this->tmp . '/zip');\n            if ($folder === false) {\n                throw new RuntimeException(Installer::lastErrorMsg());\n            }\n\n            $script = $folder . '/system/install.php';\n            if ((file_exists($script) && $install = include $script) && is_callable($install)) {\n                $install($zip);\n            } else {\n                throw new RuntimeException('Uploaded archive file is not a valid Grav update package');\n            }\n        } catch (Exception $e) {\n            Installer::setError($e->getMessage());\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Gpm/UninstallCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Gpm\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Gpm;\n\nuse Grav\\Common\\GPM\\GPM;\nuse Grav\\Common\\GPM\\Installer;\nuse Grav\\Common\\GPM\\Local;\nuse Grav\\Common\\GPM\\Remote;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\GpmCommand;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Question\\ConfirmationQuestion;\nuse Throwable;\nuse function count;\nuse function in_array;\nuse function is_array;\n\n/**\n * Class UninstallCommand\n * @package Grav\\Console\\Gpm\n */\nclass UninstallCommand extends GpmCommand\n{\n    /** @var array */\n    protected $data;\n    /** @var GPM */\n    protected $gpm;\n    /** @var string */\n    protected $destination;\n    /** @var string */\n    protected $file;\n    /** @var string */\n    protected $tmp;\n    /** @var array */\n    protected $dependencies = [];\n    /** @var string */\n    protected $all_yes;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('uninstall')\n            ->addOption(\n                'all-yes',\n                'y',\n                InputOption::VALUE_NONE,\n                'Assumes yes (or best approach) instead of prompting'\n            )\n            ->addArgument(\n                'package',\n                InputArgument::IS_ARRAY | InputArgument::REQUIRED,\n                'The package(s) that are desired to be removed. Use the \"index\" command for a list of packages'\n            )\n            ->setDescription('Performs the uninstallation of plugins and themes')\n            ->setHelp('The <info>uninstall</info> command allows to uninstall plugins and themes');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $this->gpm = new GPM();\n\n        $this->all_yes = $input->getOption('all-yes');\n\n        $packages = array_map('strtolower', $input->getArgument('package'));\n        $this->data = ['total' => 0, 'not_found' => []];\n\n        $total = 0;\n        foreach ($packages as $package) {\n            $plugin = $this->gpm->getInstalledPlugin($package);\n            $theme = $this->gpm->getInstalledTheme($package);\n            if ($plugin || $theme) {\n                $this->data[strtolower($package)] = $plugin ?: $theme;\n                $total++;\n            } else {\n                $this->data['not_found'][] = $package;\n            }\n        }\n        $this->data['total'] = $total;\n\n        $io->newLine();\n\n        if (!$this->data['total']) {\n            $io->writeln('Nothing to uninstall.');\n            $io->newLine();\n\n            return 0;\n        }\n\n        if (count($this->data['not_found'])) {\n            $io->writeln('These packages were not found installed: <red>' . implode(\n                '</red>, <red>',\n                $this->data['not_found']\n            ) . '</red>');\n        }\n\n        unset($this->data['not_found'], $this->data['total']);\n\n        // Plugins need to be initialized in order to make clearcache to work.\n        try {\n            $this->initializePlugins();\n        } catch (Throwable $e) {\n            $io->writeln(\"<red>Some plugins failed to initialize: {$e->getMessage()}</red>\");\n        }\n\n        $error = 0;\n        foreach ($this->data as $slug => $package) {\n            $io->writeln(\"Preparing to uninstall <cyan>{$package->name}</cyan> [v{$package->version}]\");\n\n            $io->write('  |- Checking destination...  ');\n            $checks = $this->checkDestination($slug, $package);\n\n            if (!$checks) {\n                $io->writeln(\"  '- <red>Installation failed or aborted.</red>\");\n                $io->newLine();\n                $error = 1;\n            } else {\n                $uninstall = $this->uninstallPackage($slug, $package);\n\n                if (!$uninstall) {\n                    $io->writeln(\"  '- <red>Uninstallation failed or aborted.</red>\");\n                    $error = 1;\n                } else {\n                    $io->writeln(\"  '- <green>Success!</green>  \");\n                }\n            }\n        }\n\n        // clear cache after successful upgrade\n        $this->clearCache();\n\n        return $error;\n    }\n\n    /**\n     * @param string $slug\n     * @param Local\\Package|Remote\\Package $package\n     * @param bool $is_dependency\n     * @return bool\n     */\n    private function uninstallPackage($slug, $package, $is_dependency = false): bool\n    {\n        $io = $this->getIO();\n\n        if (!$slug) {\n            return false;\n        }\n\n        //check if there are packages that have this as a dependency. Abort and show list\n        $dependent_packages = $this->gpm->getPackagesThatDependOnPackage($slug);\n        if (count($dependent_packages) > ($is_dependency ? 1 : 0)) {\n            $io->newLine(2);\n            $io->writeln('<red>Uninstallation failed.</red>');\n            $io->newLine();\n            if (count($dependent_packages) > ($is_dependency ? 2 : 1)) {\n                $io->writeln('The installed packages <cyan>' . implode('</cyan>, <cyan>', $dependent_packages) . '</cyan> depends on this package. Please remove those first.');\n            } else {\n                $io->writeln('The installed package <cyan>' . implode('</cyan>, <cyan>', $dependent_packages) . '</cyan> depends on this package. Please remove it first.');\n            }\n\n            $io->newLine();\n            return false;\n        }\n\n        if (isset($package->dependencies)) {\n            $dependencies = $package->dependencies;\n\n            if ($is_dependency) {\n                foreach ($dependencies as $key => $dependency) {\n                    if (in_array($dependency['name'], $this->dependencies, true)) {\n                        unset($dependencies[$key]);\n                    }\n                }\n            } elseif (count($dependencies) > 0) {\n                $io->writeln('  `- Dependencies found...');\n                $io->newLine();\n            }\n\n            foreach ($dependencies as $dependency) {\n                $this->dependencies[] = $dependency['name'];\n\n                if (is_array($dependency)) {\n                    $dependency = $dependency['name'];\n                }\n                if ($dependency === 'grav' || $dependency === 'php') {\n                    continue;\n                }\n\n                $dependencyPackage = $this->gpm->findPackage($dependency);\n\n                $dependency_exists = $this->packageExists($dependency, $dependencyPackage);\n\n                if ($dependency_exists == Installer::EXISTS) {\n                    $io->writeln(\"A dependency on <cyan>{$dependencyPackage->name}</cyan> [v{$dependencyPackage->version}] was found\");\n\n                    $question = new ConfirmationQuestion(\"  |- Uninstall <cyan>{$dependencyPackage->name}</cyan>? [y|N] \", false);\n                    $answer = $this->all_yes ? true : $io->askQuestion($question);\n\n                    if ($answer) {\n                        $uninstall = $this->uninstallPackage($dependency, $dependencyPackage, true);\n\n                        if (!$uninstall) {\n                            $io->writeln(\"  '- <red>Uninstallation failed or aborted.</red>\");\n                        } else {\n                            $io->writeln(\"  '- <green>Success!</green>  \");\n                        }\n                        $io->newLine();\n                    } else {\n                        $io->writeln(\"  '- <yellow>You decided not to uninstall {$dependencyPackage->name}.</yellow>\");\n                        $io->newLine();\n                    }\n                }\n            }\n        }\n\n\n        $locator = Grav::instance()['locator'];\n        $path = $locator->findResource($package->package_type . '://' . $slug);\n        Installer::uninstall($path);\n        $errorCode = Installer::lastErrorCode();\n\n        if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) {\n            $io->writeln(\"  |- Uninstalling {$package->name} package...  <red>error</red>                             \");\n            $io->writeln(\"  |  '- <yellow>\" . Installer::lastErrorMsg() . '</yellow>');\n\n            return false;\n        }\n\n        $message = Installer::getMessage();\n        if ($message) {\n            $io->writeln(\"  |- {$message}\");\n        }\n\n        if (!$is_dependency && $this->dependencies) {\n            $io->writeln(\"Finishing up uninstalling <cyan>{$package->name}</cyan>\");\n        }\n        $io->writeln(\"  |- Uninstalling {$package->name} package...  <green>ok</green>                             \");\n\n        return true;\n    }\n\n    /**\n     * @param string $slug\n     * @param Local\\Package|Remote\\Package $package\n     * @return bool\n     */\n    private function checkDestination(string $slug, $package): bool\n    {\n        $io = $this->getIO();\n\n        $exists = $this->packageExists($slug, $package);\n\n        if ($exists === Installer::IS_LINK) {\n            $io->write(\"\\x0D\");\n            $io->writeln('  |- Checking destination...  <yellow>symbolic link</yellow>');\n\n            if ($this->all_yes) {\n                $io->writeln(\"  |     '- <yellow>Skipped automatically.</yellow>\");\n\n                return false;\n            }\n\n            $question = new ConfirmationQuestion(\n                \"  |  '- Destination has been detected as symlink, delete symbolic link first? [y|N] \",\n                false\n            );\n\n            $answer = $io->askQuestion($question);\n            if (!$answer) {\n                $io->writeln(\"  |     '- <red>You decided not to delete the symlink automatically.</red>\");\n\n                return false;\n            }\n        }\n\n        $io->write(\"\\x0D\");\n        $io->writeln('  |- Checking destination...  <green>ok</green>');\n\n        return true;\n    }\n\n    /**\n     * Check if package exists\n     *\n     * @param string $slug\n     * @param Local\\Package|Remote\\Package $package\n     * @return int\n     */\n    private function packageExists(string $slug, $package): int\n    {\n        $path = Grav::instance()['locator']->findResource($package->package_type . '://' . $slug);\n        Installer::isValidDestination($path);\n\n        return Installer::lastErrorCode();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Gpm/UpdateCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Gpm\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Gpm;\n\nuse Grav\\Common\\GPM\\GPM;\nuse Grav\\Common\\GPM\\Installer;\nuse Grav\\Common\\GPM\\Upgrader;\nuse Grav\\Console\\GpmCommand;\nuse Symfony\\Component\\Console\\Input\\ArrayInput;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse Symfony\\Component\\Console\\Question\\ConfirmationQuestion;\nuse ZipArchive;\nuse function array_key_exists;\nuse function count;\n\n/**\n * Class UpdateCommand\n * @package Grav\\Console\\Gpm\n */\nclass UpdateCommand extends GpmCommand\n{\n    /** @var array */\n    protected $data;\n    /** @var string */\n    protected $destination;\n    /** @var string */\n    protected $file;\n    /** @var array */\n    protected $types = ['plugins', 'themes'];\n    /** @var GPM  */\n    protected $gpm;\n    /** @var string */\n    protected $all_yes;\n    /** @var string */\n    protected $overwrite;\n    /** @var Upgrader */\n    protected $upgrader;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('update')\n            ->addOption(\n                'force',\n                'f',\n                InputOption::VALUE_NONE,\n                'Force re-fetching the data from remote'\n            )\n            ->addOption(\n                'destination',\n                'd',\n                InputOption::VALUE_OPTIONAL,\n                'The grav instance location where the updates should be applied to. By default this would be where the grav cli has been launched from',\n                GRAV_ROOT\n            )\n            ->addOption(\n                'all-yes',\n                'y',\n                InputOption::VALUE_NONE,\n                'Assumes yes (or best approach) instead of prompting'\n            )\n            ->addOption(\n                'overwrite',\n                'o',\n                InputOption::VALUE_NONE,\n                'Option to overwrite packages if they already exist'\n            )\n            ->addOption(\n                'plugins',\n                'p',\n                InputOption::VALUE_NONE,\n                'Update only plugins'\n            )\n            ->addOption(\n                'themes',\n                't',\n                InputOption::VALUE_NONE,\n                'Update only themes'\n            )\n            ->addArgument(\n                'package',\n                InputArgument::IS_ARRAY | InputArgument::OPTIONAL,\n                'The package or packages that is desired to update. By default all available updates will be applied.'\n            )\n            ->setDescription('Detects and performs an update of plugins and themes when available')\n            ->setHelp('The <info>update</info> command updates plugins and themes when a new version is available');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        if (!class_exists(ZipArchive::class)) {\n            $io->title('GPM Update');\n            $io->error('php-zip extension needs to be enabled!');\n\n            return 1;\n        }\n\n        $this->upgrader = new Upgrader($input->getOption('force'));\n        $local = $this->upgrader->getLocalVersion();\n        $remote = $this->upgrader->getRemoteVersion();\n        if ($local !== $remote) {\n            $io->writeln('<yellow>WARNING</yellow>: A new version of Grav is available. You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.');\n            $io->newLine();\n            $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true);\n            $answer = $io->askQuestion($question);\n\n            if (!$answer) {\n                $io->writeln('<red>Update aborted. Exiting...</red>');\n\n                return 1;\n            }\n        }\n\n        $this->gpm = new GPM($input->getOption('force'));\n\n        $this->all_yes = $input->getOption('all-yes');\n        $this->overwrite = $input->getOption('overwrite');\n\n        $this->displayGPMRelease();\n\n        $this->destination = realpath($input->getOption('destination'));\n\n        if (!Installer::isGravInstance($this->destination)) {\n            $io->writeln('<red>ERROR</red>: ' . Installer::lastErrorMsg());\n            exit;\n        }\n        if ($input->getOption('plugins') === false && $input->getOption('themes') === false) {\n            $list_type = ['plugins' => true, 'themes' => true];\n        } else {\n            $list_type['plugins'] = $input->getOption('plugins');\n            $list_type['themes'] = $input->getOption('themes');\n        }\n\n        if ($this->overwrite) {\n            $this->data = $this->gpm->getInstallable($list_type);\n            $description = ' can be overwritten';\n        } else {\n            $this->data = $this->gpm->getUpdatable($list_type);\n            $description = ' need updating';\n        }\n\n        $only_packages = array_map('strtolower', $input->getArgument('package'));\n\n        if (!$this->overwrite && !$this->data['total']) {\n            $io->writeln('Nothing to update.');\n\n            return 0;\n        }\n\n        $io->write(\"Found <green>{$this->gpm->countInstalled()}</green> packages installed of which <magenta>{$this->data['total']}</magenta>{$description}\");\n\n        $limit_to = $this->userInputPackages($only_packages);\n\n        $io->newLine();\n\n        unset($this->data['total'], $limit_to['total']);\n\n\n        // updates review\n        $slugs = [];\n\n        $index = 1;\n        foreach ($this->data as $packages) {\n            foreach ($packages as $slug => $package) {\n                if (!array_key_exists($slug, $limit_to) && count($only_packages)) {\n                    continue;\n                }\n\n                if (!$package->available) {\n                    $package->available = $package->version;\n                }\n\n                $io->writeln(\n                    // index\n                    str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' .\n                    // name\n                    '<cyan>' . str_pad($package->name, 15) . '</cyan> ' .\n                    // version\n                    \"[v<magenta>{$package->version}</magenta> -> v<green>{$package->available}</green>]\"\n                );\n                $slugs[] = $slug;\n            }\n        }\n\n        if (!$this->all_yes) {\n            // prompt to continue\n            $io->newLine();\n            $question = new ConfirmationQuestion('Continue with the update process? [Y|n] ', true);\n            $answer = $io->askQuestion($question);\n\n            if (!$answer) {\n                $io->writeln('<red>Update aborted. Exiting...</red>');\n\n                return 1;\n            }\n        }\n\n        // finally update\n        $install_command = $this->getApplication()->find('install');\n\n        $args = new ArrayInput([\n            'command' => 'install',\n            'package' => $slugs,\n            '-f' => $input->getOption('force'),\n            '-d' => $this->destination,\n            '-y' => true\n        ]);\n        $command_exec = $install_command->run($args, $io);\n\n        if ($command_exec != 0) {\n            $io->writeln('<red>Error:</red> An error occurred while trying to install the packages');\n\n            return 1;\n        }\n\n        return 0;\n    }\n\n    /**\n     * @param array $only_packages\n     * @return array\n     */\n    private function userInputPackages(array $only_packages): array\n    {\n        $io = $this->getIO();\n\n        $found = ['total' => 0];\n        $ignore = [];\n\n        if (!count($only_packages)) {\n            $io->newLine();\n        } else {\n            foreach ($only_packages as $only_package) {\n                $find = $this->gpm->findPackage($only_package);\n\n                if (!$find || (!$this->overwrite && !$this->gpm->isUpdatable($find->slug))) {\n                    $name = $find->slug ?? $only_package;\n                    $ignore[$name] = $name;\n                } else {\n                    $found[$find->slug] = $find;\n                    $found['total']++;\n                }\n            }\n\n            if ($found['total']) {\n                $list = $found;\n                unset($list['total']);\n                $list = array_keys($list);\n\n                if ($found['total'] !== $this->data['total']) {\n                    $io->write(\", only <magenta>{$found['total']}</magenta> will be updated\");\n                }\n\n                $io->newLine();\n                $io->writeln('Limiting updates for only <cyan>' . implode(\n                    '</cyan>, <cyan>',\n                    $list\n                ) . '</cyan>');\n            }\n\n            if (count($ignore)) {\n                $io->newLine();\n                $io->writeln('Packages not found or not requiring updates: <red>' . implode(\n                    '</red>, <red>',\n                    $ignore\n                ) . '</red>');\n            }\n        }\n\n        return $found;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Gpm/VersionCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Gpm\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Gpm;\n\nuse Grav\\Common\\GPM\\GPM;\nuse Grav\\Common\\GPM\\Upgrader;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\GpmCommand;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse Symfony\\Component\\Console\\Input\\InputArgument;\nuse Symfony\\Component\\Console\\Input\\InputOption;\nuse function count;\n\n/**\n * Class VersionCommand\n * @package Grav\\Console\\Gpm\n */\nclass VersionCommand extends GpmCommand\n{\n    /** @var GPM */\n    protected $gpm;\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this\n            ->setName('version')\n            ->addOption(\n                'force',\n                'f',\n                InputOption::VALUE_NONE,\n                'Force re-fetching the data from remote'\n            )\n            ->addArgument(\n                'package',\n                InputArgument::IS_ARRAY | InputArgument::OPTIONAL,\n                'The package or packages that is desired to know the version of. By default and if not specified this would be grav'\n            )\n            ->setDescription('Shows the version of an installed package. If available also shows pending updates.')\n            ->setHelp('The <info>version</info> command displays the current version of a package installed and, if available, the available version of pending updates');\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $input = $this->getInput();\n        $io = $this->getIO();\n\n        $this->gpm = new GPM($input->getOption('force'));\n        $packages = $input->getArgument('package');\n\n        $installed = false;\n\n        if (!count($packages)) {\n            $packages = ['grav'];\n        }\n\n        foreach ($packages as $package) {\n            $package = strtolower($package);\n            $name = null;\n            $version = null;\n            $updatable = false;\n\n            if ($package === 'grav') {\n                $name = 'Grav';\n                $version = GRAV_VERSION;\n                $upgrader = new Upgrader();\n\n                if ($upgrader->isUpgradable()) {\n                    $updatable = \" [upgradable: v<green>{$upgrader->getRemoteVersion()}</green>]\";\n                }\n            } else {\n                // get currently installed version\n                $locator = Grav::instance()['locator'];\n                $blueprints_path = $locator->findResource('plugins://' . $package . DS . 'blueprints.yaml');\n                if (!file_exists($blueprints_path)) { // theme?\n                    $blueprints_path = $locator->findResource('themes://' . $package . DS . 'blueprints.yaml');\n                    if (!file_exists($blueprints_path)) {\n                        continue;\n                    }\n                }\n\n                $file = YamlFile::instance($blueprints_path);\n                $package_yaml = $file->content();\n                $file->free();\n\n                $version = $package_yaml['version'];\n\n                if (!$version) {\n                    continue;\n                }\n\n                $installed = $this->gpm->findPackage($package);\n                if ($installed) {\n                    $name = $installed->name;\n\n                    if ($this->gpm->isUpdatable($package)) {\n                        $updatable = \" [updatable: v<green>{$installed->available}</green>]\";\n                    }\n                }\n            }\n\n            $updatable = $updatable ?: '';\n\n            if ($installed || $package === 'grav') {\n                $io->writeln(\"You are running <white>{$name}</white> v<cyan>{$version}</cyan>{$updatable}\");\n            } else {\n                $io->writeln(\"Package <red>{$package}</red> not found\");\n            }\n        }\n\n        return 0;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/GpmCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n/**\n * Class ConsoleCommand\n * @package Grav\\Console\n */\nclass GpmCommand extends Command\n{\n    use ConsoleTrait;\n\n    /**\n     * @param InputInterface  $input\n     * @param OutputInterface $output\n     * @return int\n     */\n    protected function execute(InputInterface $input, OutputInterface $output)\n    {\n        $this->setupConsole($input, $output);\n\n        $grav = Grav::instance();\n        $grav['config']->init();\n        $grav['uri']->init();\n        // @phpstan-ignore-next-line\n        $grav['accounts'];\n\n        return $this->serve();\n    }\n\n    /**\n     * Override with your implementation.\n     *\n     * @return int\n     */\n    protected function serve()\n    {\n        // Return error.\n        return 1;\n    }\n\n    /**\n     * @return void\n     */\n    protected function displayGPMRelease()\n    {\n        /** @var Config $config */\n        $config = Grav::instance()['config'];\n\n        $io = $this->getIO();\n        $io->newLine();\n        $io->writeln('GPM Releases Configuration: <yellow>' . ucfirst($config->get('system.gpm.releases')) . '</yellow>');\n        $io->newLine();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/GravCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console;\n\nuse Symfony\\Component\\Console\\Command\\Command;\nuse Symfony\\Component\\Console\\Input\\InputInterface;\nuse Symfony\\Component\\Console\\Output\\OutputInterface;\n\n/**\n * Class ConsoleCommand\n * @package Grav\\Console\n */\nclass GravCommand extends Command\n{\n    use ConsoleTrait;\n\n    /**\n     * @param InputInterface  $input\n     * @param OutputInterface $output\n     * @return int\n     */\n    protected function execute(InputInterface $input, OutputInterface $output)\n    {\n        $this->setupConsole($input, $output);\n\n        // Old versions of Grav called this command after grav upgrade.\n        // We need make this command to work with older ConsoleTrait:\n        if (method_exists($this, 'initializeGrav')) {\n            $this->initializeGrav();\n        }\n\n        return $this->serve();\n    }\n\n    /**\n     * Override with your implementation.\n     *\n     * @return int\n     */\n    protected function serve()\n    {\n        // Return error.\n        return 1;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/Plugin/PluginListCommand.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\Plugin\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\Plugin;\n\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Plugins;\nuse Grav\\Console\\ConsoleCommand;\n\n/**\n * Class InfoCommand\n * @package Grav\\Console\\Gpm\n */\nclass PluginListCommand extends ConsoleCommand\n{\n    protected static $defaultName = 'plugins:list';\n\n    /**\n     * @return void\n     */\n    protected function configure(): void\n    {\n        $this->setHidden(true);\n    }\n\n    /**\n     * @return int\n     */\n    protected function serve(): int\n    {\n        $bin = $this->argv;\n        $pattern = '([A-Z]\\w+Command\\.php)';\n\n        $io = $this->getIO();\n        $io->newLine();\n        $io->writeln('<red>Usage:</red>');\n        $io->writeln(\"  {$bin} [slug] [command] [arguments]\");\n        $io->newLine();\n        $io->writeln('<red>Example:</red>');\n        $io->writeln(\"  {$bin} error log -l 1 --trace\");\n        $io->newLine();\n        $io->writeln('<red>Plugins with CLI available:</red>');\n\n        $plugins = Plugins::all();\n        $index = 0;\n        foreach ($plugins as $name => $plugin) {\n            if (!$plugin->enabled) {\n                continue;\n            }\n\n            $list = Folder::all(\"plugins://{$name}\", ['compare' => 'Pathname', 'pattern' => '/\\/cli\\/' . $pattern . '$/usm', 'levels' => 1]);\n            if (!$list) {\n                continue;\n            }\n\n            $index++;\n            $num = str_pad((string)$index, 2, '0', STR_PAD_LEFT);\n            $io->writeln('  ' . $num . '. <red>' . str_pad($name, 15) . \"</red> <white>{$bin} {$name} list</white>\");\n        }\n\n        return 0;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Console/TerminalObjects/Table.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Console\\TerminalObjects\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Console\\TerminalObjects;\n\n/**\n * Class Table\n * @package Grav\\Console\\TerminalObjects\n * @deprecated 1.7 Use Symfony Console Table\n */\nclass Table extends \\League\\CLImate\\TerminalObject\\Basic\\Table\n{\n    /**\n     * @return array\n     */\n    public function result()\n    {\n        $this->column_widths = $this->getColumnWidths();\n        $this->table_width   = $this->getWidth();\n        $this->border        = $this->getBorder();\n\n        $this->buildHeaderRow();\n\n        foreach ($this->data as $key => $columns) {\n            $this->rows[] = $this->buildRow($columns);\n        }\n\n        $this->rows[] = $this->border;\n\n        return $this->rows;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Events/BeforeSessionStartEvent.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Events\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Events;\n\nuse Grav\\Framework\\Session\\SessionInterface;\nuse Symfony\\Contracts\\EventDispatcher\\Event;\n\n/**\n * Plugins Loaded Event\n *\n * This event is called from $grav['session']->start() right before session_start() call.\n *\n * @property SessionInterface $session Session instance.\n */\nclass BeforeSessionStartEvent extends Event\n{\n    /** @var SessionInterface */\n    public $session;\n\n    public function __construct(SessionInterface $session)\n    {\n        $this->session = $session;\n    }\n\n    public function __debugInfo(): array\n    {\n        return (array)$this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Events/FlexRegisterEvent.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Events\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Events;\n\nuse Grav\\Framework\\Flex\\Flex;\nuse Symfony\\Contracts\\EventDispatcher\\Event;\n\n/**\n * Flex Register Event\n *\n * This event is called the first time $grav['flex'] is being called.\n *\n * Use this event to register enabled Directories to Flex.\n *\n * @property Flex $flex Flex instance.\n */\nclass FlexRegisterEvent extends Event\n{\n    /** @var Flex */\n    public $flex;\n\n    /**\n     * FlexRegisterEvent constructor.\n     * @param Flex $flex\n     */\n    public function __construct(Flex $flex)\n    {\n        $this->flex = $flex;\n    }\n\n    /**\n     * @return array\n     */\n    public function __debugInfo(): array\n    {\n        return (array)$this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Events/PageEvent.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Events\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Events;\n\nuse Grav\\Framework\\Flex\\Flex;\nuse RocketTheme\\Toolbox\\Event\\Event;\n\nclass PageEvent extends Event\n{\n    public $page;\n}\n"
  },
  {
    "path": "system/src/Grav/Events/PermissionsRegisterEvent.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Events\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Events;\n\nuse Grav\\Framework\\Acl\\Permissions;\nuse Symfony\\Contracts\\EventDispatcher\\Event;\n\n/**\n * Permissions Register Event\n *\n * This event is called the first time $grav['permissions'] is being called.\n *\n * Use this event to register any new permission types you use in your plugins.\n *\n * @property Permissions $permissions Permissions instance.\n */\nclass PermissionsRegisterEvent extends Event\n{\n    /** @var Permissions */\n    public $permissions;\n\n    /**\n     * PermissionsRegisterEvent constructor.\n     * @param Permissions $permissions\n     */\n    public function __construct(Permissions $permissions)\n    {\n        $this->permissions = $permissions;\n    }\n\n    /**\n     * @return array\n     */\n    public function __debugInfo(): array\n    {\n        return (array)$this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Events/PluginsLoadedEvent.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Events\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Events;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Plugins;\nuse Symfony\\Contracts\\EventDispatcher\\Event;\n\n/**\n * Plugins Loaded Event\n *\n * This event is called from InitializeProcessor.\n *\n * This is the first event plugin can see. Please avoid using this event if possible.\n *\n * @property Grav $grav Grav container.\n * @property Plugins $plugins Plugins instance.\n */\nclass PluginsLoadedEvent extends Event\n{\n    /** @var Grav */\n    public $grav;\n    /** @var Plugins */\n    public $plugins;\n\n    /**\n     * PluginsLoadedEvent constructor.\n     * @param Grav $grav\n     * @param Plugins $plugins\n     */\n    public function __construct(Grav $grav, Plugins $plugins)\n    {\n        $this->grav = $grav;\n        $this->plugins = $plugins;\n    }\n\n    /**\n     * @return array\n     */\n    public function __debugInfo(): array\n    {\n        return [\n            'plugins' => $this->plugins\n        ];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Events/SessionStartEvent.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Events\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Events;\n\nuse Grav\\Framework\\Session\\SessionInterface;\nuse Symfony\\Contracts\\EventDispatcher\\Event;\n\n/**\n * Plugins Loaded Event\n *\n * This event is called from $grav['session']->start() right after successful session_start() call.\n *\n * @property SessionInterface $session Session instance.\n */\nclass SessionStartEvent extends Event\n{\n    /** @var SessionInterface */\n    public $session;\n\n    public function __construct(SessionInterface $session)\n    {\n        $this->session = $session;\n    }\n\n    public function __debugInfo(): array\n    {\n        return (array)$this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Events/TypesEvent.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Events\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Events;\n\nuse Grav\\Framework\\Flex\\Flex;\nuse RocketTheme\\Toolbox\\Event\\Event;\n\nclass TypesEvent extends Event\n{\n    public $types;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Acl/Access.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Acl\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Acl;\n\nuse ArrayIterator;\nuse Countable;\nuse Grav\\Common\\Utils;\nuse IteratorAggregate;\nuse JsonSerializable;\nuse Traversable;\nuse function count;\nuse function is_array;\nuse function is_bool;\nuse function is_string;\nuse function strlen;\n\n/**\n * Class Access\n * @package Grav\\Framework\\Acl\n * @implements IteratorAggregate<string,bool|array|null>\n */\nclass Access implements JsonSerializable, IteratorAggregate, Countable\n{\n    /** @var string */\n    private $name;\n    /** @var array */\n    private $rules;\n    /** @var array */\n    private $ops;\n    /** @var array<string,bool|array|null> */\n    private $acl = [];\n    /** @var array */\n    private $inherited = [];\n\n    /**\n     * Access constructor.\n     * @param string|array|null $acl\n     * @param array|null $rules\n     * @param string $name\n     */\n    public function __construct($acl = null, array $rules = null, string $name = '')\n    {\n        $this->name = $name;\n        $this->rules = $rules ?? [];\n        $this->ops = ['+' => true, '-' => false];\n        if (is_string($acl)) {\n            $this->acl = $this->resolvePermissions($acl);\n        } elseif (is_array($acl)) {\n            $this->acl = $this->normalizeAcl($acl);\n        }\n    }\n\n    /**\n     * @return string\n     */\n    public function getName(): string\n    {\n        return $this->name;\n    }\n\n    /**\n     * @param Access $parent\n     * @param string|null $name\n     * @return void\n     */\n    public function inherit(Access $parent, string $name = null)\n    {\n        // Remove cached null actions from acl.\n        $acl = $this->getAllActions();\n        // Get only inherited actions.\n        $inherited = array_diff_key($parent->getAllActions(), $acl);\n\n        $this->inherited += $parent->inherited + array_fill_keys(array_keys($inherited), $name ?? $parent->getName());\n        $this->acl = array_replace($acl, $inherited);\n    }\n\n    /**\n     * Checks user authorization to the action.\n     *\n     * @param  string $action\n     * @param  string|null $scope\n     * @return bool|null\n     */\n    public function authorize(string $action, string $scope = null): ?bool\n    {\n        if (null !== $scope) {\n            $action = $scope !== 'test' ? \"{$scope}.{$action}\" : $action;\n        }\n\n        return $this->get($action);\n    }\n\n    /**\n     * @return array\n     */\n    public function toArray(): array\n    {\n        return Utils::arrayUnflattenDotNotation($this->acl);\n    }\n\n    /**\n     * @return array\n     */\n    public function getAllActions(): array\n    {\n        return array_filter($this->acl, static function($val) { return $val !== null; });\n    }\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        return $this->toArray();\n    }\n\n    /**\n     * @param string $action\n     * @return bool|null\n     */\n    public function get(string $action)\n    {\n        // Get access value.\n        if (isset($this->acl[$action])) {\n            return $this->acl[$action];\n        }\n\n        // If no value is defined, check the parent access (all true|false).\n        $pos = strrpos($action, '.');\n        $value = $pos ? $this->get(substr($action, 0, $pos)) : null;\n\n        // Cache result for faster lookup.\n        $this->acl[$action] = $value;\n\n        return $value;\n    }\n\n    /**\n     * @param string $action\n     * @return bool\n     */\n    public function isInherited(string $action): bool\n    {\n        return isset($this->inherited[$action]);\n    }\n\n    /**\n     * @param string $action\n     * @return string|null\n     */\n    public function getInherited(string $action): ?string\n    {\n        return $this->inherited[$action] ?? null;\n    }\n\n    /**\n     * @return Traversable\n     */\n    public function getIterator(): Traversable\n    {\n        return new ArrayIterator($this->acl);\n    }\n\n    /**\n     * @return int\n     */\n    public function count(): int\n    {\n        return count($this->acl);\n    }\n\n    /**\n     * @param array $acl\n     * @return array\n     */\n    protected function normalizeAcl(array $acl): array\n    {\n        if (empty($acl)) {\n            return [];\n        }\n\n        // Normalize access control list.\n        $list = [];\n        foreach (Utils::arrayFlattenDotNotation($acl) as $key => $value) {\n            if (is_bool($value)) {\n                $list[$key] = $value;\n            } elseif ($value === 0 || $value === 1) {\n                $list[$key] = (bool)$value;\n            } elseif($value === null) {\n                continue;\n            } elseif ($this->rules && is_string($value)) {\n                $list[$key] = $this->resolvePermissions($value);\n            } elseif (Utils::isPositive($value)) {\n                $list[$key] = true;\n            } elseif (Utils::isNegative($value)) {\n                $list[$key] = false;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $access\n     * @return array\n     */\n    protected function resolvePermissions(string $access): array\n    {\n        $len = strlen($access);\n        $op = true;\n        $list = [];\n        for($count = 0; $count < $len; $count++) {\n            $letter = $access[$count];\n            if (isset($this->rules[$letter])) {\n                $list[$this->rules[$letter]] = $op;\n                $op = true;\n            } elseif (isset($this->ops[$letter])) {\n                $op = $this->ops[$letter];\n            }\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Acl/Action.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Acl\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Acl;\n\nuse ArrayIterator;\nuse Countable;\nuse Grav\\Common\\Inflector;\nuse IteratorAggregate;\nuse RuntimeException;\nuse Traversable;\nuse function count;\n\n/**\n * Class Action\n * @package Grav\\Framework\\Acl\n * @implements IteratorAggregate<string,Action>\n */\nclass Action implements IteratorAggregate, Countable\n{\n    /** @var string */\n    public $name;\n    /** @var string */\n    public $type;\n    /** @var bool */\n    public $visible;\n    /** @var string|null */\n    public $label;\n    /** @var array */\n    public $params;\n\n    /** @var Action|null */\n    protected $parent;\n    /** @var array<string,Action> */\n    protected $children = [];\n\n    /**\n     * @param string $name\n     * @param array $action\n     */\n    public function __construct(string $name, array $action = [])\n    {\n        $label = $action['label'] ?? null;\n        if (!$label) {\n            if ($pos = mb_strrpos($name, '.')) {\n                $label = mb_substr($name, $pos + 1);\n            } else {\n                $label = $name;\n            }\n            $label = Inflector::humanize($label, 'all');\n        }\n\n        $this->name = $name;\n        $this->type = $action['type'] ?? 'action';\n        $this->visible = (bool)($action['visible'] ?? true);\n        $this->label = $label;\n        unset($action['type'], $action['label']);\n        $this->params = $action;\n\n        // Include compact rules.\n        if (isset($action['letters'])) {\n            foreach ($action['letters'] as $letter => $data) {\n                $data['letter'] = $letter;\n                $childName = $this->name . '.' . $data['action'];\n                unset($data['action']);\n                $child = new Action($childName, $data);\n                $this->addChild($child);\n            }\n        }\n    }\n\n    /**\n     * @return array\n     */\n    public function getParams(): array\n    {\n        return $this->params;\n    }\n\n    /**\n     * @param string $name\n     * @return mixed|null\n     */\n    public function getParam(string $name)\n    {\n        return $this->params[$name] ?? null;\n    }\n\n    /**\n     * @return Action|null\n     */\n    public function getParent(): ?Action\n    {\n        return $this->parent;\n    }\n\n    /**\n     * @param Action|null $parent\n     * @return void\n     */\n    public function setParent(?Action $parent): void\n    {\n        $this->parent = $parent;\n    }\n\n    /**\n     * @return string\n     */\n    public function getScope(): string\n    {\n        $pos = mb_strpos($this->name, '.');\n        if ($pos) {\n            return mb_substr($this->name, 0, $pos);\n        }\n\n        return $this->name;\n    }\n\n    /**\n     * @return int\n     */\n    public function getLevels(): int\n    {\n        return mb_substr_count($this->name, '.');\n    }\n\n    /**\n     * @return bool\n     */\n    public function hasChildren(): bool\n    {\n        return !empty($this->children);\n    }\n\n    /**\n     * @return Action[]\n     */\n    public function getChildren(): array\n    {\n        return $this->children;\n    }\n\n    /**\n     * @param string $name\n     * @return Action|null\n     */\n    public function getChild(string $name): ?Action\n    {\n        return $this->children[$name] ?? null;\n    }\n\n    /**\n     * @param Action $child\n     * @return void\n     */\n    public function addChild(Action $child): void\n    {\n        if (mb_strpos($child->name, \"{$this->name}.\") !== 0) {\n            throw new RuntimeException('Bad child');\n        }\n\n        $child->setParent($this);\n        $name = mb_substr($child->name, mb_strlen($this->name) + 1);\n\n        $this->children[$name] = $child;\n    }\n\n    /**\n     * @return Traversable\n     */\n    public function getIterator(): Traversable\n    {\n        return new ArrayIterator($this->children);\n    }\n\n    /**\n     * @return int\n     */\n    public function count(): int\n    {\n        return count($this->children);\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function __debugInfo()\n    {\n        return [\n            'name' => $this->name,\n            'type' => $this->type,\n            'label' => $this->label,\n            'params' => $this->params,\n            'actions' => $this->children\n        ];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Acl/Permissions.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Acl\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Acl;\n\nuse ArrayAccess;\nuse ArrayIterator;\nuse Countable;\nuse IteratorAggregate;\nuse RecursiveIteratorIterator;\nuse RuntimeException;\nuse Traversable;\nuse function count;\n\n/**\n * Class Permissions\n * @package Grav\\Framework\\Acl\n * @implements ArrayAccess<string,Action>\n * @implements IteratorAggregate<string,Action>\n */\nclass Permissions implements ArrayAccess, Countable, IteratorAggregate\n{\n    /** @var array<string,Action> */\n    protected $instances = [];\n    /** @var array<string,Action> */\n    protected $actions = [];\n    /** @var array */\n    protected $nested = [];\n    /** @var array */\n    protected $types = [];\n\n    /**\n     * @return array\n     */\n    public function getInstances(): array\n    {\n        $iterator = new RecursiveActionIterator($this->actions);\n        $recursive = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::SELF_FIRST);\n\n        return iterator_to_array($recursive);\n    }\n\n    /**\n     * @param string $name\n     * @return bool\n     */\n    public function hasAction(string $name): bool\n    {\n        return isset($this->instances[$name]);\n    }\n\n    /**\n     * @param string $name\n     * @return Action|null\n     */\n    public function getAction(string $name): ?Action\n    {\n        return $this->instances[$name] ?? null;\n    }\n\n    /**\n     * @param Action $action\n     * @return void\n     */\n    public function addAction(Action $action): void\n    {\n        $name = $action->name;\n        $parent = $this->getParent($name);\n        if ($parent) {\n            $parent->addChild($action);\n        } else {\n            $this->actions[$name] = $action;\n        }\n\n        $this->instances[$name] = $action;\n\n        // If Action has children, add those, too.\n        foreach ($action->getChildren() as $child) {\n            $this->instances[$child->name] = $child;\n        }\n    }\n\n    /**\n     * @return array\n     */\n    public function getActions(): array\n    {\n        return $this->actions;\n    }\n\n    /**\n     * @param Action[] $actions\n     * @return void\n     */\n    public function addActions(array $actions): void\n    {\n        foreach ($actions as $action) {\n            $this->addAction($action);\n        }\n    }\n\n    /**\n     * @param string $name\n     * @return bool\n     */\n    public function hasType(string $name): bool\n    {\n        return isset($this->types[$name]);\n    }\n\n    /**\n     * @param string $name\n     * @return Action|null\n     */\n    public function getType(string $name): ?Action\n    {\n        return $this->types[$name] ?? null;\n    }\n\n    /**\n     * @param string $name\n     * @param array $type\n     * @return void\n     */\n    public function addType(string $name, array $type): void\n    {\n        $this->types[$name] = $type;\n    }\n\n    /**\n     * @return array\n     */\n    public function getTypes(): array\n    {\n        return $this->types;\n    }\n\n    /**\n     * @param array $types\n     * @return void\n     */\n    public function addTypes(array $types): void\n    {\n        $types = array_replace($this->types, $types);\n\n        $this->types = $types;\n    }\n\n    /**\n     * @param array|null $access\n     * @return Access\n     */\n    public function getAccess(array $access = null): Access\n    {\n        return new Access($access ?? []);\n    }\n\n    /**\n     * @param int|string $offset\n     * @return bool\n     */\n    public function offsetExists($offset): bool\n    {\n        return isset($this->nested[$offset]);\n    }\n\n    /**\n     * @param int|string $offset\n     * @return Action|null\n     */\n    public function offsetGet($offset): ?Action\n    {\n        return $this->nested[$offset] ?? null;\n    }\n\n    /**\n     * @param int|string $offset\n     * @param mixed $value\n     * @return void\n     */\n    public function offsetSet($offset, $value): void\n    {\n        throw new RuntimeException(__METHOD__ . '(): Not Supported');\n    }\n\n    /**\n     * @param int|string $offset\n     * @return void\n     */\n    public function offsetUnset($offset): void\n    {\n        throw new RuntimeException(__METHOD__ . '(): Not Supported');\n    }\n\n    /**\n     * @return int\n     */\n    public function count(): int\n    {\n        return count($this->actions);\n    }\n\n    /**\n     * @return ArrayIterator|Traversable\n     */\n    #[\\ReturnTypeWillChange]\n    public function getIterator()\n    {\n        return new ArrayIterator($this->actions);\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function __debugInfo()\n    {\n        return [\n            'actions' => $this->actions\n        ];\n    }\n\n    /**\n     * @param string $name\n     * @return Action|null\n     */\n    protected function getParent(string $name): ?Action\n    {\n        if ($pos = strrpos($name, '.')) {\n            $parentName = substr($name, 0, $pos);\n\n            $parent = $this->getAction($parentName);\n            if (!$parent) {\n                $parent = new Action($parentName);\n                $this->addAction($parent);\n            }\n\n            return $parent;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Acl/PermissionsReader.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Acl\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Acl;\n\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse RuntimeException;\nuse stdClass;\nuse function is_array;\n\n/**\n * Class PermissionsReader\n * @package Grav\\Framework\\Acl\n */\nclass PermissionsReader\n{\n    /** @var array */\n    protected static $types;\n\n    /**\n     * @param string $filename\n     * @return Action[]\n     */\n    public static function fromYaml(string $filename): array\n    {\n        $content = CompiledYamlFile::instance($filename)->content();\n        $actions = $content['actions'] ?? [];\n        $types = $content['types'] ?? [];\n\n        return static::fromArray($actions, $types);\n    }\n\n    /**\n     * @param array $actions\n     * @param array $types\n     * @return Action[]\n     */\n    public static function fromArray(array $actions, array $types): array\n    {\n        static::initTypes($types);\n\n        $list = [];\n        foreach (static::read($actions) as $type => $data) {\n            $list[$type] = new Action($type, $data);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param array $actions\n     * @param string $prefix\n     * @return array\n     */\n    public static function read(array $actions, string $prefix = ''): array\n    {\n        $list = [];\n        foreach ($actions as $name => $action) {\n            $prefixName = $prefix . $name;\n            $list[$prefixName] = null;\n\n            // Support nested sets of actions.\n            if (isset($action['actions']) && is_array($action['actions'])) {\n                $innerList = static::read($action['actions'], \"{$prefixName}.\");\n\n                $list += $innerList;\n            }\n\n            unset($action['actions']);\n\n            // Add defaults if they exist.\n            $action = static::addDefaults($action);\n\n            // Build flat list of actions.\n            $list[$prefixName] = $action;\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param array $types\n     * @return void\n     */\n    protected static function initTypes(array $types)\n    {\n        static::$types = [];\n\n        $dependencies = [];\n        foreach ($types as $type => $defaults) {\n            $current = array_fill_keys((array)($defaults['use'] ?? null), null);\n            $defType = $defaults['type'] ?? $type;\n            if ($type !== $defType) {\n                $current[$defaults['type']] = null;\n            }\n\n            $dependencies[$type] = (object)$current;\n        }\n\n        // Build dependency tree.\n        foreach ($dependencies as $type => $dep) {\n            foreach (get_object_vars($dep) as $k => &$val) {\n                if (null === $val) {\n                    $val = $dependencies[$k] ?? new stdClass();\n                }\n            }\n            unset($val);\n        }\n\n        $encoded = json_encode($dependencies);\n        if ($encoded === false) {\n            throw new RuntimeException('json_encode(): failed to encode dependencies');\n        }\n        $dependencies = json_decode($encoded, true);\n\n        foreach (static::getDependencies($dependencies) as $type) {\n            $defaults = $types[$type] ?? null;\n            if ($defaults) {\n                static::$types[$type] = static::addDefaults($defaults);\n            }\n        }\n    }\n\n    /**\n     * @param array $dependencies\n     * @return array\n     */\n    protected static function getDependencies(array $dependencies): array\n    {\n        $list = [[]];\n        foreach ($dependencies as $name => $deps) {\n            $current = $deps ? static::getDependencies($deps) : [];\n            $current[] = $name;\n\n            $list[] = $current;\n        }\n\n        return array_unique(array_merge(...$list));\n    }\n\n    /**\n     * @param array $action\n     * @return array\n     */\n    protected static function addDefaults(array $action): array\n    {\n        $scopes = [];\n\n        // Add used properties.\n        $use = (array)($action['use'] ?? null);\n        foreach ($use as $type) {\n            if (isset(static::$types[$type])) {\n                $used = static::$types[$type];\n                unset($used['type']);\n                $scopes[] = $used;\n            }\n        }\n        unset($action['use']);\n\n        // Add type defaults.\n        $type = $action['type'] ?? 'default';\n        $defaults = static::$types[$type] ?? null;\n        if (is_array($defaults)) {\n            $scopes[] = $defaults;\n        }\n\n        if ($scopes) {\n            $scopes[] = $action;\n\n            $action = array_replace_recursive(...$scopes);\n\n            $newType =  $defaults['type'] ?? null;\n            if ($newType && $newType !== $type) {\n                $action['type'] = $newType;\n            }\n        }\n\n        return $action;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Acl/RecursiveActionIterator.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Acl\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Acl;\n\nuse RecursiveIterator;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Constructor;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Countable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\Iterator;\n\n/**\n * Class Action\n * @package Grav\\Framework\\Acl\n * @implements RecursiveIterator<string,Action>\n */\nclass RecursiveActionIterator implements RecursiveIterator, \\Countable\n{\n    use Constructor, Iterator, Countable;\n\n    public $items;\n\n    /**\n     * @see \\Iterator::key()\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function key()\n    {\n        /** @var Action $current */\n        $current = $this->current();\n\n        return $current->name;\n    }\n\n    /**\n     * @see \\RecursiveIterator::hasChildren()\n     * @return bool\n     */\n    public function hasChildren(): bool\n    {\n        /** @var Action $current */\n        $current = $this->current();\n\n        return $current->hasChildren();\n    }\n\n    /**\n     * @see \\RecursiveIterator::getChildren()\n     * @return RecursiveActionIterator\n     */\n    public function getChildren(): self\n    {\n        /** @var Action $current */\n        $current = $this->current();\n\n        return new static($current->getChildren());\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/AbstractCache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache;\n\nuse DateInterval;\nuse Psr\\SimpleCache\\InvalidArgumentException;\n\n/**\n * Cache trait for PSR-16 compatible \"Simple Cache\" implementation\n * @package Grav\\Framework\\Cache\n */\nabstract class AbstractCache implements CacheInterface\n{\n    use CacheTrait;\n\n    /**\n     * @param string $namespace\n     * @param null|int|DateInterval $defaultLifetime\n     * @throws InvalidArgumentException\n     */\n    public function __construct($namespace = '', $defaultLifetime = null)\n    {\n        $this->init($namespace, $defaultLifetime);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/Adapter/ChainCache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache\\Adapter;\n\nuse DateInterval;\nuse Grav\\Framework\\Cache\\AbstractCache;\nuse Grav\\Framework\\Cache\\CacheInterface;\nuse Grav\\Framework\\Cache\\Exception\\InvalidArgumentException;\nuse function count;\nuse function get_class;\n\n/**\n * Cache class for PSR-16 compatible \"Simple Cache\" implementation using chained cache adapters.\n *\n * @package Grav\\Framework\\Cache\n */\nclass ChainCache extends AbstractCache\n{\n    /** @var CacheInterface[] */\n    protected $caches;\n\n    /** @var int */\n    protected $count;\n\n    /**\n     * Chain Cache constructor.\n     * @param array $caches\n     * @param null|int|DateInterval $defaultLifetime\n     * @throws InvalidArgumentException\n     */\n    public function __construct(array $caches, $defaultLifetime = null)\n    {\n        try {\n            parent::__construct('', $defaultLifetime);\n        } catch (\\Psr\\SimpleCache\\InvalidArgumentException $e) {\n            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);\n        }\n\n        if (!$caches) {\n            throw new InvalidArgumentException('At least one cache must be specified');\n        }\n\n        foreach ($caches as $cache) {\n            if (!$cache instanceof CacheInterface) {\n                throw new InvalidArgumentException(\n                    sprintf(\n                        \"The class '%s' does not implement the '%s' interface\",\n                        get_class($cache),\n                        CacheInterface::class\n                    )\n                );\n            }\n        }\n\n        $this->caches = array_values($caches);\n        $this->count = count($caches);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doGet($key, $miss)\n    {\n        foreach ($this->caches as $i => $cache) {\n            $value = $cache->doGet($key, $miss);\n            if ($value !== $miss) {\n                while (--$i >= 0) {\n                    // Update all the previous caches with missing value.\n                    $this->caches[$i]->doSet($key, $value, $this->getDefaultLifetime());\n                }\n\n                return $value;\n            }\n        }\n\n        return $miss;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doSet($key, $value, $ttl)\n    {\n        $success = true;\n        $i = $this->count;\n\n        while ($i--) {\n            $success = $this->caches[$i]->doSet($key, $value, $ttl) && $success;\n        }\n\n        return $success;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doDelete($key)\n    {\n        $success = true;\n        $i = $this->count;\n\n        while ($i--) {\n            $success = $this->caches[$i]->doDelete($key) && $success;\n        }\n\n        return $success;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doClear()\n    {\n        $success = true;\n        $i = $this->count;\n\n        while ($i--) {\n            $success = $this->caches[$i]->doClear() && $success;\n        }\n\n        return $success;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doGetMultiple($keys, $miss)\n    {\n        $list = [];\n        /**\n         * @var int $i\n         * @var CacheInterface $cache\n         */\n        foreach ($this->caches as $i => $cache) {\n            $list[$i] = $cache->doGetMultiple($keys, $miss);\n\n            $keys = array_diff_key($keys, $list[$i]);\n\n            if (!$keys) {\n                break;\n            }\n        }\n\n        // Update all the previous caches with missing values.\n        $values = [];\n        /**\n         * @var int $i\n         * @var CacheInterface $items\n         */\n        foreach (array_reverse($list) as $i => $items) {\n            $values += $items;\n            if ($i && $values) {\n                $this->caches[$i-1]->doSetMultiple($values, $this->getDefaultLifetime());\n            }\n        }\n\n        return $values;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doSetMultiple($values, $ttl)\n    {\n        $success = true;\n        $i = $this->count;\n\n        while ($i--) {\n            $success = $this->caches[$i]->doSetMultiple($values, $ttl) && $success;\n        }\n\n        return $success;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doDeleteMultiple($keys)\n    {\n        $success = true;\n        $i = $this->count;\n\n        while ($i--) {\n            $success = $this->caches[$i]->doDeleteMultiple($keys) && $success;\n        }\n\n        return $success;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doHas($key)\n    {\n        foreach ($this->caches as $cache) {\n            if ($cache->doHas($key)) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache\\Adapter;\n\nuse DateInterval;\nuse Doctrine\\Common\\Cache\\CacheProvider;\nuse Grav\\Framework\\Cache\\AbstractCache;\nuse Grav\\Framework\\Cache\\Exception\\InvalidArgumentException;\n\n/**\n * Cache class for PSR-16 compatible \"Simple Cache\" implementation using Doctrine Cache backend.\n * @package Grav\\Framework\\Cache\n */\nclass DoctrineCache extends AbstractCache\n{\n    /** @var CacheProvider */\n    protected $driver;\n\n    /**\n     * Doctrine Cache constructor.\n     *\n     * @param CacheProvider $doctrineCache\n     * @param string $namespace\n     * @param null|int|DateInterval $defaultLifetime\n     * @throws InvalidArgumentException\n     */\n    public function __construct(CacheProvider $doctrineCache, $namespace = '', $defaultLifetime = null)\n    {\n        // Do not use $namespace or $defaultLifetime directly, store them with constructor and fetch with methods.\n        try {\n            parent::__construct($namespace, $defaultLifetime);\n        } catch (\\Psr\\SimpleCache\\InvalidArgumentException $e) {\n            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);\n        }\n\n        // Set namespace to Doctrine Cache provider if it was given.\n        $namespace = $this->getNamespace();\n        if ($namespace) {\n            $doctrineCache->setNamespace($namespace);\n        }\n\n        $this->driver = $doctrineCache;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doGet($key, $miss)\n    {\n        $value = $this->driver->fetch($key);\n\n        // Doctrine cache does not differentiate between no result and cached 'false'. Make sure that we do.\n        return $value !== false || $this->driver->contains($key) ? $value : $miss;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doSet($key, $value, $ttl)\n    {\n        return $this->driver->save($key, $value, (int) $ttl);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doDelete($key)\n    {\n        return $this->driver->delete($key);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doClear()\n    {\n        return $this->driver->deleteAll();\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doGetMultiple($keys, $miss)\n    {\n        return $this->driver->fetchMultiple($keys);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doSetMultiple($values, $ttl)\n    {\n        return $this->driver->saveMultiple($values, (int) $ttl);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doDeleteMultiple($keys)\n    {\n        return $this->driver->deleteMultiple($keys);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doHas($key)\n    {\n        return $this->driver->contains($key);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/Adapter/FileCache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache\\Adapter;\n\nuse ErrorException;\nuse FilesystemIterator;\nuse Grav\\Framework\\Cache\\AbstractCache;\nuse Grav\\Framework\\Cache\\Exception\\CacheException;\nuse Grav\\Framework\\Cache\\Exception\\InvalidArgumentException;\nuse RecursiveDirectoryIterator;\nuse RecursiveIteratorIterator;\nuse RuntimeException;\nuse function strlen;\n\n/**\n * Cache class for PSR-16 compatible \"Simple Cache\" implementation using file backend.\n *\n * Defaults to 1 year TTL. Does not support unlimited TTL.\n *\n * @package Grav\\Framework\\Cache\n */\nclass FileCache extends AbstractCache\n{\n    /** @var string */\n    private $directory;\n    /** @var string|null */\n    private $tmp;\n\n    /**\n     * FileCache constructor.\n     * @param string $namespace\n     * @param int|null $defaultLifetime\n     * @param string|null $folder\n     * @throws \\Psr\\SimpleCache\\InvalidArgumentException|InvalidArgumentException\n     */\n    public function __construct($namespace = '', $defaultLifetime = null, $folder = null)\n    {\n        try {\n            parent::__construct($namespace, $defaultLifetime ?: 31557600); // = 1 year\n\n            $this->initFileCache($namespace, $folder ?? '');\n        } catch (\\Psr\\SimpleCache\\InvalidArgumentException $e) {\n            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);\n        }\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doGet($key, $miss)\n    {\n        $now = time();\n        $file = $this->getFile($key);\n\n        if (!file_exists($file) || !$h = @fopen($file, 'rb')) {\n            return $miss;\n        }\n\n        if ($now >= (int) $expiresAt = fgets($h)) {\n            fclose($h);\n            @unlink($file);\n        } else {\n            $i = rawurldecode(rtrim((string)fgets($h)));\n            $value = stream_get_contents($h) ?: '';\n            fclose($h);\n\n            if ($i === $key) {\n                return unserialize($value, ['allowed_classes' => true]);\n            }\n        }\n\n        return $miss;\n    }\n\n    /**\n     * @inheritdoc\n     * @throws CacheException\n     */\n    public function doSet($key, $value, $ttl)\n    {\n        $expiresAt = time() + (int)$ttl;\n\n        $result = $this->write(\n            $this->getFile($key, true),\n            $expiresAt . \"\\n\" . rawurlencode($key) . \"\\n\" . serialize($value),\n            $expiresAt\n        );\n\n        if (!$result && !is_writable($this->directory)) {\n            throw new CacheException(sprintf('Cache directory is not writable (%s)', $this->directory));\n        }\n\n        return $result;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doDelete($key)\n    {\n        $file = $this->getFile($key);\n\n        $result = false;\n        if (file_exists($file)) {\n            $result = @unlink($file);\n            $result &= !file_exists($file);\n        }\n\n        return $result;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doClear()\n    {\n        $result = true;\n        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->directory, FilesystemIterator::SKIP_DOTS));\n\n        foreach ($iterator as $file) {\n            $result = ($file->isDir() || @unlink($file) || !file_exists($file)) && $result;\n        }\n\n        return $result;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function doHas($key)\n    {\n        $file = $this->getFile($key);\n\n        return file_exists($file) && (@filemtime($file) > time() || $this->doGet($key, null));\n    }\n\n    /**\n     * @param string $key\n     * @param bool $mkdir\n     * @return string\n     */\n    protected function getFile($key, $mkdir = false)\n    {\n        $hash = str_replace('/', '-', base64_encode(hash('sha256', static::class . $key, true)));\n        $dir = $this->directory . $hash[0] . DIRECTORY_SEPARATOR . $hash[1] . DIRECTORY_SEPARATOR;\n\n        if ($mkdir) {\n            $this->mkdir($dir);\n        }\n\n        return $dir . substr($hash, 2, 20);\n    }\n\n    /**\n     * @param string $namespace\n     * @param string $directory\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    protected function initFileCache($namespace, $directory)\n    {\n        if ($directory === '') {\n            $directory = sys_get_temp_dir() . '/grav-cache';\n        } else {\n            $directory = realpath($directory) ?: $directory;\n        }\n\n        if (isset($namespace[0])) {\n            if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) {\n                throw new InvalidArgumentException(sprintf('Namespace contains \"%s\" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0]));\n            }\n            $directory .= DIRECTORY_SEPARATOR . $namespace;\n        }\n\n        $this->mkdir($directory);\n\n        $directory .= DIRECTORY_SEPARATOR;\n        // On Windows the whole path is limited to 258 chars\n        if ('\\\\' === DIRECTORY_SEPARATOR && strlen($directory) > 234) {\n            throw new InvalidArgumentException(sprintf('Cache folder is too long (%s)', $directory));\n        }\n        $this->directory = $directory;\n    }\n\n    /**\n     * @param string $file\n     * @param string $data\n     * @param int|null $expiresAt\n     * @return bool\n     */\n    private function write($file, $data, $expiresAt = null)\n    {\n        set_error_handler(__CLASS__.'::throwError');\n\n        try {\n            if ($this->tmp === null) {\n                $this->tmp = $this->directory . uniqid('', true);\n            }\n\n            file_put_contents($this->tmp, $data);\n\n            if ($expiresAt !== null) {\n                touch($this->tmp, $expiresAt);\n            }\n\n            return rename($this->tmp, $file);\n        } finally {\n            restore_error_handler();\n        }\n    }\n\n    /**\n     * @param  string  $dir\n     * @return void\n     * @throws RuntimeException\n     */\n    private function mkdir($dir)\n    {\n        // Silence error for open_basedir; should fail in mkdir instead.\n        if (@is_dir($dir)) {\n            return;\n        }\n\n        $success = @mkdir($dir, 0777, true);\n\n        if (!$success) {\n            // Take yet another look, make sure that the folder doesn't exist.\n            clearstatcache(true, $dir);\n            if (!@is_dir($dir)) {\n                throw new RuntimeException(sprintf('Unable to create directory: %s', $dir));\n            }\n        }\n    }\n\n    /**\n     * @param int $type\n     * @param string $message\n     * @param string $file\n     * @param int $line\n     * @return bool\n     * @internal\n     * @throws ErrorException\n     */\n    public static function throwError($type, $message, $file, $line)\n    {\n        throw new ErrorException($message, 0, $type, $file, $line);\n    }\n\n    /**\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __destruct()\n    {\n        if ($this->tmp !== null && file_exists($this->tmp)) {\n            unlink($this->tmp);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/Adapter/MemoryCache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache\\Adapter;\n\nuse Grav\\Framework\\Cache\\AbstractCache;\nuse function array_key_exists;\n\n/**\n * Cache class for PSR-16 compatible \"Simple Cache\" implementation using in memory backend.\n *\n * Memory backend does not use namespace or default ttl as the cache is unique to each cache object and request.\n *\n * @package Grav\\Framework\\Cache\n */\nclass MemoryCache extends AbstractCache\n{\n    /** @var array */\n    protected $cache = [];\n\n    /**\n     * @param string $key\n     * @param mixed $miss\n     * @return mixed\n     */\n    public function doGet($key, $miss)\n    {\n        if (!array_key_exists($key, $this->cache)) {\n            return $miss;\n        }\n\n        return $this->cache[$key];\n    }\n\n    /**\n     * @param string $key\n     * @param mixed $value\n     * @param int $ttl\n     * @return bool\n     */\n    public function doSet($key, $value, $ttl)\n    {\n        $this->cache[$key] = $value;\n\n        return true;\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     */\n    public function doDelete($key)\n    {\n        unset($this->cache[$key]);\n\n        return true;\n    }\n\n    /**\n     * @return bool\n     */\n    public function doClear()\n    {\n        $this->cache = [];\n\n        return true;\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     */\n    public function doHas($key)\n    {\n        return array_key_exists($key, $this->cache);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/Adapter/SessionCache.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache\\Adapter;\n\nuse Grav\\Framework\\Cache\\AbstractCache;\n\n/**\n * Cache class for PSR-16 compatible \"Simple Cache\" implementation using session backend.\n *\n * @package Grav\\Framework\\Cache\n */\nclass SessionCache extends AbstractCache\n{\n    public const VALUE = 0;\n    public const LIFETIME = 1;\n\n    /**\n     * @param string $key\n     * @param mixed $miss\n     * @return mixed\n     */\n    public function doGet($key, $miss)\n    {\n        $stored = $this->doGetStored($key);\n\n        return $stored ? $stored[self::VALUE] : $miss;\n    }\n\n    /**\n     * @param string $key\n     * @param mixed $value\n     * @param int $ttl\n     * @return bool\n     */\n    public function doSet($key, $value, $ttl)\n    {\n        $stored = [self::VALUE => $value];\n        if (null !== $ttl) {\n            $stored[self::LIFETIME] = time() + $ttl;\n        }\n\n        $_SESSION[$this->getNamespace()][$key] = $stored;\n\n        return true;\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     */\n    public function doDelete($key)\n    {\n        unset($_SESSION[$this->getNamespace()][$key]);\n\n        return true;\n    }\n\n    /**\n     * @return bool\n     */\n    public function doClear()\n    {\n        unset($_SESSION[$this->getNamespace()]);\n\n        return true;\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     */\n    public function doHas($key)\n    {\n        return $this->doGetStored($key) !== null;\n    }\n\n    /**\n     * @return string\n     */\n    public function getNamespace()\n    {\n        return 'cache-' . parent::getNamespace();\n    }\n\n    /**\n     * @param string $key\n     * @return mixed|null\n     */\n    protected function doGetStored($key)\n    {\n        $stored = $_SESSION[$this->getNamespace()][$key] ?? null;\n\n        if (isset($stored[self::LIFETIME]) && $stored[self::LIFETIME] < time()) {\n            unset($_SESSION[$this->getNamespace()][$key]);\n            $stored = null;\n        }\n\n        return $stored ?: null;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/CacheInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache;\n\nuse Psr\\SimpleCache\\CacheInterface as SimpleCacheInterface;\n\n/**\n * PSR-16 compatible \"Simple Cache\" interface.\n * @package Grav\\Framework\\Object\\Storage\n */\ninterface CacheInterface extends SimpleCacheInterface\n{\n    /**\n     * @param string $key\n     * @param mixed $miss\n     * @return mixed\n     */\n    public function doGet($key, $miss);\n\n    /**\n     * @param string $key\n     * @param mixed $value\n     * @param int|null $ttl\n     * @return mixed\n     */\n    public function doSet($key, $value, $ttl);\n\n    /**\n     * @param string $key\n     * @return mixed\n     */\n    public function doDelete($key);\n\n    /**\n     * @return bool\n     */\n    public function doClear();\n\n    /**\n     * @param string[] $keys\n     * @param mixed $miss\n     * @return mixed\n     */\n    public function doGetMultiple($keys, $miss);\n\n    /**\n     * @param array<string, mixed> $values\n     * @param int|null $ttl\n     * @return mixed\n     */\n    public function doSetMultiple($values, $ttl);\n\n    /**\n     * @param string[] $keys\n     * @return mixed\n     */\n    public function doDeleteMultiple($keys);\n\n    /**\n     * @param string $key\n     * @return mixed\n     */\n    public function doHas($key);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/CacheTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache;\n\nuse DateInterval;\nuse DateTime;\nuse Grav\\Framework\\Cache\\Exception\\InvalidArgumentException;\nuse stdClass;\nuse Traversable;\nuse function array_key_exists;\nuse function get_class;\nuse function gettype;\nuse function is_array;\nuse function is_int;\nuse function is_object;\nuse function is_string;\nuse function strlen;\n\n/**\n * Cache trait for PSR-16 compatible \"Simple Cache\" implementation\n * @package Grav\\Framework\\Cache\n */\ntrait CacheTrait\n{\n    /** @var string */\n    private $namespace = '';\n    /** @var int|null */\n    private $defaultLifetime = null;\n    /** @var stdClass */\n    private $miss;\n    /** @var bool */\n    private $validation = true;\n\n    /**\n     * Always call from constructor.\n     *\n     * @param string $namespace\n     * @param null|int|DateInterval $defaultLifetime\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    protected function init($namespace = '', $defaultLifetime = null)\n    {\n        $this->namespace = (string) $namespace;\n        $this->defaultLifetime = $this->convertTtl($defaultLifetime);\n        $this->miss = new stdClass;\n    }\n\n    /**\n     * @param bool $validation\n     * @return void\n     */\n    public function setValidation($validation)\n    {\n        $this->validation = (bool) $validation;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getNamespace()\n    {\n        return $this->namespace;\n    }\n\n    /**\n     * @return int|null\n     */\n    protected function getDefaultLifetime()\n    {\n        return $this->defaultLifetime;\n    }\n\n    /**\n     * @param string $key\n     * @param mixed|null $default\n     * @return mixed|null\n     * @throws InvalidArgumentException\n     */\n    public function get($key, $default = null)\n    {\n        $this->validateKey($key);\n\n        $value = $this->doGet($key, $this->miss);\n\n        return $value !== $this->miss ? $value : $default;\n    }\n\n    /**\n     * @param string $key\n     * @param mixed $value\n     * @param null|int|DateInterval $ttl\n     * @return bool\n     * @throws InvalidArgumentException\n     */\n    public function set($key, $value, $ttl = null)\n    {\n        $this->validateKey($key);\n\n        $ttl = $this->convertTtl($ttl);\n\n        // If a negative or zero TTL is provided, the item MUST be deleted from the cache.\n        return null !== $ttl && $ttl <= 0 ? $this->doDelete($key) : $this->doSet($key, $value, $ttl);\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     * @throws InvalidArgumentException\n     */\n    public function delete($key)\n    {\n        $this->validateKey($key);\n\n        return $this->doDelete($key);\n    }\n\n    /**\n     * @return bool\n     */\n    public function clear()\n    {\n        return $this->doClear();\n    }\n\n    /**\n     * @param iterable $keys\n     * @param mixed|null $default\n     * @return iterable\n     * @throws InvalidArgumentException\n     */\n    public function getMultiple($keys, $default = null)\n    {\n        if ($keys instanceof Traversable) {\n            $keys = iterator_to_array($keys, false);\n        } elseif (!is_array($keys)) {\n            $isObject = is_object($keys);\n            throw new InvalidArgumentException(\n                sprintf(\n                    'Cache keys must be array or Traversable, \"%s\" given',\n                     $isObject ? get_class($keys) : gettype($keys)\n                )\n            );\n        }\n\n        if (empty($keys)) {\n            return [];\n        }\n\n        $this->validateKeys($keys);\n        $keys = array_unique($keys);\n        $keys = array_combine($keys, $keys);\n\n        $list = $this->doGetMultiple($keys, $this->miss);\n\n        // Make sure that values are returned in the same order as the keys were given.\n        $values = [];\n        foreach ($keys as $key) {\n            if (!array_key_exists($key, $list) || $list[$key] === $this->miss) {\n                $values[$key] = $default;\n            } else {\n                $values[$key] = $list[$key];\n            }\n        }\n\n        return $values;\n    }\n\n    /**\n     * @param iterable $values\n     * @param null|int|DateInterval $ttl\n     * @return bool\n     * @throws InvalidArgumentException\n     */\n    public function setMultiple($values, $ttl = null)\n    {\n        if ($values instanceof Traversable) {\n            $values = iterator_to_array($values, true);\n        } elseif (!is_array($values)) {\n            $isObject = is_object($values);\n            throw new InvalidArgumentException(\n                sprintf(\n                    'Cache values must be array or Traversable, \"%s\" given',\n                    $isObject ? get_class($values) : gettype($values)\n                )\n            );\n        }\n\n        $keys = array_keys($values);\n\n        if (empty($keys)) {\n            return true;\n        }\n\n        $this->validateKeys($keys);\n\n        $ttl = $this->convertTtl($ttl);\n\n        // If a negative or zero TTL is provided, the item MUST be deleted from the cache.\n        return null !== $ttl && $ttl <= 0 ? $this->doDeleteMultiple($keys) : $this->doSetMultiple($values, $ttl);\n    }\n\n    /**\n     * @param iterable $keys\n     * @return bool\n     * @throws InvalidArgumentException\n     */\n    public function deleteMultiple($keys)\n    {\n        if ($keys instanceof Traversable) {\n            $keys = iterator_to_array($keys, false);\n        } elseif (!is_array($keys)) {\n            $isObject = is_object($keys);\n            throw new InvalidArgumentException(\n                sprintf(\n                    'Cache keys must be array or Traversable, \"%s\" given',\n                    $isObject ? get_class($keys) : gettype($keys)\n                )\n            );\n        }\n\n        if (empty($keys)) {\n            return true;\n        }\n\n        $this->validateKeys($keys);\n\n        return $this->doDeleteMultiple($keys);\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     * @throws InvalidArgumentException\n     */\n    public function has($key)\n    {\n        $this->validateKey($key);\n\n        return $this->doHas($key);\n    }\n\n    /**\n     * @param array $keys\n     * @param mixed $miss\n     * @return array\n     */\n    public function doGetMultiple($keys, $miss)\n    {\n        $results = [];\n\n        foreach ($keys as $key) {\n            $value = $this->doGet($key, $miss);\n            if ($value !== $miss) {\n                $results[$key] = $value;\n            }\n        }\n\n        return $results;\n    }\n\n    /**\n     * @param array $values\n     * @param int|null $ttl\n     * @return bool\n     */\n    public function doSetMultiple($values, $ttl)\n    {\n        $success = true;\n\n        foreach ($values as $key => $value) {\n            $success = $this->doSet($key, $value, $ttl) && $success;\n        }\n\n        return $success;\n    }\n\n    /**\n     * @param array $keys\n     * @return bool\n     */\n    public function doDeleteMultiple($keys)\n    {\n        $success = true;\n\n        foreach ($keys as $key) {\n            $success = $this->doDelete($key) && $success;\n        }\n\n        return $success;\n    }\n\n    /**\n     * @param string|mixed $key\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    protected function validateKey($key)\n    {\n        if (!is_string($key)) {\n            throw new InvalidArgumentException(\n                sprintf(\n                    'Cache key must be string, \"%s\" given',\n                    is_object($key) ? get_class($key) : gettype($key)\n                )\n            );\n        }\n        if (!isset($key[0])) {\n            throw new InvalidArgumentException('Cache key length must be greater than zero');\n        }\n        if (strlen($key) > 64) {\n            throw new InvalidArgumentException(\n                sprintf('Cache key length must be less than 65 characters, key had %d characters', strlen($key))\n            );\n        }\n        if (strpbrk($key, '{}()/\\@:') !== false) {\n            throw new InvalidArgumentException(\n                sprintf('Cache key \"%s\" contains reserved characters {}()/\\@:', $key)\n            );\n        }\n    }\n\n    /**\n     * @param array $keys\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    protected function validateKeys($keys)\n    {\n        if (!$this->validation) {\n            return;\n        }\n\n        foreach ($keys as $key) {\n            $this->validateKey($key);\n        }\n    }\n\n    /**\n     * @param null|int|DateInterval    $ttl\n     * @return int|null\n     * @throws InvalidArgumentException\n     */\n    protected function convertTtl($ttl)\n    {\n        if ($ttl === null) {\n            return $this->getDefaultLifetime();\n        }\n\n        if (is_int($ttl)) {\n            return $ttl;\n        }\n\n        if ($ttl instanceof DateInterval) {\n            $date = DateTime::createFromFormat('U', '0');\n            $ttl = $date ? (int)$date->add($ttl)->format('U') : 0;\n        }\n\n        throw new InvalidArgumentException(\n            sprintf(\n                'Expiration date must be an integer, a DateInterval or null, \"%s\" given',\n                is_object($ttl) ? get_class($ttl) : gettype($ttl)\n            )\n        );\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/Exception/CacheException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache\\Exception;\n\nuse Exception;\nuse Psr\\SimpleCache\\CacheException as SimpleCacheException;\n\n/**\n * CacheException class for PSR-16 compatible \"Simple Cache\" implementation.\n * @package Grav\\Framework\\Cache\\Exception\n */\nclass CacheException extends Exception implements SimpleCacheException\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Cache/Exception/InvalidArgumentException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Cache\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Cache\\Exception;\n\nuse Psr\\SimpleCache\\InvalidArgumentException as SimpleCacheInvalidArgumentException;\n\n/**\n * InvalidArgumentException class for PSR-16 compatible \"Simple Cache\" implementation.\n * @package Grav\\Framework\\Cache\\Exception\n */\nclass InvalidArgumentException extends \\InvalidArgumentException implements SimpleCacheInvalidArgumentException\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Collection/AbstractFileCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Collection\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Collection;\n\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Doctrine\\Common\\Collections\\Expr\\ClosureExpressionVisitor;\nuse FilesystemIterator;\nuse Grav\\Common\\Grav;\nuse RecursiveDirectoryIterator;\nuse RocketTheme\\Toolbox\\ResourceLocator\\RecursiveUniformResourceIterator;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse SeekableIterator;\nuse function array_slice;\n\n/**\n * Collection of objects stored into a filesystem.\n *\n * @package Grav\\Framework\\Collection\n * @template TKey of array-key\n * @template T of object\n * @extends AbstractLazyCollection<TKey,T>\n * @implements FileCollectionInterface<TKey,T>\n */\nclass AbstractFileCollection extends AbstractLazyCollection implements FileCollectionInterface\n{\n    /** @var string */\n    protected $path;\n    /** @var RecursiveDirectoryIterator|RecursiveUniformResourceIterator */\n    protected $iterator;\n    /** @var callable */\n    protected $createObjectFunction;\n    /** @var callable|null */\n    protected $filterFunction;\n    /** @var int */\n    protected $flags;\n    /** @var int */\n    protected $nestingLimit;\n\n    /**\n     * @param string $path\n     */\n    protected function __construct($path)\n    {\n        $this->path = $path;\n        $this->flags = self::INCLUDE_FILES | self::INCLUDE_FOLDERS;\n        $this->nestingLimit = 0;\n        $this->createObjectFunction = [$this, 'createObject'];\n\n        $this->setIterator();\n    }\n\n    /**\n     * @return string\n     */\n    public function getPath()\n    {\n        return $this->path;\n    }\n\n    /**\n     * @param Criteria $criteria\n     * @return ArrayCollection\n     * @phpstan-return ArrayCollection<TKey,T>\n     * @todo Implement lazy matching\n     */\n    public function matching(Criteria $criteria)\n    {\n        $expr = $criteria->getWhereExpression();\n\n        $oldFilter = $this->filterFunction;\n        if ($expr) {\n            $visitor = new ClosureExpressionVisitor();\n            $filter = $visitor->dispatch($expr);\n            $this->addFilter($filter);\n        }\n\n        $filtered = $this->doInitializeByIterator($this->iterator, $this->nestingLimit);\n        $this->filterFunction = $oldFilter;\n\n        if ($orderings = $criteria->getOrderings()) {\n            $next = null;\n            /**\n             * @var string $field\n             * @var string $ordering\n             */\n            foreach (array_reverse($orderings) as $field => $ordering) {\n                $next = ClosureExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next);\n            }\n            /** @phpstan-ignore-next-line */\n            if (null === $next) {\n                throw new RuntimeException('Criteria is missing orderings');\n            }\n\n            uasort($filtered, $next);\n        } else {\n            ksort($filtered);\n        }\n\n        $offset = $criteria->getFirstResult();\n        $length = $criteria->getMaxResults();\n\n        if ($offset || $length) {\n            $filtered = array_slice($filtered, (int)$offset, $length);\n        }\n\n        return new ArrayCollection($filtered);\n    }\n\n    /**\n     * @return void\n     */\n    protected function setIterator()\n    {\n        $iteratorFlags = RecursiveDirectoryIterator::SKIP_DOTS + FilesystemIterator::UNIX_PATHS\n            + FilesystemIterator::CURRENT_AS_SELF + FilesystemIterator::FOLLOW_SYMLINKS;\n\n        if (strpos($this->path, '://')) {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            $this->iterator = $locator->getRecursiveIterator($this->path, $iteratorFlags);\n        } else {\n            $this->iterator = new RecursiveDirectoryIterator($this->path, $iteratorFlags);\n        }\n    }\n\n    /**\n     * @param callable $filterFunction\n     * @return $this\n     */\n    protected function addFilter(callable $filterFunction)\n    {\n        if ($this->filterFunction) {\n            $oldFilterFunction = $this->filterFunction;\n            $this->filterFunction = function ($expr) use ($oldFilterFunction, $filterFunction) {\n                return $oldFilterFunction($expr) && $filterFunction($expr);\n            };\n        } else {\n            $this->filterFunction = $filterFunction;\n        }\n\n        return $this;\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    protected function doInitialize()\n    {\n        $filtered = $this->doInitializeByIterator($this->iterator, $this->nestingLimit);\n        ksort($filtered);\n\n        $this->collection = new ArrayCollection($filtered);\n    }\n\n    /**\n     * @param SeekableIterator $iterator\n     * @param int $nestingLimit\n     * @return array\n     * @phpstan-param SeekableIterator<int,T> $iterator\n     */\n    protected function doInitializeByIterator(SeekableIterator $iterator, $nestingLimit)\n    {\n        $children = [];\n        $objects = [];\n        $filter = $this->filterFunction;\n        $objectFunction = $this->createObjectFunction;\n\n        /** @var RecursiveDirectoryIterator $file */\n        foreach ($iterator as $file) {\n            // Skip files if they shouldn't be included.\n            if (!($this->flags & static::INCLUDE_FILES) && $file->isFile()) {\n                continue;\n            }\n\n            // Apply main filter.\n            if ($filter && !$filter($file)) {\n                continue;\n            }\n\n            // Include children if the recursive flag is set.\n            if (($this->flags & static::RECURSIVE) && $nestingLimit > 0 && $file->hasChildren()) {\n                $children[] = $file->getChildren();\n            }\n\n            // Skip folders if they shouldn't be included.\n            if (!($this->flags & static::INCLUDE_FOLDERS) && $file->isDir()) {\n                continue;\n            }\n\n            $object = $objectFunction($file);\n            $objects[$object->key] = $object;\n        }\n\n        if ($children) {\n            $objects += $this->doInitializeChildren($children, $nestingLimit - 1);\n        }\n\n        return $objects;\n    }\n\n    /**\n     * @param array $children\n     * @param int $nestingLimit\n     * @return array\n     */\n    protected function doInitializeChildren(array $children, $nestingLimit)\n    {\n        $objects = [];\n        foreach ($children as $iterator) {\n            $objects += $this->doInitializeByIterator($iterator, $nestingLimit);\n        }\n\n        return $objects;\n    }\n\n    /**\n     * @param RecursiveDirectoryIterator $file\n     * @return object\n     */\n    protected function createObject($file)\n    {\n        return (object) [\n            'key' => $file->getSubPathname(),\n            'type' => $file->isDir() ? 'folder' : 'file:' . $file->getExtension(),\n            'url' => method_exists($file, 'getUrl') ? $file->getUrl() : null,\n            'pathname' => $file->getPathname(),\n            'mtime' => $file->getMTime()\n        ];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Collection/AbstractIndexCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Collection\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Collection;\n\nuse ArrayIterator;\nuse Closure;\nuse Grav\\Framework\\Compat\\Serializable;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse InvalidArgumentException;\nuse Iterator;\nuse function array_key_exists;\nuse function array_slice;\nuse function count;\n\n/**\n * Abstract Index Collection.\n * @template TKey of array-key\n * @template T\n * @template C of CollectionInterface\n * @implements CollectionInterface<TKey,T>\n */\nabstract class AbstractIndexCollection implements CollectionInterface\n{\n    use Serializable;\n\n    /**\n     * @var array\n     * @phpstan-var array<TKey,T>\n     */\n    private $entries;\n\n    /**\n     * Initializes a new IndexCollection.\n     *\n     * @param array $entries\n     * @phpstan-param array<TKey,T> $entries\n     */\n    public function __construct(array $entries = [])\n    {\n        $this->entries = $entries;\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function toArray()\n    {\n        return $this->loadElements($this->entries);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function first()\n    {\n        $value = reset($this->entries);\n        $key = (string)key($this->entries);\n\n        return $this->loadElement($key, $value);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function last()\n    {\n        $value = end($this->entries);\n        $key = (string)key($this->entries);\n\n        return $this->loadElement($key, $value);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    #[\\ReturnTypeWillChange]\n    public function key()\n    {\n        /** @phpstan-var TKey */\n        return (string)key($this->entries);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    #[\\ReturnTypeWillChange]\n    public function next()\n    {\n        $value = next($this->entries);\n        $key = (string)key($this->entries);\n\n        return $this->loadElement($key, $value);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    #[\\ReturnTypeWillChange]\n    public function current()\n    {\n        $value = current($this->entries);\n        $key = (string)key($this->entries);\n\n        return $this->loadElement($key, $value);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function remove($key)\n    {\n        if (!array_key_exists($key, $this->entries)) {\n            return null;\n        }\n\n        $value = $this->entries[$key];\n        unset($this->entries[$key]);\n\n        return $this->loadElement((string)$key, $value);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function removeElement($element)\n    {\n        $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null;\n\n        if (null !== $key || !isset($this->entries[$key])) {\n            return false;\n        }\n\n        unset($this->entries[$key]);\n\n        return true;\n    }\n\n    /**\n     * Required by interface ArrayAccess.\n     *\n     * @param string|int|null $offset\n     * @return bool\n     * @phpstan-param TKey|null $offset\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset)\n    {\n        /** @phpstan-ignore-next-line phpstan bug? */\n        return $offset !== null ? $this->containsKey($offset) : false;\n    }\n\n    /**\n     * Required by interface ArrayAccess.\n     *\n     * @param string|int|null $offset\n     * @return mixed\n     * @phpstan-param TKey|null $offset\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        /** @phpstan-ignore-next-line phpstan bug? */\n        return $offset !== null ? $this->get($offset) : null;\n    }\n\n    /**\n     * Required by interface ArrayAccess.\n     *\n     * @param string|int|null $offset\n     * @param mixed $value\n     * @return void\n     * @phpstan-param TKey|null $offset\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetSet($offset, $value)\n    {\n        if (null === $offset) {\n            $this->add($value);\n        } else {\n            /** @phpstan-ignore-next-line phpstan bug? */\n            $this->set($offset, $value);\n        }\n    }\n\n    /**\n     * Required by interface ArrayAccess.\n     *\n     * @param string|int|null $offset\n     * @return void\n     * @phpstan-param TKey|null $offset\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetUnset($offset)\n    {\n        if ($offset !== null) {\n            /** @phpstan-ignore-next-line phpstan bug? */\n            $this->remove($offset);\n        }\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function containsKey($key)\n    {\n        return isset($this->entries[$key]) || array_key_exists($key, $this->entries);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function contains($element)\n    {\n        $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null;\n\n        return $key && isset($this->entries[$key]);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function exists(Closure $p)\n    {\n        return $this->loadCollection($this->entries)->exists($p);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function indexOf($element)\n    {\n        $key = $this->isAllowedElement($element) ? $this->getCurrentKey($element) : null;\n\n        return $key && isset($this->entries[$key]) ? $key : false;\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function get($key)\n    {\n        if (!isset($this->entries[$key])) {\n            return null;\n        }\n\n        return $this->loadElement((string)$key, $this->entries[$key]);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function getKeys()\n    {\n        return array_keys($this->entries);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function getValues()\n    {\n        return array_values($this->loadElements($this->entries));\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    #[\\ReturnTypeWillChange]\n    public function count()\n    {\n        return count($this->entries);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function set($key, $value)\n    {\n        if (!$this->isAllowedElement($value)) {\n            throw new InvalidArgumentException('Invalid argument $value');\n        }\n\n        $this->entries[$key] = $this->getElementMeta($value);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function add($element)\n    {\n        if (!$this->isAllowedElement($element)) {\n            throw new InvalidArgumentException('Invalid argument $element');\n        }\n\n        $this->entries[$this->getCurrentKey($element)] = $this->getElementMeta($element);\n\n        return true;\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function isEmpty()\n    {\n        return empty($this->entries);\n    }\n\n    /**\n     * Required by interface IteratorAggregate.\n     *\n     * {@inheritDoc}\n     * @phpstan-return Iterator<TKey,T>\n     */\n    #[\\ReturnTypeWillChange]\n    public function getIterator()\n    {\n        return new ArrayIterator($this->loadElements());\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function map(Closure $func)\n    {\n        return $this->loadCollection($this->entries)->map($func);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function filter(Closure $p)\n    {\n        return $this->loadCollection($this->entries)->filter($p);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function forAll(Closure $p)\n    {\n        return $this->loadCollection($this->entries)->forAll($p);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function partition(Closure $p)\n    {\n        return $this->loadCollection($this->entries)->partition($p);\n    }\n\n    /**\n     * Returns a string representation of this object.\n     *\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return __CLASS__ . '@' . spl_object_hash($this);\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function clear()\n    {\n        $this->entries = [];\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function slice($offset, $length = null)\n    {\n        return $this->loadElements(array_slice($this->entries, $offset, $length, true));\n    }\n\n    /**\n     * @param int $start\n     * @param int|null $limit\n     * @return static\n     * @phpstan-return static<TKey,T,C>\n     */\n    public function limit($start, $limit = null)\n    {\n        return $this->createFrom(array_slice($this->entries, $start, $limit, true));\n    }\n\n    /**\n     * Reverse the order of the items.\n     *\n     * @return static\n     * @phpstan-return static<TKey,T,C>\n     */\n    public function reverse()\n    {\n        return $this->createFrom(array_reverse($this->entries));\n    }\n\n    /**\n     * Shuffle items.\n     *\n     * @return static\n     * @phpstan-return static<TKey,T,C>\n     */\n    public function shuffle()\n    {\n        $keys = $this->getKeys();\n        shuffle($keys);\n\n        return $this->createFrom(array_replace(array_flip($keys), $this->entries));\n    }\n\n    /**\n     * Select items from collection.\n     *\n     * Collection is returned in the order of $keys given to the function.\n     *\n     * @param array $keys\n     * @return static\n     * @phpstan-return static<TKey,T,C>\n     */\n    public function select(array $keys)\n    {\n        $list = [];\n        foreach ($keys as $key) {\n            if (isset($this->entries[$key])) {\n                $list[$key] = $this->entries[$key];\n            }\n        }\n\n        return $this->createFrom($list);\n    }\n\n    /**\n     * Un-select items from collection.\n     *\n     * @param array $keys\n     * @return static\n     * @phpstan-return static<TKey,T,C>\n     */\n    public function unselect(array $keys)\n    {\n        return $this->select(array_diff($this->getKeys(), $keys));\n    }\n\n    /**\n     * Split collection into chunks.\n     *\n     * @param int $size     Size of each chunk.\n     * @return array\n     * @phpstan-return array<array<TKey,T>>\n     */\n    public function chunk($size)\n    {\n        /** @phpstan-var array<array<TKey,T>> */\n        return $this->loadCollection($this->entries)->chunk($size);\n    }\n\n    /**\n     * @return array\n     */\n    public function __serialize(): array\n    {\n        return [\n            'entries' => $this->entries\n        ];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    public function __unserialize(array $data): void\n    {\n        $this->entries = $data['entries'];\n    }\n\n    /**\n     * Implements JsonSerializable interface.\n     *\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->loadCollection()->jsonSerialize();\n    }\n\n    /**\n     * Creates a new instance from the specified elements.\n     *\n     * This method is provided for derived classes to specify how a new\n     * instance should be created when constructor semantics have changed.\n     *\n     * @param array $entries Elements.\n     * @return static\n     * @phpstan-return static<TKey,T,C>\n     */\n    protected function createFrom(array $entries)\n    {\n        return new static($entries);\n    }\n\n    /**\n     * @return array\n     */\n    protected function getEntries(): array\n    {\n        return $this->entries;\n    }\n\n    /**\n     * @param array $entries\n     * @return void\n     * @phpstan-param array<TKey,T> $entries\n     */\n    protected function setEntries(array $entries): void\n    {\n        $this->entries = $entries;\n    }\n\n    /**\n     * @param FlexObjectInterface $element\n     * @return string\n     * @phpstan-param T $element\n     * @phpstan-return TKey\n     */\n    protected function getCurrentKey($element)\n    {\n        return $element->getKey();\n    }\n\n    /**\n     * @param string $key\n     * @param mixed $value\n     * @return mixed|null\n     */\n    abstract protected function loadElement($key, $value);\n\n    /**\n     * @param array|null $entries\n     * @return array\n     * @phpstan-return array<TKey,T>\n     */\n    abstract protected function loadElements(array $entries = null): array;\n\n    /**\n     * @param array|null $entries\n     * @return CollectionInterface\n     * @phpstan-return C\n     */\n    abstract protected function loadCollection(array $entries = null): CollectionInterface;\n\n    /**\n     * @param mixed $value\n     * @return bool\n     */\n    abstract protected function isAllowedElement($value): bool;\n\n    /**\n     * @param mixed $element\n     * @return mixed\n     */\n    abstract protected function getElementMeta($element);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Collection/AbstractLazyCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Collection\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Collection;\n\nuse Doctrine\\Common\\Collections\\AbstractLazyCollection as BaseAbstractLazyCollection;\n\n/**\n * General JSON serializable collection.\n *\n * @package Grav\\Framework\\Collection\n * @template TKey of array-key\n * @template T\n * @extends BaseAbstractLazyCollection<TKey,T>\n * @implements CollectionInterface<TKey,T>\n */\nabstract class AbstractLazyCollection extends BaseAbstractLazyCollection implements CollectionInterface\n{\n    /**\n     * @par ArrayCollection\n     * @phpstan-var ArrayCollection<TKey,T>\n     */\n    protected $collection;\n\n    /**\n     * {@inheritDoc}\n     * @phpstan-return ArrayCollection<TKey,T>\n     */\n    public function reverse()\n    {\n        $this->initialize();\n\n        return $this->collection->reverse();\n    }\n\n    /**\n     * {@inheritDoc}\n     * @phpstan-return ArrayCollection<TKey,T>\n     */\n    public function shuffle()\n    {\n        $this->initialize();\n\n        return $this->collection->shuffle();\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function chunk($size)\n    {\n        $this->initialize();\n\n        return $this->collection->chunk($size);\n    }\n\n    /**\n     * {@inheritDoc}\n     * @phpstan-param array<TKey,T> $keys\n     * @phpstan-return ArrayCollection<TKey,T>\n     */\n    public function select(array $keys)\n    {\n        $this->initialize();\n\n        return $this->collection->select($keys);\n    }\n\n    /**\n     * {@inheritDoc}\n     * @phpstan-param array<TKey,T> $keys\n     * @phpstan-return ArrayCollection<TKey,T>\n     */\n    public function unselect(array $keys)\n    {\n        $this->initialize();\n\n        return $this->collection->unselect($keys);\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        $this->initialize();\n\n        return $this->collection->jsonSerialize();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Collection/ArrayCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Collection\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Collection;\n\nuse Doctrine\\Common\\Collections\\ArrayCollection as BaseArrayCollection;\n\n/**\n * General JSON serializable collection.\n *\n * @package Grav\\Framework\\Collection\n * @template TKey of array-key\n * @template T\n * @extends BaseArrayCollection<TKey,T>\n * @implements CollectionInterface<TKey,T>\n */\nclass ArrayCollection extends BaseArrayCollection implements CollectionInterface\n{\n    /**\n     * Reverse the order of the items.\n     *\n     * @return static\n     * @phpstan-return static<TKey,T>\n     */\n    public function reverse()\n    {\n        $keys = array_reverse($this->toArray());\n\n        /** @phpstan-var static<TKey,T> */\n        return $this->createFrom($keys);\n    }\n\n    /**\n     * Shuffle items.\n     *\n     * @return static\n     * @phpstan-return static<TKey,T>\n     */\n    public function shuffle()\n    {\n        $keys = $this->getKeys();\n        shuffle($keys);\n        $keys = array_replace(array_flip($keys), $this->toArray());\n\n        /** @phpstan-var static<TKey,T> */\n        return $this->createFrom($keys);\n    }\n\n    /**\n     * Split collection into chunks.\n     *\n     * @param int $size     Size of each chunk.\n     * @return array\n     * @phpstan-return array<array<TKey,T>>\n     */\n    public function chunk($size)\n    {\n        /** @phpstan-var array<array<TKey,T>> */\n        return array_chunk($this->toArray(), $size, true);\n    }\n\n    /**\n     * Select items from collection.\n     *\n     * Collection is returned in the order of $keys given to the function.\n     *\n     * @param array<int,string> $keys\n     * @return static\n     * @phpstan-param TKey[] $keys\n     * @phpstan-return static<TKey,T>\n     */\n    public function select(array $keys)\n    {\n        $list = [];\n        foreach ($keys as $key) {\n            if ($this->containsKey($key)) {\n                $list[$key] = $this->get($key);\n            }\n        }\n\n        /** @phpstan-var static<TKey,T> */\n        return $this->createFrom($list);\n    }\n\n    /**\n     * Un-select items from collection.\n     *\n     * @param array<int|string> $keys\n     * @return static\n     * @phpstan-param TKey[] $keys\n     * @phpstan-return static<TKey,T>\n     */\n    public function unselect(array $keys)\n    {\n        $list = array_diff($this->getKeys(), $keys);\n\n        /** @phpstan-var static<TKey,T> */\n        return $this->select($list);\n    }\n\n    /**\n     * Implements JsonSerializable interface.\n     *\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->toArray();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Collection/CollectionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Collection\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Collection;\n\nuse Doctrine\\Common\\Collections\\Collection;\nuse JsonSerializable;\n\n/**\n * Collection Interface.\n *\n * @package Grav\\Framework\\Collection\n * @template TKey of array-key\n * @template T\n * @extends Collection<TKey,T>\n */\ninterface CollectionInterface extends Collection, JsonSerializable\n{\n    /**\n     * Reverse the order of the items.\n     *\n     * @return CollectionInterface\n     * @phpstan-return static<TKey,T>\n     */\n    public function reverse();\n\n    /**\n     * Shuffle items.\n     *\n     * @return CollectionInterface\n     * @phpstan-return static<TKey,T>\n     */\n    public function shuffle();\n\n    /**\n     * Split collection into chunks.\n     *\n     * @param int $size     Size of each chunk.\n     * @return array\n     * @phpstan-return array<array<TKey,T>>\n     */\n    public function chunk($size);\n\n    /**\n     * Select items from collection.\n     *\n     * Collection is returned in the order of $keys given to the function.\n     *\n     * @param array<int|string> $keys\n     * @return CollectionInterface\n     * @phpstan-return static<TKey,T>\n     */\n    public function select(array $keys);\n\n    /**\n     * Un-select items from collection.\n     *\n     * @param array<int|string> $keys\n     * @return CollectionInterface\n     * @phpstan-return static<TKey,T>\n     */\n    public function unselect(array $keys);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Collection/FileCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Collection\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Collection;\n\nuse stdClass;\n\n/**\n * Collection of objects stored into a filesystem.\n *\n * @package Grav\\Framework\\Collection\n * @extends AbstractFileCollection<array-key,stdClass>\n */\nclass FileCollection extends AbstractFileCollection\n{\n    /**\n     * @param string $path\n     * @param int    $flags\n     */\n    public function __construct($path, $flags = null)\n    {\n        parent::__construct($path);\n\n        $this->flags = (int)($flags ?: self::INCLUDE_FILES | self::INCLUDE_FOLDERS | self::RECURSIVE);\n\n        $this->setIterator();\n        $this->setFilter();\n        $this->setObjectBuilder();\n        $this->setNestingLimit();\n    }\n\n    /**\n     * @return int\n     */\n    public function getFlags()\n    {\n        return $this->flags;\n    }\n\n    /**\n     * @return int\n     */\n    public function getNestingLimit()\n    {\n        return $this->nestingLimit;\n    }\n\n    /**\n     * @param int $limit\n     * @return $this\n     */\n    public function setNestingLimit($limit = 99)\n    {\n        $this->nestingLimit = (int) $limit;\n\n        return $this;\n    }\n\n    /**\n     * @param callable|null $filterFunction\n     * @return $this\n     */\n    public function setFilter(callable $filterFunction = null)\n    {\n        $this->filterFunction = $filterFunction;\n\n        return $this;\n    }\n\n    /**\n     * @param callable $filterFunction\n     * @return $this\n     */\n    public function addFilter(callable $filterFunction)\n    {\n        parent::addFilter($filterFunction);\n\n        return $this;\n    }\n\n    /**\n     * @param callable|null $objectFunction\n     * @return $this\n     */\n    public function setObjectBuilder(callable $objectFunction = null)\n    {\n        $this->createObjectFunction = $objectFunction ?: [$this, 'createObject'];\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Collection/FileCollectionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Collection\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Collection;\n\nuse Doctrine\\Common\\Collections\\Selectable;\n\n/**\n * Collection of objects stored into a filesystem.\n *\n * @package Grav\\Framework\\Collection\n * @template TKey of array-key\n * @template T\n * @extends CollectionInterface<TKey,T>\n * @extends Selectable<TKey,T>\n */\ninterface FileCollectionInterface extends CollectionInterface, Selectable\n{\n    public const INCLUDE_FILES = 1;\n    public const INCLUDE_FOLDERS = 2;\n    public const RECURSIVE = 4;\n\n    /**\n     * @return string\n     */\n    public function getPath();\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Compat/Serializable.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Compat\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Compat;\n\n/**\n * Serializable trait\n *\n * Adds backwards compatibility to PHP 7.3 Serializable interface.\n *\n * Note: Remember to add: `implements \\Serializable` to the classes which use this trait.\n *\n * @package Grav\\Framework\\Traits\n */\ntrait Serializable\n{\n    /**\n     * @return string\n     */\n    final public function serialize(): string\n    {\n        return serialize($this->__serialize());\n    }\n\n    /**\n     * @param string $serialized\n     * @return void\n     */\n    final public function unserialize($serialized): void\n    {\n        $this->__unserialize(unserialize($serialized, ['allowed_classes' => $this->getUnserializeAllowedClasses()]));\n    }\n\n    /**\n     * @return array|bool\n     */\n    protected function getUnserializeAllowedClasses()\n    {\n        return false;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/ContentBlock/ContentBlock.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\ContentBlock\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\ContentBlock;\n\nuse Exception;\nuse Grav\\Framework\\Compat\\Serializable;\nuse InvalidArgumentException;\nuse RuntimeException;\nuse function get_class;\n\n/**\n * Class to create nested blocks of content.\n *\n * $innerBlock = ContentBlock::create();\n * $innerBlock->setContent('my inner content');\n * $outerBlock = ContentBlock::create();\n * $outerBlock->setContent(sprintf('Inside my outer block I have %s.', $innerBlock->getToken()));\n * $outerBlock->addBlock($innerBlock);\n * echo $outerBlock;\n *\n * @package Grav\\Framework\\ContentBlock\n */\nclass ContentBlock implements ContentBlockInterface\n{\n    use Serializable;\n\n    /** @var int */\n    protected $version = 1;\n    /** @var string */\n    protected $id;\n    /** @var string */\n    protected $tokenTemplate = '@@BLOCK-%s@@';\n    /** @var string */\n    protected $content = '';\n    /** @var array */\n    protected $blocks = [];\n    /** @var string */\n    protected $checksum;\n    /** @var bool */\n    protected $cached = true;\n\n    /**\n     * @param string|null $id\n     * @return static\n     */\n    public static function create($id = null)\n    {\n        return new static($id);\n    }\n\n    /**\n     * @param array $serialized\n     * @return ContentBlockInterface\n     * @throws InvalidArgumentException\n     */\n    public static function fromArray(array $serialized)\n    {\n        try {\n            $type = $serialized['_type'] ?? null;\n            $id = $serialized['id'] ?? null;\n\n            if (!$type || !$id || !is_a($type, ContentBlockInterface::class, true)) {\n                throw new InvalidArgumentException('Bad data');\n            }\n\n            /** @var ContentBlockInterface $instance */\n            $instance = new $type($id);\n            $instance->build($serialized);\n        } catch (Exception $e) {\n            throw new InvalidArgumentException(sprintf('Cannot unserialize Block: %s', $e->getMessage()), $e->getCode(), $e);\n        }\n\n        return $instance;\n    }\n\n    /**\n     * Block constructor.\n     *\n     * @param string|null $id\n     */\n    public function __construct($id = null)\n    {\n        $this->id = $id ? (string) $id : $this->generateId();\n    }\n\n    /**\n     * @return string\n     */\n    public function getId()\n    {\n        return $this->id;\n    }\n\n    /**\n     * @return string\n     */\n    public function getToken()\n    {\n        return sprintf($this->tokenTemplate, $this->getId());\n    }\n\n    /**\n     * @return array\n     */\n    public function toArray()\n    {\n        $blocks = [];\n        /** @var ContentBlockInterface $block */\n        foreach ($this->blocks as $block) {\n            $blocks[$block->getId()] = $block->toArray();\n        }\n\n        $array = [\n            '_type' => get_class($this),\n            '_version' => $this->version,\n            'id' => $this->id,\n            'cached' => $this->cached\n        ];\n\n        if ($this->checksum) {\n            $array['checksum'] = $this->checksum;\n        }\n\n        if ($this->content) {\n            $array['content'] = $this->content;\n        }\n\n        if ($blocks) {\n            $array['blocks'] = $blocks;\n        }\n\n        return $array;\n    }\n\n    /**\n     * @return string\n     */\n    public function toString()\n    {\n        if (!$this->blocks) {\n            return (string) $this->content;\n        }\n\n        $tokens = [];\n        $replacements = [];\n        foreach ($this->blocks as $block) {\n            $tokens[] = $block->getToken();\n            $replacements[] = $block->toString();\n        }\n\n        return str_replace($tokens, $replacements, (string) $this->content);\n    }\n\n    /**\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        try {\n            return $this->toString();\n        } catch (Exception $e) {\n            return sprintf('Error while rendering block: %s', $e->getMessage());\n        }\n    }\n\n    /**\n     * @param array $serialized\n     * @return void\n     * @throws RuntimeException\n     */\n    public function build(array $serialized)\n    {\n        $this->checkVersion($serialized);\n\n        $this->id = $serialized['id'] ?? $this->generateId();\n        $this->checksum = $serialized['checksum'] ?? null;\n        $this->cached = $serialized['cached'] ?? null;\n\n        if (isset($serialized['content'])) {\n            $this->setContent($serialized['content']);\n        }\n\n        $blocks = isset($serialized['blocks']) ? (array) $serialized['blocks'] : [];\n        foreach ($blocks as $block) {\n            $this->addBlock(self::fromArray($block));\n        }\n    }\n\n    /**\n     * @return bool\n     */\n    public function isCached()\n    {\n        if (!$this->cached) {\n            return false;\n        }\n\n        foreach ($this->blocks as $block) {\n            if (!$block->isCached()) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * @return $this\n     */\n    public function disableCache()\n    {\n        $this->cached = false;\n\n        return $this;\n    }\n\n    /**\n     * @param string $checksum\n     * @return $this\n     */\n    public function setChecksum($checksum)\n    {\n        $this->checksum = $checksum;\n\n        return $this;\n    }\n\n    /**\n     * @return string\n     */\n    public function getChecksum()\n    {\n        return $this->checksum;\n    }\n\n    /**\n     * @param string $content\n     * @return $this\n     */\n    public function setContent($content)\n    {\n        $this->content = $content;\n\n        return $this;\n    }\n\n    /**\n     * @param ContentBlockInterface $block\n     * @return $this\n     */\n    public function addBlock(ContentBlockInterface $block)\n    {\n        $this->blocks[$block->getId()] = $block;\n\n        return $this;\n    }\n\n    /**\n     * @return array\n     */\n    final public function __serialize(): array\n    {\n        return $this->toArray();\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    final public function __unserialize(array $data): void\n    {\n        $this->build($data);\n    }\n\n    /**\n     * @return string\n     */\n    protected function generateId()\n    {\n        return uniqid('', true);\n    }\n\n    /**\n     * @param array $serialized\n     * @return void\n     * @throws RuntimeException\n     */\n    protected function checkVersion(array $serialized)\n    {\n        $version = isset($serialized['_version']) ? (int) $serialized['_version'] : 1;\n        if ($version !== $this->version) {\n            throw new RuntimeException(sprintf('Unsupported version %s', $version));\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\ContentBlock\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\ContentBlock;\n\nuse Serializable;\n\n/**\n * ContentBlock Interface\n * @package Grav\\Framework\\ContentBlock\n */\ninterface ContentBlockInterface extends Serializable\n{\n    /**\n     * @param string|null $id\n     * @return static\n     */\n    public static function create($id = null);\n\n    /**\n     * @param array $serialized\n     * @return ContentBlockInterface\n     */\n    public static function fromArray(array $serialized);\n\n    /**\n     * @param string|null $id\n     */\n    public function __construct($id = null);\n\n    /**\n     * @return string\n     */\n    public function getId();\n\n    /**\n     * @return string\n     */\n    public function getToken();\n\n    /**\n     * @return array\n     */\n    public function toArray();\n\n    /**\n     * @return string\n     */\n    public function toString();\n\n    /**\n     * @return string\n     */\n    public function __toString();\n\n    /**\n     * @param array $serialized\n     * @return void\n     */\n    public function build(array $serialized);\n\n    /**\n     * @param string $checksum\n     * @return $this\n     */\n    public function setChecksum($checksum);\n\n    /**\n     * @return string\n     */\n    public function getChecksum();\n\n    /**\n     * @param string $content\n     * @return $this\n     */\n    public function setContent($content);\n\n    /**\n     * @param ContentBlockInterface $block\n     * @return $this\n     */\n    public function addBlock(ContentBlockInterface $block);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/ContentBlock/HtmlBlock.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\ContentBlock\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\ContentBlock;\n\nuse RuntimeException;\nuse function is_array;\nuse function is_string;\n\n/**\n * HtmlBlock\n *\n * @package Grav\\Framework\\ContentBlock\n */\nclass HtmlBlock extends ContentBlock implements HtmlBlockInterface\n{\n    /** @var int */\n    protected $version = 1;\n    /** @var array */\n    protected $frameworks = [];\n    /** @var array */\n    protected $styles = [];\n    /** @var array */\n    protected $scripts = [];\n    /** @var array */\n    protected $links = [];\n    /** @var array */\n    protected $html = [];\n\n    /**\n     * @return array\n     */\n    public function getAssets()\n    {\n        $assets = $this->getAssetsFast();\n\n        $this->sortAssets($assets['styles']);\n        $this->sortAssets($assets['scripts']);\n        $this->sortAssets($assets['links']);\n        $this->sortAssets($assets['html']);\n\n        return $assets;\n    }\n\n    /**\n     * @return array\n     */\n    public function getFrameworks()\n    {\n        $assets = $this->getAssetsFast();\n\n        return array_keys($assets['frameworks']);\n    }\n\n    /**\n     * @param string $location\n     * @return array\n     */\n    public function getStyles($location = 'head')\n    {\n        return $this->getAssetsInLocation('styles', $location);\n    }\n\n    /**\n     * @param string $location\n     * @return array\n     */\n    public function getScripts($location = 'head')\n    {\n        return $this->getAssetsInLocation('scripts', $location);\n    }\n\n    /**\n     * @param string $location\n     * @return array\n     */\n    public function getLinks($location = 'head')\n    {\n        return $this->getAssetsInLocation('links', $location);\n    }\n\n    /**\n     * @param string $location\n     * @return array\n     */\n    public function getHtml($location = 'bottom')\n    {\n        return $this->getAssetsInLocation('html', $location);\n    }\n\n    /**\n     * @return array\n     */\n    public function toArray()\n    {\n        $array = parent::toArray();\n\n        if ($this->frameworks) {\n            $array['frameworks'] = $this->frameworks;\n        }\n        if ($this->styles) {\n            $array['styles'] = $this->styles;\n        }\n        if ($this->scripts) {\n            $array['scripts'] = $this->scripts;\n        }\n        if ($this->links) {\n            $array['links'] = $this->links;\n        }\n        if ($this->html) {\n            $array['html'] = $this->html;\n        }\n\n        return $array;\n    }\n\n    /**\n     * @param array $serialized\n     * @return void\n     * @throws RuntimeException\n     */\n    public function build(array $serialized)\n    {\n        parent::build($serialized);\n\n        $this->frameworks = isset($serialized['frameworks']) ? (array) $serialized['frameworks'] : [];\n        $this->styles = isset($serialized['styles']) ? (array) $serialized['styles'] : [];\n        $this->scripts = isset($serialized['scripts']) ? (array) $serialized['scripts'] : [];\n        $this->links = isset($serialized['links']) ? (array) $serialized['links'] : [];\n        $this->html = isset($serialized['html']) ? (array) $serialized['html'] : [];\n    }\n\n    /**\n     * @param string $framework\n     * @return $this\n     */\n    public function addFramework($framework)\n    {\n        $this->frameworks[$framework] = 1;\n\n        return $this;\n    }\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     *\n     * @example $block->addStyle('assets/js/my.js');\n     * @example $block->addStyle(['href' => 'assets/js/my.js', 'media' => 'screen']);\n     */\n    public function addStyle($element, $priority = 0, $location = 'head')\n    {\n        if (!is_array($element)) {\n            $element = ['href' => (string) $element];\n        }\n        if (empty($element['href'])) {\n            return false;\n        }\n        if (!isset($this->styles[$location])) {\n            $this->styles[$location] = [];\n        }\n\n        $id = !empty($element['id']) ? ['id' => (string) $element['id']] : [];\n        $href = $element['href'];\n        $type = !empty($element['type']) ? (string) $element['type'] : 'text/css';\n        $media = !empty($element['media']) ? (string) $element['media'] : null;\n        unset(\n            $element['tag'],\n            $element['id'],\n            $element['rel'],\n            $element['content'],\n            $element['href'],\n            $element['type'],\n            $element['media']\n        );\n\n        $this->styles[$location][md5($href) . sha1($href)] = [\n                ':type' => 'file',\n                ':priority' => (int) $priority,\n                'href' => $href,\n                'type' => $type,\n                'media' => $media,\n                'element' => $element\n            ] + $id;\n\n        return true;\n    }\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addInlineStyle($element, $priority = 0, $location = 'head')\n    {\n        if (!is_array($element)) {\n            $element = ['content' => (string) $element];\n        }\n        if (empty($element['content'])) {\n            return false;\n        }\n        if (!isset($this->styles[$location])) {\n            $this->styles[$location] = [];\n        }\n\n        $content = (string) $element['content'];\n        $type = !empty($element['type']) ? (string) $element['type'] : 'text/css';\n\n        unset($element['content'], $element['type']);\n\n        $this->styles[$location][md5($content) . sha1($content)] = [\n            ':type' => 'inline',\n            ':priority' => (int) $priority,\n            'content' => $content,\n            'type' => $type,\n            'element' => $element\n        ];\n\n        return true;\n    }\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addScript($element, $priority = 0, $location = 'head')\n    {\n        if (!is_array($element)) {\n            $element = ['src' => (string) $element];\n        }\n        if (empty($element['src'])) {\n            return false;\n        }\n        if (!isset($this->scripts[$location])) {\n            $this->scripts[$location] = [];\n        }\n\n        $src = $element['src'];\n        $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript';\n        $loading = !empty($element['loading']) ? (string) $element['loading'] : null;\n        $defer = !empty($element['defer']);\n        $async = !empty($element['async']);\n        $handle = !empty($element['handle']) ? (string) $element['handle'] : '';\n\n        unset($element['src'], $element['type'], $element['loading'], $element['defer'], $element['async'], $element['handle']);\n\n        $this->scripts[$location][md5($src) . sha1($src)] = [\n            ':type' => 'file',\n            ':priority' => (int) $priority,\n            'src' => $src,\n            'type' => $type,\n            'loading' => $loading,\n            'defer' => $defer,\n            'async' => $async,\n            'handle' => $handle,\n            'element' => $element\n        ];\n\n        return true;\n    }\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addInlineScript($element, $priority = 0, $location = 'head')\n    {\n        if (!is_array($element)) {\n            $element = ['content' => (string) $element];\n        }\n        if (empty($element['content'])) {\n            return false;\n        }\n        if (!isset($this->scripts[$location])) {\n            $this->scripts[$location] = [];\n        }\n\n        $content = (string) $element['content'];\n        $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript';\n        $loading = !empty($element['loading']) ? (string) $element['loading'] : null;\n\n        unset($element['content'], $element['type'], $element['loading']);\n\n        $this->scripts[$location][md5($content) . sha1($content)] = [\n            ':type' => 'inline',\n            ':priority' => (int) $priority,\n            'content' => $content,\n            'type' => $type,\n            'loading' => $loading,\n            'element' => $element\n        ];\n\n        return true;\n    }\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addModule($element, $priority = 0, $location = 'head')\n    {\n        if (!is_array($element)) {\n            $element = ['src' => (string) $element];\n        }\n\n        $element['type'] = 'module';\n\n        return $this->addScript($element, $priority, $location);\n    }\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addInlineModule($element, $priority = 0, $location = 'head')\n    {\n        if (!is_array($element)) {\n            $element = ['content' => (string) $element];\n        }\n\n        $element['type'] = 'module';\n\n        return $this->addInlineScript($element, $priority, $location);\n    }\n\n    /**\n     * @param array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addLink($element, $priority = 0, $location = 'head')\n    {\n        if (!is_array($element) || empty($element['rel']) || empty($element['href'])) {\n            return false;\n        }\n\n        if (!isset($this->links[$location])) {\n            $this->links[$location] = [];\n        }\n\n        $rel = (string) $element['rel'];\n        $href = (string) $element['href'];\n\n        unset($element['rel'], $element['href']);\n\n        $this->links[$location][md5($href) . sha1($href)] = [\n            ':type' => 'file',\n            ':priority' => (int) $priority,\n            'href' => $href,\n            'rel' => $rel,\n            'element' => $element,\n        ];\n\n        return true;\n    }\n\n    /**\n     * @param string $html\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addHtml($html, $priority = 0, $location = 'bottom')\n    {\n        if (empty($html) || !is_string($html)) {\n            return false;\n        }\n        if (!isset($this->html[$location])) {\n            $this->html[$location] = [];\n        }\n\n        $this->html[$location][md5($html) . sha1($html)] = [\n            ':priority' => (int) $priority,\n            'html' => $html\n        ];\n\n        return true;\n    }\n\n    /**\n     * @return array\n     */\n    protected function getAssetsFast()\n    {\n        $assets = [\n            'frameworks' => $this->frameworks,\n            'styles' => $this->styles,\n            'scripts' => $this->scripts,\n            'links' => $this->links,\n            'html' => $this->html\n        ];\n\n        foreach ($this->blocks as $block) {\n            if ($block instanceof self) {\n                $blockAssets = $block->getAssetsFast();\n                $assets['frameworks'] += $blockAssets['frameworks'];\n\n                foreach ($blockAssets['styles'] as $location => $styles) {\n                    if (!isset($assets['styles'][$location])) {\n                        $assets['styles'][$location] = $styles;\n                    } elseif ($styles) {\n                        $assets['styles'][$location] += $styles;\n                    }\n                }\n\n                foreach ($blockAssets['scripts'] as $location => $scripts) {\n                    if (!isset($assets['scripts'][$location])) {\n                        $assets['scripts'][$location] = $scripts;\n                    } elseif ($scripts) {\n                        $assets['scripts'][$location] += $scripts;\n                    }\n                }\n\n                foreach ($blockAssets['links'] as $location => $links) {\n                    if (!isset($assets['links'][$location])) {\n                        $assets['links'][$location] = $links;\n                    } elseif ($links) {\n                        $assets['links'][$location] += $links;\n                    }\n                }\n\n                foreach ($blockAssets['html'] as $location => $htmls) {\n                    if (!isset($assets['html'][$location])) {\n                        $assets['html'][$location] = $htmls;\n                    } elseif ($htmls) {\n                        $assets['html'][$location] += $htmls;\n                    }\n                }\n            }\n        }\n\n        return $assets;\n    }\n\n    /**\n     * @param string $type\n     * @param string $location\n     * @return array\n     */\n    protected function getAssetsInLocation($type, $location)\n    {\n        $assets = $this->getAssetsFast();\n\n        if (empty($assets[$type][$location])) {\n            return [];\n        }\n\n        $styles = $assets[$type][$location];\n        $this->sortAssetsInLocation($styles);\n\n        return $styles;\n    }\n\n    /**\n     * @param array $items\n     * @return void\n     */\n    protected function sortAssetsInLocation(array &$items)\n    {\n        $count = 0;\n        foreach ($items as &$item) {\n            $item[':order'] = ++$count;\n        }\n        unset($item);\n\n        uasort(\n            $items,\n            static function ($a, $b) {\n                return $a[':priority'] <=> $b[':priority'] ?: $a[':order'] <=> $b[':order'];\n            }\n        );\n    }\n\n    /**\n     * @param array $array\n     * @return void\n     */\n    protected function sortAssets(array &$array)\n    {\n        foreach ($array as &$items) {\n            $this->sortAssetsInLocation($items);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\ContentBlock\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\ContentBlock;\n\n/**\n * Interface HtmlBlockInterface\n * @package Grav\\Framework\\ContentBlock\n */\ninterface HtmlBlockInterface extends ContentBlockInterface\n{\n    /**\n     * @return array\n     */\n    public function getAssets();\n\n    /**\n     * @return array\n     */\n    public function getFrameworks();\n\n    /**\n     * @param string $location\n     * @return array\n     */\n    public function getStyles($location = 'head');\n\n    /**\n     * @param string $location\n     * @return array\n     */\n    public function getScripts($location = 'head');\n\n\n    /**\n     * @param string $location\n     * @return array\n     */\n    public function getLinks($location = 'head');\n\n    /**\n     * @param string $location\n     * @return array\n     */\n    public function getHtml($location = 'bottom');\n\n    /**\n     * @param string $framework\n     * @return $this\n     */\n    public function addFramework($framework);\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     *\n     * @example $block->addStyle('assets/js/my.js');\n     * @example $block->addStyle(['href' => 'assets/js/my.js', 'media' => 'screen']);\n     */\n    public function addStyle($element, $priority = 0, $location = 'head');\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addInlineStyle($element, $priority = 0, $location = 'head');\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addScript($element, $priority = 0, $location = 'head');\n\n    /**\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addInlineScript($element, $priority = 0, $location = 'head');\n\n\n    /**\n     * Shortcut for writing addScript(['type' => 'module', 'src' => ...]).\n     *\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addModule($element, $priority = 0, $location = 'head');\n\n    /**\n     * Shortcut for writing addInlineScript(['type' => 'module', 'content' => ...]).\n     *\n     * @param string|array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addInlineModule($element, $priority = 0, $location = 'head');\n\n    /**\n     * @param array $element\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addLink($element, $priority = 0, $location = 'head');\n\n    /**\n     * @param string $html\n     * @param int $priority\n     * @param string $location\n     * @return bool\n     */\n    public function addHtml($html, $priority = 0, $location = 'bottom');\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Contracts/Media/MediaObjectInterface.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Contracts\\Media;\n\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\n\n/**\n * Media Object Interface\n */\ninterface MediaObjectInterface extends IdentifierInterface\n{\n    /**\n     * Returns true if the object exists.\n     *\n     * @return bool\n     * @phpstan-pure\n     */\n    public function exists(): bool;\n\n    /**\n     * Get metadata associated to the media object.\n     *\n     * @return array\n     * @phpstan-pure\n     */\n    public function getMeta(): array;\n\n    /**\n     * @param string $field\n     * @return mixed\n     * @phpstan-pure\n     */\n    public function get(string $field);\n\n    /**\n     * Return URL pointing to the media object.\n     *\n     * @return string\n     * @phpstan-pure\n     */\n    public function getUrl(): string;\n\n    /**\n     * Create media response.\n     *\n     * @param array $actions\n     * @return ResponseInterface\n     * @phpstan-pure\n     */\n    public function createResponse(array $actions): ResponseInterface;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Contracts/Object/IdentifierInterface.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Contracts\\Object;\n\nuse JsonSerializable;\n\n/**\n * Interface IdentifierInterface\n */\ninterface IdentifierInterface extends JsonSerializable\n{\n    /**\n     * Get identifier's ID.\n     *\n     * @return string\n     * @phpstan-pure\n     */\n    public function getId(): string;\n\n    /**\n     * Get identifier's type.\n     *\n     * @return string\n     * @phpstan-pure\n     */\n    public function getType(): string;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Contracts/Relationships/RelationshipIdentifierInterface.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Contracts\\Relationships;\n\nuse ArrayAccess;\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\n\n/**\n * Interface RelationshipIdentifierInterface\n */\ninterface RelationshipIdentifierInterface extends IdentifierInterface\n{\n    /**\n     * If identifier has meta.\n     *\n     * @return bool\n     * @phpstan-pure\n     */\n    public function hasIdentifierMeta(): bool;\n\n    /**\n     * Get identifier meta.\n     *\n     * @return array<string,mixed>|ArrayAccess<string,mixed>\n     * @phpstan-pure\n     */\n    public function getIdentifierMeta();\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Contracts/Relationships/RelationshipInterface.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Contracts\\Relationships;\n\nuse Countable;\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\nuse IteratorAggregate;\nuse JsonSerializable;\nuse Serializable;\n\n/**\n * Interface Relationship\n *\n * @template T of IdentifierInterface\n * @template P of IdentifierInterface\n * @extends IteratorAggregate<string, T>\n */\ninterface RelationshipInterface extends Countable, IteratorAggregate, JsonSerializable, Serializable\n{\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getName(): string;\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getType(): string;\n\n    /**\n     * @return bool\n     * @phpstan-pure\n     */\n    public function isModified(): bool;\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getCardinality(): string;\n\n    /**\n     * @return P\n     * @phpstan-pure\n     */\n    public function getParent(): IdentifierInterface;\n\n    /**\n     * @param string $id\n     * @param string|null $type\n     * @return bool\n     * @phpstan-pure\n     */\n    public function has(string $id, string $type = null): bool;\n\n    /**\n     * @param T $identifier\n     * @return bool\n     * @phpstan-pure\n     */\n    public function hasIdentifier(IdentifierInterface $identifier): bool;\n\n    /**\n     * @param T $identifier\n     * @return bool\n     */\n    public function addIdentifier(IdentifierInterface $identifier): bool;\n\n    /**\n     * @param T|null $identifier\n     * @return bool\n     */\n    public function removeIdentifier(IdentifierInterface $identifier = null): bool;\n\n    /**\n     * @return iterable<T>\n     */\n    public function getIterator(): iterable;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Contracts/Relationships/RelationshipsInterface.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Contracts\\Relationships;\n\nuse ArrayAccess;\nuse Countable;\nuse Iterator;\nuse JsonSerializable;\n\n/**\n * Interface RelationshipsInterface\n *\n * @template T of \\Grav\\Framework\\Contracts\\Object\\IdentifierInterface\n * @template P of \\Grav\\Framework\\Contracts\\Object\\IdentifierInterface\n * @extends ArrayAccess<string,RelationshipInterface<T,P>>\n * @extends Iterator<string,RelationshipInterface<T,P>>\n */\ninterface RelationshipsInterface extends Countable, ArrayAccess, Iterator, JsonSerializable\n{\n    /**\n     * @return bool\n     * @phpstan-pure\n     */\n    public function isModified(): bool;\n\n    /**\n     * @return array\n     */\n    public function getModified(): array;\n\n    /**\n     * @return int\n     * @phpstan-pure\n     */\n    public function count(): int;\n\n    /**\n     * @param string $offset\n     * @return RelationshipInterface<T,P>|null\n     */\n    public function offsetGet($offset): ?RelationshipInterface;\n\n    /**\n     * @return RelationshipInterface<T,P>|null\n     */\n    public function current(): ?RelationshipInterface;\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function key(): string;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Contracts/Relationships/ToManyRelationshipInterface.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Contracts\\Relationships;\n\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\n\n/**\n * Interface ToManyRelationshipInterface\n *\n * @template T of IdentifierInterface\n * @template P of IdentifierInterface\n * @template-extends RelationshipInterface<T,P>\n */\ninterface ToManyRelationshipInterface extends RelationshipInterface\n{\n    /**\n     * @param positive-int $pos\n     * @return IdentifierInterface|null\n     */\n    public function getNthIdentifier(int $pos): ?IdentifierInterface;\n\n    /**\n     * @param string $id\n     * @param string|null $type\n     * @return T|null\n     * @phpstan-pure\n     */\n    public function getIdentifier(string $id, string $type = null): ?IdentifierInterface;\n\n    /**\n     * @param string $id\n     * @param string|null $type\n     * @return T|null\n     * @phpstan-pure\n     */\n    public function getObject(string $id, string $type = null): ?object;\n\n    /**\n     * @param iterable<T> $identifiers\n     * @return bool\n     */\n    public function addIdentifiers(iterable $identifiers): bool;\n\n    /**\n     * @param iterable<T> $identifiers\n     * @return bool\n     */\n    public function replaceIdentifiers(iterable $identifiers): bool;\n\n    /**\n     * @param iterable<T> $identifiers\n     * @return bool\n     */\n    public function removeIdentifiers(iterable $identifiers): bool;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Contracts/Relationships/ToOneRelationshipInterface.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Contracts\\Relationships;\n\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\n\n/**\n * Interface ToOneRelationshipInterface\n *\n * @template T of IdentifierInterface\n * @template P of IdentifierInterface\n * @template-extends RelationshipInterface<T,P>\n */\ninterface ToOneRelationshipInterface extends RelationshipInterface\n{\n    /**\n     * @param string|null $id\n     * @param string|null $type\n     * @return T|null\n     * @phpstan-pure\n     */\n    public function getIdentifier(string $id = null, string $type = null): ?IdentifierInterface;\n\n    /**\n     * @param string|null $id\n     * @param string|null $type\n     * @return T|null\n     * @phpstan-pure\n     */\n    public function getObject(string $id = null, string $type = null): ?object;\n\n    /**\n     * @param T|null $identifier\n     * @return bool\n     */\n    public function replaceIdentifier(IdentifierInterface $identifier = null): bool;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Controller\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\Controller\\Traits;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\ValidationException;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Psr7\\Response;\nuse Grav\\Framework\\RequestHandler\\Exception\\RequestException;\nuse Grav\\Framework\\Route\\Route;\nuse JsonSerializable;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Message\\StreamInterface;\nuse Throwable;\nuse function get_class;\nuse function in_array;\n\n/**\n * Trait ControllerResponseTrait\n * @package Grav\\Framework\\Controller\\Traits\n */\ntrait ControllerResponseTrait\n{\n    /**\n     * Display the current page.\n     *\n     * @return Response\n     */\n    protected function createDisplayResponse(): ResponseInterface\n    {\n        return new Response(418);\n    }\n\n    /**\n     * @param string $content\n     * @param int|null $code\n     * @param array|null $headers\n     * @return Response\n     */\n    protected function createHtmlResponse(string $content, int $code = null, array $headers = null): ResponseInterface\n    {\n        $code = $code ?? 200;\n        if ($code < 100 || $code > 599) {\n            $code = 500;\n        }\n        $headers = $headers ?? [];\n\n        return new Response($code, $headers, $content);\n    }\n\n    /**\n     * @param array $content\n     * @param int|null $code\n     * @param array|null $headers\n     * @return Response\n     */\n    protected function createJsonResponse(array $content, int $code = null, array $headers = null): ResponseInterface\n    {\n        $code = $code ?? $content['code'] ?? 200;\n        if (null === $code || $code < 100 || $code > 599) {\n            $code = 200;\n        }\n        $headers = ($headers ?? []) + [\n            'Content-Type' => 'application/json',\n            'Cache-Control' => 'no-store, max-age=0'\n        ];\n\n        return new Response($code, $headers, json_encode($content));\n    }\n\n    /**\n     * @param string $filename\n     * @param string|resource|StreamInterface $resource\n     * @param array|null $headers\n     * @param array|null $options\n     * @return ResponseInterface\n     */\n    protected function createDownloadResponse(string $filename, $resource, array $headers = null, array $options = null): ResponseInterface\n    {\n        // Required for IE, otherwise Content-Disposition may be ignored\n        if (ini_get('zlib.output_compression')) {\n            @ini_set('zlib.output_compression', 'Off');\n        }\n\n        $headers = $headers ?? [];\n        $options = $options ?? ['force_download' => true];\n\n        $file_parts = Utils::pathinfo($filename);\n\n        if (!isset($headers['Content-Type'])) {\n            $mimetype = Utils::getMimeByExtension($file_parts['extension']);\n\n            $headers['Content-Type'] = $mimetype;\n        }\n\n        // TODO: add multipart download support.\n        //$headers['Accept-Ranges'] = 'bytes';\n\n        if (!empty($options['force_download'])) {\n            $headers['Content-Disposition'] = 'attachment; filename=\"' . $file_parts['basename'] . '\"';\n        }\n\n        if (!isset($headers['Content-Length'])) {\n            $realpath = realpath($filename);\n            if ($realpath) {\n                $headers['Content-Length'] = filesize($realpath);\n            }\n        }\n\n        $headers += [\n            'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT',\n            'Last-Modified' => gmdate('D, d M Y H:i:s') . ' GMT',\n            'Cache-Control' => 'no-store, no-cache, must-revalidate',\n            'Pragma' => 'no-cache'\n        ];\n\n        return new Response(200, $headers, $resource);\n    }\n\n    /**\n     * @param string $url\n     * @param int|null $code\n     * @return Response\n     */\n    protected function createRedirectResponse(string $url, int $code = null): ResponseInterface\n    {\n        if (null === $code || $code < 301 || $code > 307) {\n            $code = (int)$this->getConfig()->get('system.pages.redirect_default_code', 302);\n        }\n\n        $ext = Utils::pathinfo($url, PATHINFO_EXTENSION);\n        $accept = $this->getAccept(['application/json', 'text/html']);\n        if ($ext === 'json' || $accept === 'application/json') {\n            return $this->createJsonResponse(['code' => $code, 'status' => 'redirect', 'redirect' => $url]);\n        }\n\n        return new Response($code, ['Location' => $url]);\n    }\n\n    /**\n     * @param Throwable $e\n     * @return ResponseInterface\n     */\n    protected function createErrorResponse(Throwable $e): ResponseInterface\n    {\n        $response = $this->getErrorJson($e);\n        $message = $response['message'];\n        $code = $response['code'];\n        $reason = $e instanceof RequestException ? $e->getHttpReason() : null;\n        $accept = $this->getAccept(['application/json', 'text/html']);\n\n        $request = $this->getRequest();\n        $context = $request->getAttributes();\n\n        /** @var Route $route */\n        $route = $context['route'] ?? null;\n\n        $ext = $route ? $route->getExtension() : null;\n        if ($ext !== 'json' && $accept === 'text/html') {\n            $method = $request->getMethod();\n\n            // On POST etc, redirect back to the previous page.\n            if ($method !== 'GET' && $method !== 'HEAD') {\n                $this->setMessage($message, 'error');\n                $referer = $request->getHeaderLine('Referer');\n\n                return $this->createRedirectResponse($referer, 303);\n            }\n\n            // TODO: improve error page\n            return $this->createHtmlResponse($response['message'], $code);\n        }\n\n        return new Response($code, ['Content-Type' => 'application/json'], json_encode($response), '1.1', $reason);\n    }\n\n    /**\n     * @param Throwable $e\n     * @return ResponseInterface\n     */\n    protected function createJsonErrorResponse(Throwable $e): ResponseInterface\n    {\n        $response = $this->getErrorJson($e);\n        $reason = $e instanceof RequestException ? $e->getHttpReason() : null;\n\n        return new Response($response['code'], ['Content-Type' => 'application/json'], json_encode($response), '1.1', $reason);\n    }\n\n    /**\n     * @param Throwable $e\n     * @return array\n     */\n    protected function getErrorJson(Throwable $e): array\n    {\n        $code = $this->getErrorCode($e instanceof RequestException ? $e->getHttpCode() : $e->getCode());\n        if ($e instanceof ValidationException) {\n            $message = $e->getMessage();\n        } else {\n            $message = htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_HTML5, 'UTF-8');\n        }\n\n        $extra = $e instanceof JsonSerializable ? $e->jsonSerialize() : [];\n\n        $response = [\n            'code' => $code,\n            'status' => 'error',\n            'message' => $message,\n            'redirect' => null,\n            'error' => [\n                'code' => $code,\n                'message' => $message\n            ] + $extra\n        ];\n\n        /** @var Debugger $debugger */\n        $debugger = Grav::instance()['debugger'];\n        if ($debugger->enabled()) {\n            $response['error'] += [\n                'type' => get_class($e),\n                'file' => $e->getFile(),\n                'line' => $e->getLine(),\n                'trace' => explode(\"\\n\", $e->getTraceAsString())\n            ];\n        }\n\n        return $response;\n    }\n\n    /**\n     * @param int $code\n     * @return int\n     */\n    protected function getErrorCode(int $code): int\n    {\n        static $errorCodes = [\n            400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,\n            422, 423, 424, 425, 426, 428, 429, 431, 451, 500, 501, 502, 503, 504, 505, 506, 507, 508, 511\n        ];\n\n        if (!in_array($code, $errorCodes, true)) {\n            $code = 500;\n        }\n\n        return $code;\n    }\n\n    /**\n     * @param array $compare\n     * @return mixed\n     */\n    protected function getAccept(array $compare)\n    {\n        $accepted = [];\n        foreach ($this->getRequest()->getHeader('Accept') as $accept) {\n            foreach (explode(',', $accept) as $item) {\n                if (!$item) {\n                    continue;\n                }\n\n                $split = explode(';q=', $item);\n                $mime = array_shift($split);\n                $priority = array_shift($split) ?? 1.0;\n\n                $accepted[$mime] = $priority;\n            }\n        }\n\n        arsort($accepted);\n\n        // TODO: add support for image/* etc\n        $list = array_intersect($compare, array_keys($accepted));\n        if (!$list && (isset($accepted['*/*']) || isset($accepted['*']))) {\n            return reset($compare);\n        }\n\n        return reset($list);\n    }\n\n    /**\n     * @return ServerRequestInterface\n     */\n    abstract protected function getRequest(): ServerRequestInterface;\n\n    /**\n     * @param string $message\n     * @param string $type\n     * @return $this\n     */\n    abstract protected function setMessage(string $message, string $type = 'info');\n\n    /**\n     * @return Config\n     */\n    abstract protected function getConfig(): Config;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/DI/Container.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\DI\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\DI;\n\nuse Psr\\Container\\ContainerInterface;\n\nclass Container extends \\Pimple\\Container implements ContainerInterface\n{\n    /**\n     * @param string $id\n     * @return mixed\n     */\n    public function get($id)\n    {\n        return $this->offsetGet($id);\n    }\n\n    /**\n     * @param string $id\n     * @return bool\n     */\n    public function has($id): bool\n    {\n        return $this->offsetExists($id);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/AbstractFile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File;\n\nuse Exception;\nuse Grav\\Framework\\Compat\\Serializable;\nuse Grav\\Framework\\File\\Interfaces\\FileInterface;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse RuntimeException;\n\n/**\n * Class AbstractFile\n * @package Grav\\Framework\\File\n */\nclass AbstractFile implements FileInterface\n{\n    use Serializable;\n\n    /** @var Filesystem */\n    private $filesystem;\n    /** @var string */\n    private $filepath;\n    /** @var string|null */\n    private $filename;\n    /** @var string|null */\n    private $path;\n    /** @var string|null */\n    private $basename;\n    /** @var string|null */\n    private $extension;\n    /** @var resource|null */\n    private $handle;\n    /** @var bool */\n    private $locked = false;\n\n    /**\n     * @param string $filepath\n     * @param Filesystem|null $filesystem\n     */\n    public function __construct(string $filepath, Filesystem $filesystem = null)\n    {\n        $this->filesystem = $filesystem ?? Filesystem::getInstance();\n        $this->setFilepath($filepath);\n    }\n\n    /**\n     * Unlock file when the object gets destroyed.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __destruct()\n    {\n        if ($this->isLocked()) {\n            $this->unlock();\n        }\n    }\n\n    /**\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __clone()\n    {\n        $this->handle = null;\n        $this->locked = false;\n    }\n\n    /**\n     * @return array\n     */\n    final public function __serialize(): array\n    {\n        return ['filesystem_normalize' => $this->filesystem->getNormalization()] + $this->doSerialize();\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    final public function __unserialize(array $data): void\n    {\n        $this->filesystem = Filesystem::getInstance($data['filesystem_normalize'] ?? null);\n\n        $this->doUnserialize($data);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::getFilePath()\n     */\n    public function getFilePath(): string\n    {\n        return $this->filepath;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::getPath()\n     */\n    public function getPath(): string\n    {\n        if (null === $this->path) {\n            $this->setPathInfo();\n        }\n\n        return $this->path ?? '';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::getFilename()\n     */\n    public function getFilename(): string\n    {\n        if (null === $this->filename) {\n            $this->setPathInfo();\n        }\n\n        return $this->filename ?? '';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::getBasename()\n     */\n    public function getBasename(): string\n    {\n        if (null === $this->basename) {\n            $this->setPathInfo();\n        }\n\n        return $this->basename ?? '';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::getExtension()\n     */\n    public function getExtension(bool $withDot = false): string\n    {\n        if (null === $this->extension) {\n            $this->setPathInfo();\n        }\n\n        return ($withDot ? '.' : '') . $this->extension;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::exists()\n     */\n    public function exists(): bool\n    {\n        return is_file($this->filepath);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::getCreationTime()\n     */\n    public function getCreationTime(): int\n    {\n        return is_file($this->filepath) ? (int)filectime($this->filepath) : time();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::getModificationTime()\n     */\n    public function getModificationTime(): int\n    {\n        return is_file($this->filepath) ? (int)filemtime($this->filepath) : time();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::lock()\n     */\n    public function lock(bool $block = true): bool\n    {\n        if (!$this->handle) {\n            if (!$this->mkdir($this->getPath())) {\n                throw new RuntimeException('Creating directory failed for ' . $this->filepath);\n            }\n            $this->handle = @fopen($this->filepath, 'cb+') ?: null;\n            if (!$this->handle) {\n                $error = error_get_last();\n                $message = $error['message'] ?? 'Unknown error';\n\n                throw new RuntimeException(\"Opening file for writing failed on error {$message}\");\n            }\n        }\n\n        $lock = $block ? LOCK_EX : LOCK_EX | LOCK_NB;\n\n        // Some filesystems do not support file locks, only fail if another process holds the lock.\n        $this->locked = flock($this->handle, $lock, $wouldBlock) || !$wouldBlock;\n\n        return $this->locked;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::unlock()\n     */\n    public function unlock(): bool\n    {\n        if (!$this->handle) {\n            return false;\n        }\n\n        if ($this->locked) {\n            flock($this->handle, LOCK_UN | LOCK_NB);\n            $this->locked = false;\n        }\n\n        fclose($this->handle);\n        $this->handle = null;\n\n        return true;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::isLocked()\n     */\n    public function isLocked(): bool\n    {\n        return $this->locked;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::isReadable()\n     */\n    public function isReadable(): bool\n    {\n        return is_readable($this->filepath) && is_file($this->filepath);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::isWritable()\n     */\n    public function isWritable(): bool\n    {\n        if (!file_exists($this->filepath)) {\n            return $this->isWritablePath($this->getPath());\n        }\n\n        return is_writable($this->filepath) && is_file($this->filepath);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::load()\n     */\n    public function load()\n    {\n        return file_get_contents($this->filepath);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::save()\n     */\n    public function save($data): void\n    {\n        $filepath = $this->filepath;\n        $dir = $this->getPath();\n\n        if (!$this->mkdir($dir)) {\n            throw new RuntimeException('Creating directory failed for ' . $filepath);\n        }\n\n        try {\n            if ($this->handle) {\n                $tmp = true;\n                // As we are using non-truncating locking, make sure that the file is empty before writing.\n                if (@ftruncate($this->handle, 0) === false || @fwrite($this->handle, $data) === false) {\n                    // Writing file failed, throw an error.\n                    $tmp = false;\n                }\n            } else {\n                // Support for symlinks.\n                $realpath = is_link($filepath) ? realpath($filepath) : $filepath;\n                if ($realpath === false) {\n                    throw new RuntimeException('Failed to save file ' . $filepath);\n                }\n\n                // Create file with a temporary name and rename it to make the save action atomic.\n                $tmp = $this->tempname($realpath);\n                if (@file_put_contents($tmp, $data) === false) {\n                    $tmp = false;\n                } elseif (@rename($tmp, $realpath) === false) {\n                    @unlink($tmp);\n                    $tmp = false;\n                }\n            }\n        } catch (Exception $e) {\n            $tmp = false;\n        }\n\n        if ($tmp === false) {\n            throw new RuntimeException('Failed to save file ' . $filepath);\n        }\n\n        // Touch the directory as well, thus marking it modified.\n        @touch($dir);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::rename()\n     */\n    public function rename(string $path): bool\n    {\n        if ($this->exists() && !@rename($this->filepath, $path)) {\n            return false;\n        }\n\n        $this->setFilepath($path);\n\n        return true;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::delete()\n     */\n    public function delete(): bool\n    {\n        return @unlink($this->filepath);\n    }\n\n    /**\n     * @param  string  $dir\n     * @return bool\n     * @throws RuntimeException\n     * @internal\n     */\n    protected function mkdir(string $dir): bool\n    {\n        // Silence error for open_basedir; should fail in mkdir instead.\n        if (@is_dir($dir)) {\n            return true;\n        }\n\n        $success = @mkdir($dir, 0777, true);\n\n        if (!$success) {\n            // Take yet another look, make sure that the folder doesn't exist.\n            clearstatcache(true, $dir);\n            if (!@is_dir($dir)) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * @return array\n     */\n    protected function doSerialize(): array\n    {\n        return [\n            'filepath' => $this->filepath\n        ];\n    }\n\n    /**\n     * @param array $serialized\n     * @return void\n     */\n    protected function doUnserialize(array $serialized): void\n    {\n        $this->setFilepath($serialized['filepath']);\n    }\n\n    /**\n     * @param string $filepath\n     */\n    protected function setFilepath(string $filepath): void\n    {\n        $this->filepath = $filepath;\n        $this->filename = null;\n        $this->basename = null;\n        $this->path = null;\n        $this->extension = null;\n    }\n\n    protected function setPathInfo(): void\n    {\n        /** @var array $pathInfo */\n        $pathInfo = $this->filesystem->pathinfo($this->filepath);\n\n        $this->filename = $pathInfo['filename'] ?? null;\n        $this->basename = $pathInfo['basename'] ?? null;\n        $this->path = $pathInfo['dirname'] ?? null;\n        $this->extension = $pathInfo['extension'] ?? null;\n    }\n\n    /**\n     * @param  string  $dir\n     * @return bool\n     * @internal\n     */\n    protected function isWritablePath(string $dir): bool\n    {\n        if ($dir === '') {\n            return false;\n        }\n\n        if (!file_exists($dir)) {\n            // Recursively look up in the directory tree.\n            return $this->isWritablePath($this->filesystem->parent($dir));\n        }\n\n        return is_dir($dir) && is_writable($dir);\n    }\n\n    /**\n     * @param string $filename\n     * @param int $length\n     * @return string\n     */\n    protected function tempname(string $filename, int $length = 5)\n    {\n        do {\n            $test = $filename . substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length);\n        } while (file_exists($test));\n\n        return $test;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/CsvFile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File;\n\nuse Grav\\Framework\\File\\Formatter\\CsvFormatter;\n\n/**\n * Class IniFile\n * @package RocketTheme\\Toolbox\\File\n */\nclass CsvFile extends DataFile\n{\n    /**\n     * File constructor.\n     * @param string $filepath\n     * @param CsvFormatter $formatter\n     */\n    public function __construct($filepath, CsvFormatter $formatter)\n    {\n        parent::__construct($filepath, $formatter);\n    }\n\n    /**\n     * @return array\n     */\n    public function load(): array\n    {\n        /** @var array */\n        return parent::load();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/DataFile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File;\n\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse RuntimeException;\nuse function is_string;\n\n/**\n * Class DataFile\n * @package Grav\\Framework\\File\n */\nclass DataFile extends AbstractFile\n{\n    /** @var FileFormatterInterface */\n    protected $formatter;\n\n    /**\n     * File constructor.\n     * @param string $filepath\n     * @param FileFormatterInterface $formatter\n     */\n    public function __construct($filepath, FileFormatterInterface $formatter)\n    {\n        parent::__construct($filepath);\n\n        $this->formatter = $formatter;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::load()\n     */\n    public function load()\n    {\n        $raw = parent::load();\n\n        try {\n            if (!is_string($raw)) {\n                throw new RuntimeException('Bad Data');\n            }\n\n            return $this->formatter->decode($raw);\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf(\"Failed to load file '%s': %s\", $this->getFilePath(), $e->getMessage()), $e->getCode(), $e);\n        }\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::save()\n     */\n    public function save($data): void\n    {\n        if (is_string($data)) {\n            // Make sure that the string is valid data.\n            try {\n                $this->formatter->decode($data);\n            } catch (RuntimeException $e) {\n                throw new RuntimeException(sprintf(\"Failed to save file '%s': %s\", $this->getFilePath(), $e->getMessage()), $e->getCode(), $e);\n            }\n            $encoded = $data;\n        } else {\n            $encoded = $this->formatter->encode($data);\n        }\n\n        parent::save($encoded);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/File.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File;\n\nuse RuntimeException;\nuse function is_string;\n\n/**\n * Class File\n * @package Grav\\Framework\\File\n */\nclass File extends AbstractFile\n{\n    /**\n     * {@inheritdoc}\n     * @see FileInterface::save()\n     */\n    public function save($data): void\n    {\n        if (!is_string($data)) {\n            throw new RuntimeException('Cannot save data, string required');\n        }\n\n        parent::save($data);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Formatter/AbstractFormatter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\\Formatter\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Formatter;\n\nuse Grav\\Framework\\Compat\\Serializable;\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse function is_string;\n\n/**\n * Abstract file formatter.\n *\n * @package Grav\\Framework\\File\\Formatter\n */\nabstract class AbstractFormatter implements FileFormatterInterface\n{\n    use Serializable;\n\n    /** @var array */\n    private $config;\n\n    /**\n     * IniFormatter constructor.\n     * @param array $config\n     */\n    public function __construct(array $config = [])\n    {\n        $this->config = $config;\n    }\n\n    /**\n     * @return string\n     */\n    public function getMimeType(): string\n    {\n        $mime = $this->getConfig('mime');\n\n        return is_string($mime) ? $mime : 'application/octet-stream';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::getDefaultFileExtension()\n     */\n    public function getDefaultFileExtension(): string\n    {\n        $extensions = $this->getSupportedFileExtensions();\n\n        // Call fails on bad configuration.\n        return reset($extensions) ?: '';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::getSupportedFileExtensions()\n     */\n    public function getSupportedFileExtensions(): array\n    {\n        $extensions = $this->getConfig('file_extension');\n\n        // Call fails on bad configuration.\n        return is_string($extensions) ? [$extensions] : $extensions;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::encode()\n     */\n    abstract public function encode($data): string;\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::decode()\n     */\n    abstract public function decode($data);\n\n\n    /**\n     * @return array\n     */\n    public function __serialize(): array\n    {\n        return ['config' => $this->config];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    public function __unserialize(array $data): void\n    {\n        $this->config = $data['config'];\n    }\n\n    /**\n     * Get either full configuration or a single option.\n     *\n     * @param string|null $name Configuration option (optional)\n     * @return mixed\n     */\n    protected function getConfig(string $name = null)\n    {\n        if (null !== $name) {\n            return $this->config[$name] ?? null;\n        }\n\n        return $this->config;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Formatter/CsvFormatter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\\Formatter\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Formatter;\n\nuse Exception;\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse JsonSerializable;\nuse RuntimeException;\nuse stdClass;\nuse function count;\nuse function is_array;\nuse function is_object;\nuse function is_scalar;\n\n/**\n * Class CsvFormatter\n * @package Grav\\Framework\\File\\Formatter\n */\nclass CsvFormatter extends AbstractFormatter\n{\n    /**\n     * IniFormatter constructor.\n     * @param array $config\n     */\n    public function __construct(array $config = [])\n    {\n        $config += [\n            'file_extension' => ['.csv', '.tsv'],\n            'delimiter' => ',',\n            'mime' => 'text/x-csv'\n        ];\n\n        parent::__construct($config);\n    }\n\n    /**\n     * Returns delimiter used to both encode and decode CSV.\n     *\n     * @return string\n     */\n    public function getDelimiter(): string\n    {\n        // Call fails on bad configuration.\n        return $this->getConfig('delimiter');\n    }\n\n    /**\n     * @param array $data\n     * @param string|null $delimiter\n     * @return string\n     * @see FileFormatterInterface::encode()\n     */\n    public function encode($data, $delimiter = null): string\n    {\n        if (count($data) === 0) {\n            return '';\n        }\n        $delimiter = $delimiter ?? $this->getDelimiter();\n        $header = array_keys(reset($data));\n\n        // Encode the field names\n        $string = $this->encodeLine($header, $delimiter);\n\n        // Encode the data\n        foreach ($data as $row) {\n            $string .= $this->encodeLine($row, $delimiter);\n        }\n\n        return $string;\n    }\n\n    /**\n     * @param string $data\n     * @param string|null $delimiter\n     * @return array\n     * @see FileFormatterInterface::decode()\n     */\n    public function decode($data, $delimiter = null): array\n    {\n        $delimiter = $delimiter ?? $this->getDelimiter();\n        $lines = preg_split('/\\r\\n|\\r|\\n/', $data);\n        if ($lines === false) {\n            throw new RuntimeException('Decoding CSV failed');\n        }\n\n        // Get the field names\n        $headerStr = array_shift($lines);\n        if (!$headerStr) {\n            throw new RuntimeException('CSV header missing');\n        }\n\n        $header = str_getcsv($headerStr, $delimiter);\n\n        // Allow for replacing a null string with null/empty value\n        $null_replace = $this->getConfig('null');\n\n        // Get the data\n        $list = [];\n        $line = null;\n        try {\n            foreach ($lines as $line) {\n                if (!empty($line)) {\n                    $csv_line = str_getcsv($line, $delimiter);\n\n                    if ($null_replace) {\n                        array_walk($csv_line, static function (&$el) use ($null_replace) {\n                            $el = str_replace($null_replace, \"\\0\", $el);\n                        });\n                    }\n\n                    $list[] = array_combine($header, $csv_line);\n                }\n            }\n        } catch (Exception $e) {\n            throw new RuntimeException('Badly formatted CSV line: ' . $line);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param array $line\n     * @param string $delimiter\n     * @return string\n     */\n    protected function encodeLine(array $line, string $delimiter): string\n    {\n        foreach ($line as $key => &$value) {\n            // Oops, we need to convert the line to a string.\n            if (!is_scalar($value)) {\n                if (is_array($value) || $value instanceof JsonSerializable || $value instanceof stdClass) {\n                    $value = json_encode($value);\n                } elseif (is_object($value)) {\n                    if (method_exists($value, 'toJson')) {\n                        $value = $value->toJson();\n                    } elseif (method_exists($value, 'toArray')) {\n                        $value = json_encode($value->toArray());\n                    }\n                }\n            }\n\n            $value = $this->escape((string)$value);\n        }\n        unset($value);\n\n        return implode($delimiter, $line). \"\\n\";\n    }\n\n    /**\n     * @param string $value\n     * @return string\n     */\n    protected function escape(string $value)\n    {\n        if (preg_match('/[,\"\\r\\n]/u', $value)) {\n            $value = '\"' . preg_replace('/\"/', '\"\"', $value) . '\"';\n        }\n\n        return $value;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Formatter/FormatterInterface.php",
    "content": "<?php\n\nnamespace Grav\\Framework\\File\\Formatter;\n\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\n\n/**\n * @deprecated 1.6 Use Grav\\Framework\\File\\Interfaces\\FileFormatterInterface instead\n */\ninterface FormatterInterface extends FileFormatterInterface\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Formatter/IniFormatter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\\Formatter\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Formatter;\n\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse RuntimeException;\n\n/**\n * Class IniFormatter\n * @package Grav\\Framework\\File\\Formatter\n */\nclass IniFormatter extends AbstractFormatter\n{\n    /**\n     * IniFormatter constructor.\n     * @param array $config\n     */\n    public function __construct(array $config = [])\n    {\n        $config += [\n            'file_extension' => '.ini'\n        ];\n\n        parent::__construct($config);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::encode()\n     */\n    public function encode($data): string\n    {\n        $string = '';\n        foreach ($data as $key => $value) {\n            $string .= $key . '=\"' .  preg_replace(\n                ['/\"/', '/\\\\\\/', \"/\\t/\", \"/\\n/\", \"/\\r/\"],\n                ['\\\"',  '\\\\\\\\', '\\t',   '\\n',   '\\r'],\n                $value\n            ) . \"\\\"\\n\";\n        }\n\n        return $string;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::decode()\n     */\n    public function decode($data): array\n    {\n        $decoded = @parse_ini_string($data);\n\n        if ($decoded === false) {\n            throw new RuntimeException('Decoding INI failed');\n        }\n\n        return $decoded;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Formatter/JsonFormatter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\\Formatter\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Formatter;\n\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse RuntimeException;\nuse function is_int;\nuse function is_string;\n\n/**\n * Class JsonFormatter\n * @package Grav\\Framework\\File\\Formatter\n */\nclass JsonFormatter extends AbstractFormatter\n{\n    /** @var array */\n    protected $encodeOptions = [\n        'JSON_FORCE_OBJECT' => JSON_FORCE_OBJECT,\n        'JSON_HEX_QUOT' => JSON_HEX_QUOT,\n        'JSON_HEX_TAG' => JSON_HEX_TAG,\n        'JSON_HEX_AMP' => JSON_HEX_AMP,\n        'JSON_HEX_APOS' => JSON_HEX_APOS,\n        'JSON_INVALID_UTF8_IGNORE' => JSON_INVALID_UTF8_IGNORE,\n        'JSON_INVALID_UTF8_SUBSTITUTE' => JSON_INVALID_UTF8_SUBSTITUTE,\n        'JSON_NUMERIC_CHECK' => JSON_NUMERIC_CHECK,\n        'JSON_PARTIAL_OUTPUT_ON_ERROR' => JSON_PARTIAL_OUTPUT_ON_ERROR,\n        'JSON_PRESERVE_ZERO_FRACTION' => JSON_PRESERVE_ZERO_FRACTION,\n        'JSON_PRETTY_PRINT' => JSON_PRETTY_PRINT,\n        'JSON_UNESCAPED_LINE_TERMINATORS' => JSON_UNESCAPED_LINE_TERMINATORS,\n        'JSON_UNESCAPED_SLASHES' => JSON_UNESCAPED_SLASHES,\n        'JSON_UNESCAPED_UNICODE' => JSON_UNESCAPED_UNICODE,\n        //'JSON_THROW_ON_ERROR' => JSON_THROW_ON_ERROR // PHP 7.3\n    ];\n\n    /** @var array */\n    protected $decodeOptions = [\n        'JSON_BIGINT_AS_STRING' => JSON_BIGINT_AS_STRING,\n        'JSON_INVALID_UTF8_IGNORE' => JSON_INVALID_UTF8_IGNORE,\n        'JSON_INVALID_UTF8_SUBSTITUTE' => JSON_INVALID_UTF8_SUBSTITUTE,\n        'JSON_OBJECT_AS_ARRAY' => JSON_OBJECT_AS_ARRAY,\n        //'JSON_THROW_ON_ERROR' => JSON_THROW_ON_ERROR // PHP 7.3\n    ];\n\n    public function __construct(array $config = [])\n    {\n        $config += [\n            'file_extension' => '.json',\n            'encode_options' => 0,\n            'decode_assoc' => true,\n            'decode_depth' => 512,\n            'decode_options' => 0\n        ];\n\n        parent::__construct($config);\n    }\n\n    /**\n     * Returns options used in encode() function.\n     *\n     * @return int\n     */\n    public function getEncodeOptions(): int\n    {\n        $options = $this->getConfig('encode_options');\n        if (!is_int($options)) {\n            if (is_string($options)) {\n                $list = preg_split('/[\\s,|]+/', $options);\n                $options = 0;\n                if ($list) {\n                    foreach ($list as $option) {\n                        if (isset($this->encodeOptions[$option])) {\n                            $options += $this->encodeOptions[$option];\n                        }\n                    }\n                }\n            } else {\n                $options = 0;\n            }\n        }\n\n        return $options;\n    }\n\n    /**\n     * Returns options used in decode() function.\n     *\n     * @return int\n     */\n    public function getDecodeOptions(): int\n    {\n        $options = $this->getConfig('decode_options');\n        if (!is_int($options)) {\n            if (is_string($options)) {\n                $list = preg_split('/[\\s,|]+/', $options);\n                $options = 0;\n                if ($list) {\n                    foreach ($list as $option) {\n                        if (isset($this->decodeOptions[$option])) {\n                            $options += $this->decodeOptions[$option];\n                        }\n                    }\n                }\n            } else {\n                $options = 0;\n            }\n        }\n\n        return $options;\n    }\n\n    /**\n     * Returns recursion depth used in decode() function.\n     *\n     * @return int\n     * @phpstan-return positive-int\n     */\n    public function getDecodeDepth(): int\n    {\n        return $this->getConfig('decode_depth');\n    }\n\n    /**\n     * Returns true if JSON objects will be converted into associative arrays.\n     *\n     * @return bool\n     */\n    public function getDecodeAssoc(): bool\n    {\n        return $this->getConfig('decode_assoc');\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::encode()\n     */\n    public function encode($data): string\n    {\n        $encoded = @json_encode($data, $this->getEncodeOptions());\n\n        if ($encoded === false && json_last_error() !== JSON_ERROR_NONE) {\n            throw new RuntimeException('Encoding JSON failed: ' . json_last_error_msg());\n        }\n\n        return $encoded ?: '';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::decode()\n     */\n    public function decode($data)\n    {\n        $decoded = @json_decode($data, $this->getDecodeAssoc(), $this->getDecodeDepth(), $this->getDecodeOptions());\n\n        if (null === $decoded && json_last_error() !== JSON_ERROR_NONE) {\n            throw new RuntimeException('Decoding JSON failed: ' . json_last_error_msg());\n        }\n\n        return $decoded;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Formatter/MarkdownFormatter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\\Formatter\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Formatter;\n\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse RuntimeException;\n\n/**\n * Class MarkdownFormatter\n * @package Grav\\Framework\\File\\Formatter\n */\nclass MarkdownFormatter extends AbstractFormatter\n{\n    /** @var FileFormatterInterface */\n    private $headerFormatter;\n\n    public function __construct(array $config = [], FileFormatterInterface $headerFormatter = null)\n    {\n        $config += [\n            'file_extension' => '.md',\n            'header' => 'header',\n            'body' => 'markdown',\n            'raw' => 'frontmatter',\n            'yaml' => ['inline' => 20]\n        ];\n\n        parent::__construct($config);\n\n        $this->headerFormatter = $headerFormatter ?? new YamlFormatter($config['yaml']);\n    }\n\n    /**\n     * Returns header field used in both encode() and decode().\n     *\n     * @return string\n     */\n    public function getHeaderField(): string\n    {\n        return $this->getConfig('header');\n    }\n\n    /**\n     * Returns body field used in both encode() and decode().\n     *\n     * @return string\n     */\n    public function getBodyField(): string\n    {\n        return $this->getConfig('body');\n    }\n\n    /**\n     * Returns raw field used in both encode() and decode().\n     *\n     * @return string\n     */\n    public function getRawField(): string\n    {\n        return $this->getConfig('raw');\n    }\n\n    /**\n     * Returns header formatter object used in both encode() and decode().\n     *\n     * @return FileFormatterInterface\n     */\n    public function getHeaderFormatter(): FileFormatterInterface\n    {\n        return $this->headerFormatter;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::encode()\n     */\n    public function encode($data): string\n    {\n        $headerVar = $this->getHeaderField();\n        $bodyVar = $this->getBodyField();\n\n        $header = isset($data[$headerVar]) ? (array) $data[$headerVar] : [];\n        $body = isset($data[$bodyVar]) ? (string) $data[$bodyVar] : '';\n\n        // Create Markdown file with YAML header.\n        $encoded = '';\n        if ($header) {\n            $encoded = \"---\\n\" . trim($this->getHeaderFormatter()->encode($data['header'])) . \"\\n---\\n\\n\";\n        }\n        $encoded .= $body;\n\n        // Normalize line endings to Unix style.\n        $encoded = preg_replace(\"/(\\r\\n|\\r)/u\", \"\\n\", $encoded);\n        if (null === $encoded) {\n            throw new RuntimeException('Encoding markdown failed');\n        }\n\n        return $encoded;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::decode()\n     */\n    public function decode($data): array\n    {\n        $headerVar = $this->getHeaderField();\n        $bodyVar = $this->getBodyField();\n        $rawVar = $this->getRawField();\n\n        // Define empty content\n        $content = [\n            $headerVar => [],\n            $bodyVar => ''\n        ];\n\n        $headerRegex = \"/^---\\n(.+?)\\n---\\n{0,}(.*)$/uis\";\n\n        // Normalize line endings to Unix style.\n        $data = preg_replace(\"/(\\r\\n|\\r)/u\", \"\\n\", $data);\n        if (null === $data) {\n            throw new RuntimeException('Decoding markdown failed');\n        }\n\n        // Parse header.\n        preg_match($headerRegex, ltrim($data), $matches);\n        if (empty($matches)) {\n            $content[$bodyVar] = $data;\n        } else {\n            // Normalize frontmatter.\n            $frontmatter = preg_replace(\"/\\n\\t/\", \"\\n    \", $matches[1]);\n            if ($rawVar) {\n                $content[$rawVar] = $frontmatter;\n            }\n            $content[$headerVar] = $this->getHeaderFormatter()->decode($frontmatter);\n            $content[$bodyVar] = $matches[2];\n        }\n\n        return $content;\n    }\n\n    public function __serialize(): array\n    {\n        return parent::__serialize() + ['headerFormatter' => $this->headerFormatter];\n    }\n\n    public function __unserialize(array $data): void\n    {\n        parent::__unserialize($data);\n\n        $this->headerFormatter = $data['headerFormatter'] ?? new YamlFormatter(['inline' => 20]);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Formatter/SerializeFormatter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\\Formatter\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Formatter;\n\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse RuntimeException;\nuse stdClass;\nuse function is_array;\nuse function is_string;\n\n/**\n * Class SerializeFormatter\n * @package Grav\\Framework\\File\\Formatter\n */\nclass SerializeFormatter extends AbstractFormatter\n{\n    /**\n     * IniFormatter constructor.\n     * @param array $config\n     */\n    public function __construct(array $config = [])\n    {\n        $config += [\n            'file_extension' => '.ser',\n            'decode_options' => ['allowed_classes' => [stdClass::class]]\n        ];\n\n        parent::__construct($config);\n    }\n\n    /**\n     * Returns options used in decode().\n     *\n     * By default only allow stdClass class.\n     *\n     * @return array\n     */\n    public function getOptions()\n    {\n        return $this->getConfig('decode_options');\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::encode()\n     */\n    public function encode($data): string\n    {\n        return serialize($this->preserveLines($data, [\"\\n\", \"\\r\"], ['\\\\n', '\\\\r']));\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::decode()\n     */\n    public function decode($data)\n    {\n        $classes = $this->getOptions()['allowed_classes'] ?? false;\n        $decoded = @unserialize($data, ['allowed_classes' => $classes]);\n\n        if ($decoded === false && $data !== serialize(false)) {\n            throw new RuntimeException('Decoding serialized data failed');\n        }\n\n        return $this->preserveLines($decoded, ['\\\\n', '\\\\r'], [\"\\n\", \"\\r\"]);\n    }\n\n    /**\n     * Preserve new lines, recursive function.\n     *\n     * @param mixed $data\n     * @param array $search\n     * @param array $replace\n     * @return mixed\n     */\n    protected function preserveLines($data, array $search, array $replace)\n    {\n        if (is_string($data)) {\n            $data = str_replace($search, $replace, $data);\n        } elseif (is_array($data)) {\n            foreach ($data as &$value) {\n                $value = $this->preserveLines($value, $search, $replace);\n            }\n            unset($value);\n        }\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Formatter/YamlFormatter.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\\Formatter\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Formatter;\n\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse RuntimeException;\nuse Symfony\\Component\\Yaml\\Exception\\DumpException;\nuse Symfony\\Component\\Yaml\\Exception\\ParseException;\nuse Symfony\\Component\\Yaml\\Yaml as YamlParser;\nuse RocketTheme\\Toolbox\\Compat\\Yaml\\Yaml as FallbackYamlParser;\nuse function function_exists;\n\n/**\n * Class YamlFormatter\n * @package Grav\\Framework\\File\\Formatter\n */\nclass YamlFormatter extends AbstractFormatter\n{\n    /**\n     * YamlFormatter constructor.\n     * @param array $config\n     */\n    public function __construct(array $config = [])\n    {\n        $config += [\n            'file_extension' => '.yaml',\n            'inline' => 5,\n            'indent' => 2,\n            'native' => true,\n            'compat' => true\n        ];\n\n        parent::__construct($config);\n    }\n\n    /**\n     * @return int\n     */\n    public function getInlineOption(): int\n    {\n        return $this->getConfig('inline');\n    }\n\n    /**\n     * @return int\n     */\n    public function getIndentOption(): int\n    {\n        return $this->getConfig('indent');\n    }\n\n    /**\n     * @return bool\n     */\n    public function useNativeDecoder(): bool\n    {\n        return $this->getConfig('native');\n    }\n\n    /**\n     * @return bool\n     */\n    public function useCompatibleDecoder(): bool\n    {\n        return $this->getConfig('compat');\n    }\n\n    /**\n     * @param array $data\n     * @param int|null $inline\n     * @param int|null $indent\n     * @return string\n     * @see FileFormatterInterface::encode()\n     */\n    public function encode($data, $inline = null, $indent = null): string\n    {\n        try {\n            return YamlParser::dump(\n                $data,\n                $inline ? (int) $inline : $this->getInlineOption(),\n                $indent ? (int) $indent : $this->getIndentOption(),\n                YamlParser::DUMP_EXCEPTION_ON_INVALID_TYPE\n            );\n        } catch (DumpException $e) {\n            throw new RuntimeException('Encoding YAML failed: ' . $e->getMessage(), 0, $e);\n        }\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FileFormatterInterface::decode()\n     */\n    public function decode($data): array\n    {\n        // Try native PECL YAML PHP extension first if available.\n        if (function_exists('yaml_parse') && $this->useNativeDecoder()) {\n            // Safely decode YAML.\n            $saved = @ini_get('yaml.decode_php');\n            @ini_set('yaml.decode_php', '0');\n            $decoded = @yaml_parse($data);\n            if ($saved !== false) {\n                @ini_set('yaml.decode_php', $saved);\n            }\n\n            if ($decoded !== false) {\n                return (array) $decoded;\n            }\n        }\n\n        try {\n            return (array) YamlParser::parse($data);\n        } catch (ParseException $e) {\n            if ($this->useCompatibleDecoder()) {\n                return (array) FallbackYamlParser::parse($data);\n            }\n\n            throw new RuntimeException('Decoding YAML failed: ' . $e->getMessage(), 0, $e);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/IniFile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File;\n\nuse Grav\\Framework\\File\\Formatter\\IniFormatter;\n\n/**\n * Class IniFile\n * @package RocketTheme\\Toolbox\\File\n */\nclass IniFile extends DataFile\n{\n    /**\n     * File constructor.\n     * @param string $filepath\n     * @param IniFormatter $formatter\n     */\n    public function __construct($filepath, IniFormatter $formatter)\n    {\n        parent::__construct($filepath, $formatter);\n    }\n\n    /**\n     * @return array\n     */\n    public function load(): array\n    {\n        /** @var array */\n        return parent::load();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Interfaces/FileFormatterInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Interfaces;\n\nuse Serializable;\n\n/**\n * Defines common interface for all file formatters.\n *\n * File formatters allow you to read and optionally write various file formats, such as:\n *\n * @used-by \\Grav\\Framework\\File\\Formatter\\CsvFormatter         CVS\n * @used-by \\Grav\\Framework\\File\\Formatter\\JsonFormatter        JSON\n * @used-by \\Grav\\Framework\\File\\Formatter\\MarkdownFormatter    Markdown\n * @used-by \\Grav\\Framework\\File\\Formatter\\SerializeFormatter   Serialized PHP\n * @used-by \\Grav\\Framework\\File\\Formatter\\YamlFormatter        YAML\n *\n * @since 1.6\n */\ninterface FileFormatterInterface extends Serializable\n{\n    /**\n     * @return string\n     * @since 1.7\n     */\n    public function getMimeType(): string;\n\n    /**\n     * Get default file extension from current formatter (with dot).\n     *\n     * Default file extension is the first defined extension.\n     *\n     * @return string Returns file extension (can be empty).\n     * @api\n     */\n    public function getDefaultFileExtension(): string;\n\n    /**\n     * Get file extensions supported by current formatter (with dot).\n     *\n     * @return string[] Returns list of all supported file extensions.\n     * @api\n     */\n    public function getSupportedFileExtensions(): array;\n\n    /**\n     * Encode data into a string.\n     *\n     * @param mixed $data Data to be encoded.\n     * @return string Returns encoded data as a string.\n     * @api\n     */\n    public function encode($data): string;\n\n    /**\n     * Decode a string into data.\n     *\n     * @param string $data String to be decoded.\n     * @return mixed Returns decoded data.\n     * @api\n     */\n    public function decode($data);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/Interfaces/FileInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File\\Interfaces;\n\nuse RuntimeException;\nuse Serializable;\n\n/**\n * Defines common interface for all file readers.\n *\n * File readers allow you to read and optionally write files of various file formats, such as:\n *\n * @used-by \\Grav\\Framework\\File\\CsvFile         CVS\n * @used-by \\Grav\\Framework\\File\\JsonFile        JSON\n * @used-by \\Grav\\Framework\\File\\MarkdownFile    Markdown\n * @used-by \\Grav\\Framework\\File\\SerializeFile   Serialized PHP\n * @used-by \\Grav\\Framework\\File\\YamlFile        YAML\n *\n * @since 1.6\n */\ninterface FileInterface extends Serializable\n{\n    /**\n     * Get both path and filename of the file.\n     *\n     * @return string Returns path and filename in the filesystem. Can also be URI.\n     * @api\n     */\n    public function getFilePath(): string;\n\n    /**\n     * Get path of the file.\n     *\n     * @return string Returns path in the filesystem. Can also be URI.\n     * @api\n     */\n    public function getPath(): string;\n\n    /**\n     * Get filename of the file.\n     *\n     * @return string Returns name of the file.\n     * @api\n     */\n    public function getFilename(): string;\n\n    /**\n     * Get basename of the file (filename without the associated file extension).\n     *\n     * @return string Returns basename of the file.\n     * @api\n     */\n    public function getBasename(): string;\n\n    /**\n     * Get file extension of the file.\n     *\n     * @param bool $withDot If true, return file extension with beginning dot (.json).\n     *\n     * @return string Returns file extension of the file (can be empty).\n     * @api\n     */\n    public function getExtension(bool $withDot = false): string;\n\n    /**\n     * Check if the file exits in the filesystem.\n     *\n     * @return bool Returns `true` if the filename exists and is a regular file, `false` otherwise.\n     * @api\n     */\n    public function exists(): bool;\n\n    /**\n     * Get file creation time.\n     *\n     * @return int Returns Unix timestamp. If file does not exist, method returns current time.\n     * @api\n     */\n    public function getCreationTime(): int;\n\n    /**\n     * Get file modification time.\n     *\n     * @return int Returns Unix timestamp. If file does not exist, method returns current time.\n     * @api\n     */\n    public function getModificationTime(): int;\n\n    /**\n     * Lock file for writing. You need to manually call unlock().\n     *\n     * @param bool $block For non-blocking lock, set the parameter to `false`.\n     *\n     * @return bool Returns `true` if the file was successfully locked, `false` otherwise.\n     * @throws RuntimeException\n     * @api\n     */\n    public function lock(bool $block = true): bool;\n\n    /**\n     * Unlock file after writing.\n     *\n     * @return bool Returns `true` if the file was successfully unlocked, `false` otherwise.\n     * @api\n     */\n    public function unlock(): bool;\n\n    /**\n     * Returns true if file has been locked by you for writing.\n     *\n     * @return bool Returns `true` if the file is locked, `false` otherwise.\n     * @api\n     */\n    public function isLocked(): bool;\n\n    /**\n     * Check if file exists and can be read.\n     *\n     * @return bool Returns `true` if the file can be read, `false` otherwise.\n     * @api\n     */\n    public function isReadable(): bool;\n\n    /**\n     * Check if file can be written.\n     *\n     * @return bool Returns `true` if the file can be written, `false` otherwise.\n     * @api\n     */\n    public function isWritable(): bool;\n\n    /**\n     * (Re)Load a file and return file contents.\n     *\n     * @return string|array|object|false Returns file content or `false` if file couldn't be read.\n     * @api\n     */\n    public function load();\n\n    /**\n     * Save file.\n     *\n     * See supported data format for each of the file format.\n     *\n     * @param  mixed $data Data to be saved.\n     *\n     * @throws RuntimeException\n     * @api\n     */\n    public function save($data): void;\n\n    /**\n     * Rename file in the filesystem if it exists.\n     *\n     * Target folder will be created if if did not exist.\n     *\n     * @param string $path New path and filename for the file. Can also be URI.\n     *\n     * @return bool Returns `true` if the file was successfully renamed, `false` otherwise.\n     * @api\n     */\n    public function rename(string $path): bool;\n\n    /**\n     * Delete file from filesystem.\n     *\n     * @return bool Returns `true` if the file was successfully deleted, `false` otherwise.\n     * @api\n     */\n    public function delete(): bool;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/JsonFile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File;\n\nuse Grav\\Framework\\File\\Formatter\\JsonFormatter;\n\n/**\n * Class JsonFile\n * @package Grav\\Framework\\File\n */\nclass JsonFile extends DataFile\n{\n    /**\n     * File constructor.\n     * @param string $filepath\n     * @param JsonFormatter $formatter\n     */\n    public function __construct($filepath, JsonFormatter $formatter)\n    {\n        parent::__construct($filepath, $formatter);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/MarkdownFile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File;\n\nuse Grav\\Framework\\File\\Formatter\\MarkdownFormatter;\n\n/**\n * Class MarkdownFile\n * @package Grav\\Framework\\File\n */\nclass MarkdownFile extends DataFile\n{\n    /**\n     * File constructor.\n     * @param string $filepath\n     * @param MarkdownFormatter $formatter\n     */\n    public function __construct($filepath, MarkdownFormatter $formatter)\n    {\n        parent::__construct($filepath, $formatter);\n    }\n\n    /**\n     * @return array\n     */\n    public function load(): array\n    {\n        /** @var array */\n        return parent::load();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/File/YamlFile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\File\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\File;\n\nuse Grav\\Framework\\File\\Formatter\\YamlFormatter;\n\n/**\n * Class YamlFile\n * @package Grav\\Framework\\File\n */\nclass YamlFile extends DataFile\n{\n    /**\n     * File constructor.\n     * @param string $filepath\n     * @param YamlFormatter $formatter\n     */\n    public function __construct($filepath, YamlFormatter $formatter)\n    {\n        parent::__construct($filepath, $formatter);\n    }\n\n    /**\n     * @return array\n     */\n    public function load(): array\n    {\n        /** @var array */\n        return parent::load();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Filesystem/Filesystem.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Filesystem\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Filesystem;\n\nuse Grav\\Framework\\Filesystem\\Interfaces\\FilesystemInterface;\nuse RuntimeException;\nuse function count;\nuse function dirname;\nuse function is_array;\nuse function pathinfo;\n\n/**\n * Class Filesystem\n * @package Grav\\Framework\\Filesystem\n */\nclass Filesystem implements FilesystemInterface\n{\n    /** @var bool|null */\n    private $normalize;\n\n    /** @var static|null */\n    protected static $default;\n\n    /** @var static|null */\n    protected static $unsafe;\n\n    /** @var static|null */\n    protected static $safe;\n\n    /**\n     * @param bool|null $normalize See $this->setNormalization()\n     * @return Filesystem\n     */\n    public static function getInstance(bool $normalize = null): Filesystem\n    {\n        if ($normalize === true) {\n            $instance = &static::$safe;\n        } elseif ($normalize === false) {\n            $instance = &static::$unsafe;\n        } else {\n            $instance = &static::$default;\n        }\n\n        if (null === $instance) {\n            $instance = new static($normalize);\n        }\n\n        return $instance;\n    }\n\n    /**\n     * Always use Filesystem::getInstance() instead.\n     *\n     * @param bool|null $normalize\n     * @internal\n     */\n    protected function __construct(bool $normalize = null)\n    {\n        $this->normalize = $normalize;\n    }\n\n    /**\n     * Set path normalization.\n     *\n     * Default option enables normalization for the streams only, but you can force the normalization to be either\n     * on or off for every path. Disabling path normalization speeds up the calls, but may cause issues if paths were\n     * not normalized.\n     *\n     * @param bool|null $normalize\n     * @return Filesystem\n     */\n    public function setNormalization(bool $normalize = null): self\n    {\n        return static::getInstance($normalize);\n    }\n\n    /**\n     * @return bool|null\n     */\n    public function getNormalization(): ?bool\n    {\n        return $this->normalize;\n    }\n\n    /**\n     * Force all paths to be normalized.\n     *\n     * @return self\n     */\n    public function unsafe(): self\n    {\n        return static::getInstance(true);\n    }\n\n    /**\n     * Force all paths not to be normalized (speeds up the calls if given paths are known to be normalized).\n     *\n     * @return self\n     */\n    public function safe(): self\n    {\n        return static::getInstance(false);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FilesystemInterface::parent()\n     */\n    public function parent(string $path, int $levels = 1): string\n    {\n        [$scheme, $path] = $this->getSchemeAndHierarchy($path);\n\n        if ($this->normalize !== false) {\n            $path = $this->normalizePathPart($path);\n        }\n\n        if ($path === '' || $path === '.') {\n            return '';\n        }\n\n        [$scheme, $parent] = $this->dirnameInternal($scheme, $path, $levels);\n\n        return $parent !== $path ? $this->toString($scheme, $parent) : '';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FilesystemInterface::normalize()\n     */\n    public function normalize(string $path): string\n    {\n        [$scheme, $path] = $this->getSchemeAndHierarchy($path);\n\n        $path = $this->normalizePathPart($path);\n\n        return $this->toString($scheme, $path);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FilesystemInterface::basename()\n     */\n    public function basename(string $path, ?string $suffix = null): string\n    {\n        // Escape path.\n        $path = str_replace(['%2F', '%5C'], '/', rawurlencode($path));\n\n        return rawurldecode($suffix ? basename($path, $suffix) : basename($path));\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FilesystemInterface::dirname()\n     */\n    public function dirname(string $path, int $levels = 1): string\n    {\n        [$scheme, $path] = $this->getSchemeAndHierarchy($path);\n\n        if ($this->normalize || ($scheme && null === $this->normalize)) {\n            $path = $this->normalizePathPart($path);\n        }\n\n        [$scheme, $path] = $this->dirnameInternal($scheme, $path, $levels);\n\n        return $this->toString($scheme, $path);\n    }\n\n    /**\n     * Gets full path with trailing slash.\n     *\n     * @param string $path\n     * @param int $levels\n     * @return string\n     * @phpstan-param positive-int $levels\n     */\n    public function pathname(string $path, int $levels = 1): string\n    {\n        $path = $this->dirname($path, $levels);\n\n        return $path !== '.' ? $path . '/' : '';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FilesystemInterface::pathinfo()\n     */\n    public function pathinfo(string $path, ?int $options = null)\n    {\n        [$scheme, $path] = $this->getSchemeAndHierarchy($path);\n\n        if ($this->normalize || ($scheme && null === $this->normalize)) {\n            $path = $this->normalizePathPart($path);\n        }\n\n        return $this->pathinfoInternal($scheme, $path, $options);\n    }\n\n    /**\n     * @param string|null $scheme\n     * @param string $path\n     * @param int $levels\n     * @return array\n     * @phpstan-param positive-int $levels\n     */\n    protected function dirnameInternal(?string $scheme, string $path, int $levels = 1): array\n    {\n        $path = dirname($path, $levels);\n\n        if (null !== $scheme && $path === '.') {\n            return [$scheme, ''];\n        }\n\n        // In Windows dirname() may return backslashes, fix that.\n        if (DIRECTORY_SEPARATOR !== '/') {\n            $path = str_replace('\\\\', '/', $path);\n        }\n\n        return [$scheme, $path];\n    }\n\n    /**\n     * @param string|null $scheme\n     * @param string $path\n     * @param int|null $options\n     * @return array|string\n     */\n    protected function pathinfoInternal(?string $scheme, string $path, ?int $options = null)\n    {\n        $path = str_replace(['%2F', '%5C'], ['/', '\\\\'], rawurlencode($path));\n\n        if (null === $options) {\n            $info = pathinfo($path);\n        } else {\n            $info = pathinfo($path, $options);\n        }\n\n        if (!is_array($info)) {\n            return rawurldecode($info);\n        }\n\n        $info = array_map('rawurldecode', $info);\n\n        if (null !== $scheme) {\n            $info['scheme'] = $scheme;\n\n            /** @phpstan-ignore-next-line because pathinfo('') doesn't have dirname */\n            $dirname = $info['dirname'] ?? '.';\n\n            if ('' !== $dirname && '.' !== $dirname) {\n                // In Windows dirname may be using backslashes, fix that.\n                if (DIRECTORY_SEPARATOR !== '/') {\n                    $dirname = str_replace(DIRECTORY_SEPARATOR, '/', $dirname);\n                }\n\n                $info['dirname'] = $scheme . '://' . $dirname;\n            } else {\n                $info = ['dirname' => $scheme . '://'] + $info;\n            }\n        }\n\n        return $info;\n    }\n\n    /**\n     * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> array(file, tmp)).\n     *\n     * @param string $filename\n     * @return array\n     */\n    protected function getSchemeAndHierarchy(string $filename): array\n    {\n        $components = explode('://', $filename, 2);\n\n        return 2 === count($components) ? $components : [null, $components[0]];\n    }\n\n    /**\n     * @param string|null $scheme\n     * @param string $path\n     * @return string\n     */\n    protected function toString(?string $scheme, string $path): string\n    {\n        if ($scheme) {\n            return $scheme . '://' . $path;\n        }\n\n        return $path;\n    }\n\n    /**\n     * @param string $path\n     * @return string\n     * @throws RuntimeException\n     */\n    protected function normalizePathPart(string $path): string\n    {\n        // Quick check for empty path.\n        if ($path === '' || $path === '.') {\n            return '';\n        }\n\n        // Quick check for root.\n        if ($path === '/') {\n            return '/';\n        }\n\n        // If the last character is not '/' or any of '\\', './', '//' and '..' are not found, path is clean and we're done.\n        if ($path[-1] !== '/' && !preg_match('`(\\\\\\\\|\\./|//|\\.\\.)`', $path)) {\n            return $path;\n        }\n\n        // Convert backslashes\n        $path = strtr($path, ['\\\\' => '/']);\n\n        $parts = explode('/', $path);\n\n        // Keep absolute paths.\n        $root = '';\n        if ($parts[0] === '') {\n            $root = '/';\n            array_shift($parts);\n        }\n\n        $list = [];\n        foreach ($parts as $i => $part) {\n            // Remove empty parts: // and /./\n            if ($part === '' || $part === '.') {\n                continue;\n            }\n\n            // Resolve /../ by removing path part.\n            if ($part === '..') {\n                $test = array_pop($list);\n                if ($test === null) {\n                    // Oops, user tried to access something outside of our root folder.\n                    throw new RuntimeException(\"Bad path {$path}\");\n                }\n            } else {\n                $list[] = $part;\n            }\n        }\n\n        // Build path back together.\n        return $root . implode('/', $list);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Filesystem/Interfaces/FilesystemInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Filesystem\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Filesystem\\Interfaces;\n\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse RuntimeException;\n\n/**\n * Defines several stream-save filesystem actions.\n *\n * @used-by Filesystem\n * @since 1.6\n */\ninterface FilesystemInterface\n{\n    /**\n     * Get parent path. Empty path is returned if there are no segments remaining.\n     *\n     * Can be used recursively to get towards the root directory.\n     *\n     * @param string    $path       A filename or path, does not need to exist as a file.\n     * @param int       $levels     The number of parent directories to go up (>= 1).\n     * @return string               Returns parent path.\n     * @throws RuntimeException\n     * @phpstan-param positive-int $levels\n     * @api\n     */\n    public function parent(string $path, int $levels = 1): string;\n\n    /**\n     * Normalize path by cleaning up `\\`, `/./`, `//` and `/../`.\n     *\n     * @param string    $path       A filename or path, does not need to exist as a file.\n     * @return string               Returns normalized path.\n     * @throws RuntimeException\n     * @api\n     */\n    public function normalize(string $path): string;\n\n    /**\n     * Unicode-safe and stream-safe `\\basename()` replacement.\n     *\n     * @param string      $path     A filename or path, does not need to exist as a file.\n     * @param string|null $suffix   If the filename ends in suffix this will also be cut off.\n     * @return string\n     * @api\n     */\n    public function basename(string $path, ?string $suffix = null): string;\n\n    /**\n     * Unicode-safe and stream-safe `\\dirname()` replacement.\n     *\n     * @see   http://php.net/manual/en/function.dirname.php\n     *\n     * @param string    $path       A filename or path, does not need to exist as a file.\n     * @param int       $levels     The number of parent directories to go up (>= 1).\n     * @return string               Returns path to the directory.\n     * @throws RuntimeException\n     * @phpstan-param positive-int $levels\n     * @api\n     */\n    public function dirname(string $path, int $levels = 1): string;\n\n    /**\n     * Unicode-safe and stream-safe `\\pathinfo()` replacement.\n     *\n     * @see   http://php.net/manual/en/function.pathinfo.php\n     *\n     * @param string    $path       A filename or path, does not need to exist as a file.\n     * @param int|null  $options    A PATHINFO_* constant.\n     * @return array|string\n     * @api\n     */\n    public function pathinfo(string $path, ?int $options = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Flex.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex;\n\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexCollectionInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Object\\ObjectCollection;\nuse RuntimeException;\nuse function count;\nuse function is_array;\n\n/**\n * Class Flex\n * @package Grav\\Framework\\Flex\n */\nclass Flex implements FlexInterface\n{\n    /** @var array */\n    protected $config;\n    /** @var FlexDirectory[] */\n    protected $types;\n\n    /**\n     * Flex constructor.\n     * @param array $types  List of [type => blueprint file, ...]\n     * @param array $config\n     */\n    public function __construct(array $types, array $config)\n    {\n        $this->config = $config;\n        $this->types = [];\n\n        foreach ($types as $type => $blueprint) {\n            if (!file_exists($blueprint)) {\n                /** @var Debugger $debugger */\n                $debugger = Grav::instance()['debugger'];\n                $debugger->addMessage(sprintf('Flex: blueprint for flex type %s is missing', $type), 'error');\n\n                continue;\n            }\n            $this->addDirectoryType($type, $blueprint);\n        }\n    }\n\n    /**\n     * @param string $type\n     * @param string $blueprint\n     * @param array  $config\n     * @return $this\n     */\n    public function addDirectoryType(string $type, string $blueprint, array $config = [])\n    {\n        $config = array_replace_recursive(['enabled' => true], $this->config, $config);\n\n        $this->types[$type] = new FlexDirectory($type, $blueprint, $config);\n\n        return $this;\n    }\n\n    /**\n     * @param FlexDirectory $directory\n     * @return $this\n     */\n    public function addDirectory(FlexDirectory $directory)\n    {\n        $this->types[$directory->getFlexType()] = $directory;\n\n        return $this;\n    }\n\n    /**\n     * @param string $type\n     * @return bool\n     */\n    public function hasDirectory(string $type): bool\n    {\n        return isset($this->types[$type]);\n    }\n\n    /**\n     * @param array|string[]|null $types\n     * @param bool $keepMissing\n     * @return array<FlexDirectory|null>\n     */\n    public function getDirectories(array $types = null, bool $keepMissing = false): array\n    {\n        if ($types === null) {\n            return $this->types;\n        }\n\n        // Return the directories in the given order.\n        $directories = [];\n        foreach ($types as $type) {\n            $directories[$type] = $this->types[$type] ?? null;\n        }\n\n        return $keepMissing ? $directories : array_filter($directories);\n    }\n\n    /**\n     * @param string $type\n     * @return FlexDirectory|null\n     */\n    public function getDirectory(string $type): ?FlexDirectory\n    {\n        return $this->types[$type] ?? null;\n    }\n\n    /**\n     * @param string $type\n     * @param array|null $keys\n     * @param string|null $keyField\n     * @return FlexCollectionInterface|null\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>|null\n     */\n    public function getCollection(string $type, array $keys = null, string $keyField = null): ?FlexCollectionInterface\n    {\n        $directory = $type ? $this->getDirectory($type) : null;\n\n        return $directory ? $directory->getCollection($keys, $keyField) : null;\n    }\n\n    /**\n     * @param array $keys\n     * @param array $options            In addition to the options in getObjects(), following options can be passed:\n     *                                  collection_class:   Class to be used to create the collection. Defaults to ObjectCollection.\n     * @return FlexCollectionInterface\n     * @throws RuntimeException\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>\n     */\n    public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface\n    {\n        $collectionClass = $options['collection_class'] ?? ObjectCollection::class;\n        if (!is_a($collectionClass, FlexCollectionInterface::class, true)) {\n            throw new RuntimeException(sprintf('Cannot create collection: Class %s does not exist', $collectionClass));\n        }\n\n        $objects = $this->getObjects($keys, $options);\n\n        return new $collectionClass($objects);\n    }\n\n    /**\n     * @param array $keys\n     * @param array $options    Following optional options can be passed:\n     *                          types:          List of allowed types.\n     *                          type:           Allowed type if types isn't defined, otherwise acts as default_type.\n     *                          default_type:   Set default type for objects given without type (only used if key_field isn't set).\n     *                          keep_missing:   Set to true if you want to return missing objects as null.\n     *                          key_field:      Key field which is used to match the objects.\n     * @return array\n     */\n    public function getObjects(array $keys, array $options = []): array\n    {\n        $type = $options['type'] ?? null;\n        $defaultType = $options['default_type'] ?? $type ?? null;\n        $keyField = $options['key_field'] ?? 'flex_key';\n\n        // Prepare empty result lists for all requested Flex types.\n        $types = $options['types'] ?? (array)$type ?: null;\n        if ($types) {\n            $types = array_fill_keys($types, []);\n        }\n        $strict = isset($types);\n\n        $guessed = [];\n        if ($keyField === 'flex_key') {\n            // We need to split Flex key lookups into individual directories.\n            $undefined = [];\n            $keyFieldFind = 'storage_key';\n\n            foreach ($keys as $flexKey) {\n                if (!$flexKey) {\n                    continue;\n                }\n\n                $flexKey = (string)$flexKey;\n                // Normalize key and type using fallback to default type if it was set.\n                [$key, $type, $guess] = $this->resolveKeyAndType($flexKey, $defaultType);\n\n                if ($type === '' && $types) {\n                    // Add keys which are not associated to any Flex type. They will be included to every Flex type.\n                    foreach ($types as $type => &$array) {\n                        $array[] = $key;\n                        $guessed[$key][] = \"{$type}.obj:{$key}\";\n                    }\n                    unset($array);\n                } elseif (!$strict || isset($types[$type])) {\n                    // Collect keys by their Flex type. If allowed types are defined, only include values from those types.\n                    $types[$type][] = $key;\n                    if ($guess) {\n                        $guessed[$key][] = \"{$type}.obj:{$key}\";\n                    }\n                }\n            }\n        } else {\n            // We are using a specific key field, make every key undefined.\n            $undefined = $keys;\n            $keyFieldFind = $keyField;\n        }\n\n        if (!$types) {\n            return [];\n        }\n\n        $list = [[]];\n        foreach ($types as $type => $typeKeys) {\n            // Also remember to look up keys from undefined Flex types.\n            $lookupKeys = $undefined ? array_merge($typeKeys, $undefined) : $typeKeys;\n\n            $collection = $this->getCollection($type, $lookupKeys, $keyFieldFind);\n            if ($collection && $keyFieldFind !== $keyField) {\n                $collection = $collection->withKeyField($keyField);\n            }\n\n            $list[] = $collection ? $collection->toArray() : [];\n        }\n\n        // Merge objects from individual types back together.\n        $list = array_merge(...$list);\n\n        // Use the original key ordering.\n        if (!$guessed) {\n            $list = array_replace(array_fill_keys($keys, null), $list);\n        } else {\n            // We have mixed keys, we need to map flex keys back to storage keys.\n            $results = [];\n            foreach ($keys as $key) {\n                $flexKey = $guessed[$key] ?? $key;\n                if (is_array($flexKey)) {\n                    $result = null;\n                    foreach ($flexKey as $tryKey) {\n                        if ($result = $list[$tryKey] ?? null) {\n                            // Use the first matching object (conflicting objects will be ignored for now).\n                            break;\n                        }\n                    }\n                } else {\n                    $result = $list[$flexKey] ?? null;\n                }\n\n                $results[$key] = $result;\n            }\n\n            $list = $results;\n        }\n\n        // Remove missing objects if not asked to keep them.\n        if (empty($options['keep_missing'])) {\n            $list = array_filter($list);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $key\n     * @param string|null $type\n     * @param string|null $keyField\n     * @return FlexObjectInterface|null\n     */\n    public function getObject(string $key, string $type = null, string $keyField = null): ?FlexObjectInterface\n    {\n        if (null === $type && null === $keyField) {\n            // Special handling for quick Flex key lookups.\n            $keyField = 'storage_key';\n            [$key, $type] = $this->resolveKeyAndType($key, $type);\n        } else {\n            $type = $this->resolveType($type);\n        }\n\n        if ($type === '' || $key === '') {\n            return null;\n        }\n\n        $directory = $this->getDirectory($type);\n\n        return $directory ? $directory->getObject($key, $keyField) : null;\n    }\n\n    /**\n     * @return int\n     */\n    public function count(): int\n    {\n        return count($this->types);\n    }\n\n    /**\n     * @param string $flexKey\n     * @param string|null $type\n     * @return array\n     */\n    protected function resolveKeyAndType(string $flexKey, string $type = null): array\n    {\n        $guess = false;\n        if (strpos($flexKey, ':') !== false) {\n            [$type, $key] = explode(':', $flexKey, 2);\n\n            $type = $this->resolveType($type);\n        } else {\n            $key = $flexKey;\n            $type = (string)$type;\n            $guess = true;\n        }\n\n        return [$key, $type, $guess];\n    }\n\n    /**\n     * @param string|null $type\n     * @return string\n     */\n    protected function resolveType(string $type = null): string\n    {\n        if (null !== $type && strpos($type, '.') !== false) {\n            return preg_replace('|\\.obj$|', '', $type) ?? $type;\n        }\n\n        return $type ?? '';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/FlexCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex;\n\nuse Doctrine\\Common\\Collections\\Collection;\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Inflector;\nuse Grav\\Common\\Twig\\Twig;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Cache\\CacheInterface;\nuse Grav\\Framework\\ContentBlock\\HtmlBlock;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexIndexInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Object\\ObjectCollection;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexCollectionInterface;\nuse Psr\\SimpleCache\\InvalidArgumentException;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse Twig\\Error\\LoaderError;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Template;\nuse Twig\\TemplateWrapper;\nuse function array_filter;\nuse function get_class;\nuse function in_array;\nuse function is_array;\nuse function is_scalar;\n\n/**\n * Class FlexCollection\n * @package Grav\\Framework\\Flex\n * @template T of FlexObjectInterface\n * @extends ObjectCollection<string,T>\n * @implements FlexCollectionInterface<T>\n */\nclass FlexCollection extends ObjectCollection implements FlexCollectionInterface\n{\n    /** @var FlexDirectory */\n    private $_flexDirectory;\n\n    /** @var string */\n    private $_keyField = 'storage_key';\n\n    /**\n     * Get list of cached methods.\n     *\n     * @return array Returns a list of methods with their caching information.\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n            'getTypePrefix' => true,\n            'getType' => true,\n            'getFlexDirectory' => true,\n            'hasFlexFeature' => true,\n            'getFlexFeatures' => true,\n            'getCacheKey' => true,\n            'getCacheChecksum' => false,\n            'getTimestamp' => true,\n            'hasProperty' => true,\n            'getProperty' => true,\n            'hasNestedProperty' => true,\n            'getNestedProperty' => true,\n            'orderBy' => true,\n\n            'render' => false,\n            'isAuthorized' => 'session',\n            'search' => true,\n            'sort' => true,\n            'getDistinctValues' => true\n        ];\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::createFromArray()\n     */\n    public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null)\n    {\n        $instance = new static($entries, $directory);\n        $instance->setKeyField($keyField);\n\n        return $instance;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::__construct()\n     */\n    public function __construct(array $entries = [], FlexDirectory $directory = null)\n    {\n        // @phpstan-ignore-next-line\n        if (get_class($this) === __CLASS__) {\n            user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \\Grav\\Common\\Flex\\Types\\Generic\\GenericCollection or your own class instead', E_USER_DEPRECATED);\n        }\n\n        parent::__construct($entries);\n\n        if ($directory) {\n            $this->setFlexDirectory($directory)->setKey($directory->getFlexType());\n        }\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCommonInterface::hasFlexFeature()\n     */\n    public function hasFlexFeature(string $name): bool\n    {\n        return in_array($name, $this->getFlexFeatures(), true);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCommonInterface::hasFlexFeature()\n     */\n    public function getFlexFeatures(): array\n    {\n        /** @var array $implements */\n        $implements = class_implements($this);\n\n        $list = [];\n        foreach ($implements as $interface) {\n            if ($pos = strrpos($interface, '\\\\')) {\n                $interface = substr($interface, $pos+1);\n            }\n\n            $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface));\n        }\n\n        return $list;\n\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::search()\n     */\n    public function search(string $search, $properties = null, array $options = null)\n    {\n        $directory = $this->getFlexDirectory();\n        $properties = $directory->getSearchProperties($properties);\n        $options = $directory->getSearchOptions($options);\n\n        $matching = $this->call('search', [$search, $properties, $options]);\n        $matching = array_filter($matching);\n\n        if ($matching) {\n            arsort($matching, SORT_NUMERIC);\n        }\n\n        /** @var string[] $array */\n        $array = array_keys($matching);\n\n        /** @phpstan-var static<T> */\n        return $this->select($array);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::sort()\n     */\n    public function sort(array $order)\n    {\n        $criteria = Criteria::create()->orderBy($order);\n\n        /** @phpstan-var FlexCollectionInterface<T> $matching */\n        $matching = $this->matching($criteria);\n\n        return $matching;\n    }\n\n    /**\n     * @param array $filters\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function filterBy(array $filters)\n    {\n        $expr = Criteria::expr();\n        $criteria = Criteria::create();\n\n        foreach ($filters as $key => $value) {\n            $criteria->andWhere($expr->eq($key, $value));\n        }\n\n        /** @phpstan-var static<T> */\n        return $this->matching($criteria);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexType()\n     */\n    public function getFlexType(): string\n    {\n        return $this->_flexDirectory->getFlexType();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexDirectory()\n     */\n    public function getFlexDirectory(): FlexDirectory\n    {\n        return $this->_flexDirectory;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getTimestamp()\n     */\n    public function getTimestamp(): int\n    {\n        $timestamps = $this->getTimestamps();\n\n        return $timestamps ? max($timestamps) : time();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexDirectory()\n     */\n    public function getCacheKey(): string\n    {\n        return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1((string)json_encode($this->call('getKey')));\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexDirectory()\n     */\n    public function getCacheChecksum(): string\n    {\n        $list = [];\n        /**\n         * @var string $key\n         * @var FlexObjectInterface $object\n         */\n        foreach ($this as $key => $object) {\n            $list[$key] = $object->getCacheChecksum();\n        }\n\n        return sha1((string)json_encode($list));\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexDirectory()\n     */\n    public function getTimestamps(): array\n    {\n        /** @var int[] $timestamps */\n        $timestamps = $this->call('getTimestamp');\n\n        return $timestamps;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexDirectory()\n     */\n    public function getStorageKeys(): array\n    {\n        /** @var string[] $keys */\n        $keys = $this->call('getStorageKey');\n\n        return $keys;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexDirectory()\n     */\n    public function getFlexKeys(): array\n    {\n        /** @var string[] $keys */\n        $keys = $this->call('getFlexKey');\n\n        return $keys;\n    }\n\n    /**\n     * Get all the values in property.\n     *\n     * Supports either single scalar values or array of scalar values.\n     *\n     * @param string $property      Object property to be used to make groups.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return array\n     */\n    public function getDistinctValues(string $property, string $separator = null): array\n    {\n        $list = [];\n\n        /** @var FlexObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $value = (array)$element->getNestedProperty($property, null, $separator);\n            foreach ($value as $v) {\n                if (is_scalar($v)) {\n                    $t = gettype($v) . (string)$v;\n                    $list[$t] = $v;\n                }\n            }\n        }\n\n        return array_values($list);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::withKeyField()\n     */\n    public function withKeyField(string $keyField = null)\n    {\n        $keyField = $keyField ?: 'key';\n        if ($keyField === $this->getKeyField()) {\n            return $this;\n        }\n\n        $entries = [];\n        foreach ($this as $key => $object) {\n            // TODO: remove hardcoded logic\n            if ($keyField === 'storage_key') {\n                $entries[$object->getStorageKey()] = $object;\n            } elseif ($keyField === 'flex_key') {\n                $entries[$object->getFlexKey()] = $object;\n            } elseif ($keyField === 'key') {\n                $entries[$object->getKey()] = $object;\n            }\n        }\n\n        return $this->createFrom($entries, $keyField);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getIndex()\n     */\n    public function getIndex()\n    {\n        /** @phpstan-var FlexIndexInterface<T> */\n        return $this->getFlexDirectory()->getIndex($this->getKeys(), $this->getKeyField());\n    }\n\n    /**\n     * @inheritdoc}\n     * @see FlexCollectionInterface::getCollection()\n     * @return $this\n     */\n    public function getCollection()\n    {\n        return $this;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::render()\n     */\n    public function render(string $layout = null, array $context = [])\n    {\n        if (!$layout) {\n            $config = $this->getTemplateConfig();\n            $layout = $config['collection']['defaults']['layout'] ?? 'default';\n        }\n\n        $type = $this->getFlexType();\n\n        $grav = Grav::instance();\n\n        /** @var Debugger $debugger */\n        $debugger = $grav['debugger'];\n        $debugger->startTimer('flex-collection-' . ($debugKey =  uniqid($type, false)), 'Render Collection ' . $type . ' (' . $layout . ')');\n\n        $key = null;\n        foreach ($context as $value) {\n            if (!is_scalar($value)) {\n                $key = false;\n                break;\n            }\n        }\n\n        if ($key !== false) {\n            $key = md5($this->getCacheKey() . '.' . $layout . json_encode($context));\n            $cache = $this->getCache('render');\n        } else {\n            $cache = null;\n        }\n\n        try {\n            $data = $cache && $key ? $cache->get($key) : null;\n\n            $block = $data ? HtmlBlock::fromArray($data) : null;\n        } catch (InvalidArgumentException $e) {\n            $debugger->addException($e);\n            $block = null;\n        } catch (\\InvalidArgumentException $e) {\n            $debugger->addException($e);\n            $block = null;\n        }\n\n        $checksum = $this->getCacheChecksum();\n        if ($block && $checksum !== $block->getChecksum()) {\n            $block = null;\n        }\n\n        if (!$block) {\n            $block = HtmlBlock::create($key ?: null);\n            $block->setChecksum($checksum);\n            if (!$key) {\n                $block->disableCache();\n            }\n\n            $event = new Event([\n                'type' => 'flex',\n                'directory' => $this->getFlexDirectory(),\n                'collection' => $this,\n                'layout' => &$layout,\n                'context' => &$context\n            ]);\n            $this->triggerEvent('onRender', $event);\n\n            $output = $this->getTemplate($layout)->render(\n                [\n                    'grav' => $grav,\n                    'config' => $grav['config'],\n                    'block' => $block,\n                    'directory' => $this->getFlexDirectory(),\n                    'collection' => $this,\n                    'layout' => $layout\n                ] + $context\n            );\n\n            if ($debugger->enabled() &&\n                !($grav['uri']->getContentType() === 'application/json' || $grav['uri']->extension() === 'json')) {\n                $output = \"\\n<!–– START {$type} collection ––>\\n{$output}\\n<!–– END {$type} collection ––>\\n\";\n            }\n\n            $block->setContent($output);\n\n            try {\n                $cache && $key && $block->isCached() && $cache->set($key, $block->toArray());\n            } catch (InvalidArgumentException $e) {\n                $debugger->addException($e);\n            }\n        }\n\n        $debugger->stopTimer('flex-collection-' . $debugKey);\n\n        return $block;\n    }\n\n    /**\n     * @param FlexDirectory $type\n     * @return $this\n     */\n    public function setFlexDirectory(FlexDirectory $type)\n    {\n        $this->_flexDirectory = $type;\n\n        return $this;\n    }\n\n    /**\n     * @param string $key\n     * @return array\n     */\n    public function getMetaData($key): array\n    {\n        $object = $this->get($key);\n\n        return $object instanceof FlexObjectInterface ? $object->getMetaData() : [];\n    }\n\n    /**\n     * @param string|null $namespace\n     * @return CacheInterface\n     */\n    public function getCache(string $namespace = null)\n    {\n        return $this->_flexDirectory->getCache($namespace);\n    }\n\n    /**\n     * @return string\n     */\n    public function getKeyField(): string\n    {\n        return $this->_keyField;\n    }\n\n    /**\n     * @param string $action\n     * @param string|null $scope\n     * @param UserInterface|null $user\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function isAuthorized(string $action, string $scope = null, UserInterface $user = null)\n    {\n        $list = $this->call('isAuthorized', [$action, $scope, $user]);\n        $list = array_filter($list);\n\n        /** @var string[] $keys */\n        $keys = array_keys($list);\n\n        /** @phpstan-var static<T> */\n        return $this->select($keys);\n    }\n\n    /**\n     * @param string $value\n     * @param string $field\n     * @return FlexObjectInterface|null\n     * @phpstan-return T|null\n     */\n    public function find($value, $field = 'id')\n    {\n        if ($value) {\n            foreach ($this as $element) {\n                if (mb_strtolower($element->getProperty($field)) === mb_strtolower($value)) {\n                    return $element;\n                }\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        $elements = [];\n\n        /**\n         * @var string $key\n         * @var array|FlexObject $object\n         */\n        foreach ($this->getElements() as $key => $object) {\n            $elements[$key] = is_array($object) ? $object : $object->jsonSerialize();\n        }\n\n        return $elements;\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function __debugInfo()\n    {\n        return [\n            'type:private' => $this->getFlexType(),\n            'key:private' => $this->getKey(),\n            'objects_key:private' => $this->getKeyField(),\n            'objects:private' => $this->getElements()\n        ];\n    }\n\n    /**\n     * Creates a new instance from the specified elements.\n     *\n     * This method is provided for derived classes to specify how a new\n     * instance should be created when constructor semantics have changed.\n     *\n     * @param array $elements Elements.\n     * @param string|null $keyField\n     * @return static\n     * @phpstan-return static<T>\n     * @throws \\InvalidArgumentException\n     */\n    protected function createFrom(array $elements, $keyField = null)\n    {\n        $collection = new static($elements, $this->_flexDirectory);\n        $collection->setKeyField($keyField ?: $this->_keyField);\n\n        return $collection;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getTypePrefix(): string\n    {\n        return 'c.';\n    }\n\n    /**\n     * @return array\n     */\n    protected function getTemplateConfig(): array\n    {\n        $config = $this->getFlexDirectory()->getConfig('site.templates', []);\n        $defaults = array_replace($config['defaults'] ?? [], $config['collection']['defaults'] ?? []);\n        $config['collection']['defaults'] = $defaults;\n\n        return $config;\n    }\n\n    /**\n     * @param string $layout\n     * @return array\n     */\n    protected function getTemplatePaths(string $layout): array\n    {\n        $config = $this->getTemplateConfig();\n        $type = $this->getFlexType();\n        $defaults = $config['collection']['defaults'] ?? [];\n\n        $ext = $defaults['ext'] ?? '.html.twig';\n        $types = array_unique(array_merge([$type], (array)($defaults['type'] ?? null)));\n        $paths = $config['collection']['paths'] ?? [\n                'flex/{TYPE}/collection/{LAYOUT}{EXT}',\n                'flex-objects/layouts/{TYPE}/collection/{LAYOUT}{EXT}'\n            ];\n        $table = ['TYPE' => '%1$s', 'LAYOUT' => '%2$s', 'EXT' => '%3$s'];\n\n        $lookups = [];\n        foreach ($paths as $path) {\n            $path = Utils::simpleTemplate($path, $table);\n            foreach ($types as $type) {\n                $lookups[] = sprintf($path, $type, $layout, $ext);\n            }\n        }\n\n        return array_unique($lookups);\n    }\n\n    /**\n     * @param string $layout\n     * @return Template|TemplateWrapper\n     * @throws LoaderError\n     * @throws SyntaxError\n     */\n    protected function getTemplate($layout)\n    {\n        $grav = Grav::instance();\n\n        /** @var Twig $twig */\n        $twig = $grav['twig'];\n\n        try {\n            return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout));\n        } catch (LoaderError $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            return $twig->twig()->resolveTemplate(['flex/404.html.twig']);\n        }\n    }\n\n    /**\n     * @param string $type\n     * @return FlexDirectory\n     */\n    protected function getRelatedDirectory($type): ?FlexDirectory\n    {\n        /** @var Flex $flex */\n        $flex = Grav::instance()['flex'];\n\n        return $flex->getDirectory($type);\n    }\n\n    /**\n     * @param string|null $keyField\n     * @return void\n     */\n    protected function setKeyField($keyField = null): void\n    {\n        $this->_keyField = $keyField ?? 'storage_key';\n    }\n\n    // DEPRECATED METHODS\n\n    /**\n     * @param bool $prefix\n     * @return string\n     * @deprecated 1.6 Use `->getFlexType()` instead.\n     */\n    public function getType($prefix = false)\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED);\n\n        $type = $prefix ? $this->getTypePrefix() : '';\n\n        return $type . $this->getFlexType();\n    }\n\n    /**\n     * @param string $name\n     * @param object|null $event\n     * @return $this\n     * @deprecated 1.7, moved to \\Grav\\Common\\Flex\\Traits\\FlexObjectTrait\n     */\n    public function triggerEvent(string $name, $event = null)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \\Grav\\Common\\Flex\\Traits\\FlexObjectTrait', E_USER_DEPRECATED);\n\n        if (null === $event) {\n            $event = new Event([\n                'type' => 'flex',\n                'directory' => $this->getFlexDirectory(),\n                'collection' => $this\n            ]);\n        }\n        if (strpos($name, 'onFlexCollection') !== 0 && strpos($name, 'on') === 0) {\n            $name = 'onFlexCollection' . substr($name, 2);\n        }\n\n        $grav = Grav::instance();\n        if ($event instanceof Event) {\n            $grav->fireEvent($name, $event);\n        } else {\n            $grav->dispatchEvent($event);\n        }\n\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/FlexDirectory.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex;\n\nuse Exception;\nuse Grav\\Common\\Cache;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Cache\\Adapter\\DoctrineCache;\nuse Grav\\Framework\\Cache\\Adapter\\MemoryCache;\nuse Grav\\Framework\\Cache\\CacheInterface;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexCollectionInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexDirectoryInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexFormInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexIndexInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexStorageInterface;\nuse Grav\\Framework\\Flex\\Storage\\SimpleStorage;\nuse Grav\\Framework\\Flex\\Traits\\FlexAuthorizeTrait;\nuse Psr\\SimpleCache\\InvalidArgumentException;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function call_user_func_array;\nuse function count;\nuse function is_array;\nuse Grav\\Common\\Flex\\Types\\Generic\\GenericObject;\nuse Grav\\Common\\Flex\\Types\\Generic\\GenericCollection;\nuse Grav\\Common\\Flex\\Types\\Generic\\GenericIndex;\nuse function is_callable;\n\n/**\n * Class FlexDirectory\n * @package Grav\\Framework\\Flex\n */\nclass FlexDirectory implements FlexDirectoryInterface\n{\n    use FlexAuthorizeTrait;\n\n    /** @var string */\n    protected $type;\n    /** @var string */\n    protected $blueprint_file;\n    /** @var Blueprint[] */\n    protected $blueprints;\n    /**\n     * @var FlexIndexInterface[]\n     * @phpstan-var FlexIndexInterface<FlexObjectInterface>[]\n     */\n    protected $indexes = [];\n    /**\n     * @var FlexCollectionInterface|null\n     * @phpstan-var FlexCollectionInterface<FlexObjectInterface>|null\n     */\n    protected $collection;\n    /** @var bool */\n    protected $enabled;\n    /** @var array */\n    protected $defaults;\n    /** @var Config */\n    protected $config;\n    /** @var FlexStorageInterface */\n    protected $storage;\n    /** @var CacheInterface[] */\n    protected $cache;\n    /** @var FlexObjectInterface[] */\n    protected $objects;\n    /** @var string */\n    protected $objectClassName;\n    /** @var string */\n    protected $collectionClassName;\n    /** @var string */\n    protected $indexClassName;\n\n    /** @var string|null */\n    private $_authorize;\n\n    /**\n     * FlexDirectory constructor.\n     * @param string $type\n     * @param string $blueprint_file\n     * @param array $defaults\n     */\n    public function __construct(string $type, string $blueprint_file, array $defaults = [])\n    {\n        $this->type = $type;\n        $this->blueprints = [];\n        $this->blueprint_file = $blueprint_file;\n        $this->defaults = $defaults;\n        $this->enabled = !empty($defaults['enabled']);\n        $this->objects = [];\n    }\n\n    /**\n     * @return bool\n     */\n    public function isListed(): bool\n    {\n        $grav = Grav::instance();\n\n        /** @var Flex $flex */\n        $flex = $grav['flex'];\n        $directory = $flex->getDirectory($this->type);\n\n        return null !== $directory;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isEnabled(): bool\n    {\n        return $this->enabled;\n    }\n\n    /**\n     * @return string\n     */\n    public function getFlexType(): string\n    {\n        return $this->type;\n    }\n\n    /**\n     * @return string\n     */\n    public function getTitle(): string\n    {\n        return $this->getBlueprintInternal()->get('title', ucfirst($this->getFlexType()));\n    }\n\n    /**\n     * @return string\n     */\n    public function getDescription(): string\n    {\n        return $this->getBlueprintInternal()->get('description', '');\n    }\n\n    /**\n     * @param string|null $name\n     * @param mixed $default\n     * @return mixed\n     */\n    public function getConfig(string $name = null, $default = null)\n    {\n        if (null === $this->config) {\n            $config = $this->getBlueprintInternal()->get('config', []);\n            $config = is_array($config) ? array_replace_recursive($config, $this->defaults, $this->getDirectoryConfig($config['admin']['views']['configure']['form'] ?? $config['admin']['configure']['form'] ?? null)) : null;\n            if (!is_array($config)) {\n                throw new RuntimeException('Bad configuration');\n            }\n\n            $this->config = new Config($config);\n        }\n\n        return null === $name ? $this->config : $this->config->get($name, $default);\n    }\n\n    /**\n     * @param string|string[]|null $properties\n     * @return array\n     */\n    public function getSearchProperties($properties = null): array\n    {\n        if (null !== $properties) {\n            return (array)$properties;\n        }\n\n        $properties = $this->getConfig('data.search.fields');\n        if (!$properties) {\n            $fields = $this->getConfig('admin.views.list.fields') ?? $this->getConfig('admin.list.fields', []);\n            foreach ($fields as $property => $value) {\n                if (!empty($value['link'])) {\n                    $properties[] = $property;\n                }\n            }\n        }\n\n        return $properties;\n    }\n\n    /**\n     * @param array|null $options\n     * @return array\n     */\n    public function getSearchOptions(array $options = null): array\n    {\n        if (empty($options['merge'])) {\n            return $options ?? (array)$this->getConfig('data.search.options');\n        }\n\n        unset($options['merge']);\n\n        return $options + (array)$this->getConfig('data.search.options');\n    }\n\n    /**\n     * @param string|null $name\n     * @param array $options\n     * @return FlexFormInterface\n     * @internal\n     */\n    public function getDirectoryForm(string $name = null, array $options = [])\n    {\n        $name = $name ?: $this->getConfig('admin.views.configure.form', '') ?: $this->getConfig('admin.configure.form', '');\n\n        return new FlexDirectoryForm($name ?? '', $this, $options);\n    }\n\n    /**\n     * @return Blueprint\n     * @internal\n     */\n    public function getDirectoryBlueprint()\n    {\n        $name = 'configure';\n\n        $type = $this->getBlueprint();\n        $overrides = $type->get(\"blueprints/{$name}\");\n\n        $path = \"blueprints://flex/shared/{$name}.yaml\";\n        $blueprint = new Blueprint($path);\n        $blueprint->load();\n        if (isset($overrides['fields'])) {\n            $blueprint->embed('form/fields/tabs/fields', $overrides['fields']);\n        }\n        $blueprint->init();\n\n        return $blueprint;\n    }\n\n    /**\n     * @param string $name\n     * @param array $data\n     * @return void\n     * @throws Exception\n     * @internal\n     */\n    public function saveDirectoryConfig(string $name, array $data)\n    {\n        $grav = Grav::instance();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        $filename = $this->getDirectoryConfigUri($name);\n        if (file_exists($filename)) {\n            $filename = $locator->findResource($filename, true);\n        } else {\n            $filesystem = Filesystem::getInstance();\n            $dirname = $filesystem->dirname($filename);\n            $basename = $filesystem->basename($filename);\n            $dirname = $locator->findResource($dirname, true) ?: $locator->findResource($dirname, true, true);\n            $filename = \"{$dirname}/{$basename}\";\n        }\n\n        $grav->fireEvent('onFlexDirectoryConfigBeforeSave', new Event([\n            'directory' => $this,\n            'name' => $name,\n            'data' => &$data,\n        ]));\n\n        $file = YamlFile::instance($filename);\n        if (!empty($data)) {\n            $file->save($data);\n        } else {\n            $file->delete();\n        }\n    }\n\n    /**\n     * @param string $name\n     * @return array\n     * @internal\n     */\n    public function loadDirectoryConfig(string $name): array\n    {\n        $grav = Grav::instance();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n        $uri = $this->getDirectoryConfigUri($name);\n\n        // If configuration is found in main configuration, use it.\n        if (str_starts_with($uri, 'config://')) {\n            $path = str_replace('/', '.', substr($uri, 9, -5));\n\n            return (array)$grav['config']->get($path);\n        }\n\n        // Load the configuration file.\n        $filename = $locator->findResource($uri, true);\n        if ($filename === false) {\n            return [];\n        }\n\n        $file = YamlFile::instance($filename);\n\n        return $file->content();\n    }\n\n    /**\n     * @param string|null $name\n     * @return string\n     */\n    public function getDirectoryConfigUri(string $name = null): string\n    {\n        $name = $name ?: $this->getFlexType();\n        $blueprint = $this->getBlueprint();\n\n        return $blueprint->get('blueprints/views/configure/file') ?? $blueprint->get('blueprints/configure/file') ?? \"config://flex/{$name}.yaml\";\n    }\n\n    /**\n     * @param string|null $name\n     * @return array\n     */\n    protected function getDirectoryConfig(string $name = null): array\n    {\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n        $name = $name ?: $this->getFlexType();\n\n        return $config->get(\"flex.{$name}\", []);\n    }\n\n    /**\n     * Returns a new uninitialized instance of blueprint.\n     *\n     * Always use $object->getBlueprint() or $object->getForm()->getBlueprint() instead.\n     *\n     * @param string $type\n     * @param string $context\n     * @return Blueprint\n     */\n    public function getBlueprint(string $type = '', string $context = '')\n    {\n        return clone $this->getBlueprintInternal($type, $context);\n    }\n\n    /**\n     * @param string $view\n     * @return string\n     */\n    public function getBlueprintFile(string $view = ''): string\n    {\n        $file = $this->blueprint_file;\n        if ($view !== '') {\n            $file = preg_replace('/\\.yaml/', \"/{$view}.yaml\", $file);\n        }\n\n        return (string)$file;\n    }\n\n    /**\n     * Get collection. In the site this will be filtered by the default filters (published etc).\n     *\n     * Use $directory->getIndex() if you want unfiltered collection.\n     *\n     * @param array|null $keys  Array of keys.\n     * @param string|null $keyField  Field to be used as the key.\n     * @return FlexCollectionInterface\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>\n     */\n    public function getCollection(array $keys = null, string $keyField = null): FlexCollectionInterface\n    {\n        // Get all selected entries.\n        $index = $this->getIndex($keys, $keyField);\n\n        if (!Utils::isAdminPlugin()) {\n            // If not in admin, filter the list by using default filters.\n            $filters = (array)$this->getConfig('site.filter', []);\n\n            foreach ($filters as $filter) {\n                $index = $index->{$filter}();\n            }\n        }\n\n        return $index;\n    }\n\n    /**\n     * Get the full collection of all stored objects.\n     *\n     * Use $directory->getCollection() if you want a filtered collection.\n     *\n     * @param array|null $keys  Array of keys.\n     * @param string|null $keyField  Field to be used as the key.\n     * @return FlexIndexInterface\n     * @phpstan-return FlexIndexInterface<FlexObjectInterface>\n     */\n    public function getIndex(array $keys = null, string $keyField = null): FlexIndexInterface\n    {\n        $keyField = $keyField ?? '';\n        $index = $this->indexes[$keyField] ?? $this->loadIndex($keyField);\n        $index = clone $index;\n\n        if (null !== $keys) {\n            /** @var FlexIndexInterface<FlexObjectInterface> $index */\n            $index = $index->select($keys);\n        }\n\n        return $index->getIndex();\n    }\n\n    /**\n     * Returns an object if it exists. If no arguments are passed (or both of them are null), method creates a new empty object.\n     *\n     * Note: It is not safe to use the object without checking if the user can access it.\n     *\n     * @param string|null $key\n     * @param string|null $keyField  Field to be used as the key.\n     * @return FlexObjectInterface|null\n     */\n    public function getObject($key = null, string $keyField = null): ?FlexObjectInterface\n    {\n        if (null === $key) {\n            return $this->createObject([], '');\n        }\n\n        $keyField = $keyField ?? '';\n        $index = $this->indexes[$keyField] ?? $this->loadIndex($keyField);\n\n        return $index->get($key);\n    }\n\n    /**\n     * @param string|null $namespace\n     * @return CacheInterface\n     */\n    public function getCache(string $namespace = null)\n    {\n        $namespace = $namespace ?: 'index';\n        $cache = $this->cache[$namespace] ?? null;\n\n        if (null === $cache) {\n            try {\n                $grav = Grav::instance();\n\n                /** @var Cache $gravCache */\n                $gravCache = $grav['cache'];\n                $config = $this->getConfig('object.cache.' . $namespace);\n                if (empty($config['enabled'])) {\n                    $cache = new MemoryCache('flex-objects-' . $this->getFlexType());\n                } else {\n                    $lifetime = $config['lifetime'] ?? 60;\n\n                    $key = $gravCache->getKey();\n                    if (Utils::isAdminPlugin()) {\n                        $key = substr($key, 0, -1);\n                    }\n                    $cache = new DoctrineCache($gravCache->getCacheDriver(), 'flex-objects-' . $this->getFlexType() . $key, $lifetime);\n                }\n            } catch (Exception $e) {\n                /** @var Debugger $debugger */\n                $debugger = Grav::instance()['debugger'];\n                $debugger->addException($e);\n\n                $cache = new MemoryCache('flex-objects-' . $this->getFlexType());\n            }\n\n            // Disable cache key validation.\n            $cache->setValidation(false);\n            $this->cache[$namespace] = $cache;\n        }\n\n        return $cache;\n    }\n\n    /**\n     * @return $this\n     */\n    public function clearCache()\n    {\n        $grav = Grav::instance();\n\n        /** @var Debugger $debugger */\n        $debugger = $grav['debugger'];\n        $debugger->addMessage(sprintf('Flex: Clearing all %s cache', $this->type), 'debug');\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n        $locator->clearCache();\n\n        $this->getCache('index')->clear();\n        $this->getCache('object')->clear();\n        $this->getCache('render')->clear();\n\n        $this->indexes = [];\n        $this->objects = [];\n\n        return $this;\n    }\n\n    /**\n     * @param string|null $key\n     * @return string|null\n     */\n    public function getStorageFolder(string $key = null): ?string\n    {\n        return $this->getStorage()->getStoragePath($key);\n    }\n\n    /**\n     * @param string|null $key\n     * @return string|null\n     */\n    public function getMediaFolder(string $key = null): ?string\n    {\n        return $this->getStorage()->getMediaPath($key);\n    }\n\n    /**\n     * @return FlexStorageInterface\n     */\n    public function getStorage(): FlexStorageInterface\n    {\n        if (null === $this->storage) {\n            $this->storage = $this->createStorage();\n        }\n\n        return $this->storage;\n    }\n\n    /**\n     * @param array $data\n     * @param string $key\n     * @param bool $validate\n     * @return FlexObjectInterface\n     */\n    public function createObject(array $data, string $key = '', bool $validate = false): FlexObjectInterface\n    {\n        /** @phpstan-var class-string $className */\n        $className = $this->objectClassName ?: $this->getObjectClass();\n        if (!is_a($className, FlexObjectInterface::class, true)) {\n            throw new \\RuntimeException('Bad object class: ' . $className);\n        }\n\n        return new $className($data, $key, $this, $validate);\n    }\n\n    /**\n     * @param array $entries\n     * @param string|null $keyField\n     * @return FlexCollectionInterface\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>\n     */\n    public function createCollection(array $entries, string $keyField = null): FlexCollectionInterface\n    {\n        /** phpstan-var class-string $className */\n        $className = $this->collectionClassName ?: $this->getCollectionClass();\n        if (!is_a($className, FlexCollectionInterface::class, true)) {\n            throw new \\RuntimeException('Bad collection class: ' . $className);\n        }\n\n        return $className::createFromArray($entries, $this, $keyField);\n    }\n\n    /**\n     * @param array $entries\n     * @param string|null $keyField\n     * @return FlexIndexInterface\n     * @phpstan-return FlexIndexInterface<FlexObjectInterface>\n     */\n    public function createIndex(array $entries, string $keyField = null): FlexIndexInterface\n    {\n        /** @phpstan-var class-string $className */\n        $className = $this->indexClassName ?: $this->getIndexClass();\n        if (!is_a($className, FlexIndexInterface::class, true)) {\n            throw new \\RuntimeException('Bad index class: ' . $className);\n        }\n\n        return $className::createFromArray($entries, $this, $keyField);\n    }\n\n    /**\n     * @return string\n     */\n    public function getObjectClass(): string\n    {\n        if (!$this->objectClassName) {\n            $this->objectClassName = $this->getConfig('data.object', GenericObject::class);\n        }\n\n        return $this->objectClassName;\n    }\n\n    /**\n     * @return string\n     */\n    public function getCollectionClass(): string\n    {\n        if (!$this->collectionClassName) {\n            $this->collectionClassName = $this->getConfig('data.collection', GenericCollection::class);\n        }\n\n        return $this->collectionClassName;\n    }\n\n\n    /**\n     * @return string\n     */\n    public function getIndexClass(): string\n    {\n        if (!$this->indexClassName) {\n            $this->indexClassName = $this->getConfig('data.index', GenericIndex::class);\n        }\n\n        return $this->indexClassName;\n    }\n\n    /**\n     * @param array $entries\n     * @param string|null $keyField\n     * @return FlexCollectionInterface\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>\n     */\n    public function loadCollection(array $entries, string $keyField = null): FlexCollectionInterface\n    {\n        return $this->createCollection($this->loadObjects($entries), $keyField);\n    }\n\n    /**\n     * @param array $entries\n     * @return FlexObjectInterface[]\n     * @internal\n     */\n    public function loadObjects(array $entries): array\n    {\n        /** @var Debugger $debugger */\n        $debugger = Grav::instance()['debugger'];\n\n        $keys = [];\n        $rows = [];\n        $fetch = [];\n\n        // Build lookup arrays with storage keys for the objects.\n        foreach ($entries as $key => $value) {\n            $k = $value['storage_key'] ?? '';\n            if ($k === '') {\n                continue;\n            }\n            $v = $this->objects[$k] ?? null;\n            $keys[$k] = $key;\n            $rows[$k] = $v;\n            if (!$v) {\n                $fetch[] = $k;\n            }\n        }\n\n        // Attempt to fetch missing rows from the cache.\n        if ($fetch) {\n             $rows = (array)array_replace($rows, $this->loadCachedObjects($fetch));\n        }\n\n        // Read missing rows from the storage.\n        $updated = [];\n        $storage = $this->getStorage();\n        $rows = $storage->readRows($rows, $updated);\n\n        // Create objects from the rows.\n        $isListed = $this->isListed();\n        $list = [];\n        foreach ($rows as $storageKey => $row) {\n            $usedKey = $keys[$storageKey];\n\n            if ($row instanceof FlexObjectInterface) {\n                $object = $row;\n            } else {\n                if ($row === null) {\n                    $debugger->addMessage(sprintf('Flex: Object %s was not found from %s storage', $storageKey, $this->type), 'debug');\n                    continue;\n                }\n\n                if (isset($row['__ERROR'])) {\n                    $message = sprintf('Flex: Object %s is broken in %s storage: %s', $storageKey, $this->type, $row['__ERROR']);\n                    $debugger->addException(new RuntimeException($message));\n                    $debugger->addMessage($message, 'error');\n                    continue;\n                }\n\n                if (!isset($row['__META'])) {\n                    $row['__META'] = [\n                        'storage_key' => $storageKey,\n                        'storage_timestamp' => $entries[$usedKey]['storage_timestamp'] ?? 0,\n                    ];\n                }\n\n                $key = $row['__META']['key'] ?? $entries[$usedKey]['key'] ?? $usedKey;\n                $object = $this->createObject($row, $key, false);\n                $this->objects[$storageKey] = $object;\n                if ($isListed) {\n                    // If unserialize works for the object, serialize the object to speed up the loading.\n                    $updated[$storageKey] = $object;\n                }\n            }\n\n            $list[$usedKey] = $object;\n        }\n\n        // Store updated rows to the cache.\n        if ($updated) {\n            $cache = $this->getCache('object');\n            if (!$cache instanceof MemoryCache) {\n                ///** @var Debugger $debugger */\n                //$debugger = Grav::instance()['debugger'];\n                //$debugger->addMessage(sprintf('Flex: Caching %d %s', \\count($entries), $this->type), 'debug');\n            }\n            try {\n                $cache->setMultiple($updated);\n            } catch (InvalidArgumentException $e) {\n                $debugger->addException($e);\n                // TODO: log about the issue.\n            }\n        }\n\n        if ($fetch) {\n            $debugger->stopTimer('flex-objects');\n        }\n\n        return $list;\n    }\n\n    protected function loadCachedObjects(array $fetch): array\n    {\n        if (!$fetch) {\n            return [];\n        }\n\n        /** @var Debugger $debugger */\n        $debugger = Grav::instance()['debugger'];\n\n        $cache = $this->getCache('object');\n\n        // Attempt to fetch missing rows from the cache.\n        $fetched = [];\n        try {\n            $loading = count($fetch);\n\n            $debugger->startTimer('flex-objects', sprintf('Flex: Loading %d %s', $loading, $this->type));\n\n            $fetched = (array)$cache->getMultiple($fetch);\n            if ($fetched) {\n                $index = $this->loadIndex('storage_key');\n\n                // Make sure cached objects are up to date: compare against index checksum/timestamp.\n                /**\n                 * @var string $key\n                 * @var mixed $value\n                 */\n                foreach ($fetched as $key => $value) {\n                    if ($value instanceof FlexObjectInterface) {\n                        $objectMeta = $value->getMetaData();\n                    } else {\n                        $objectMeta = $value['__META'] ?? [];\n                    }\n                    $indexMeta = $index->getMetaData($key);\n\n                    $indexChecksum = $indexMeta['checksum'] ?? $indexMeta['storage_timestamp'] ?? null;\n                    $objectChecksum = $objectMeta['checksum'] ?? $objectMeta['storage_timestamp'] ?? null;\n                    if ($indexChecksum !== $objectChecksum) {\n                        unset($fetched[$key]);\n                    }\n                }\n            }\n\n        } catch (InvalidArgumentException $e) {\n            $debugger->addException($e);\n        }\n\n        return $fetched;\n    }\n\n    /**\n     * @return void\n     */\n    public function reloadIndex(): void\n    {\n        $this->getCache('index')->clear();\n        $this->getIndex()::loadEntriesFromStorage($this->getStorage());\n\n        $this->indexes = [];\n        $this->objects = [];\n    }\n\n    /**\n     * @param string $scope\n     * @param string $action\n     * @return string\n     */\n    public function getAuthorizeRule(string $scope, string $action): string\n    {\n        if (!$this->_authorize) {\n            $config = $this->getConfig('admin.permissions');\n            if ($config) {\n                $this->_authorize = array_key_first($config) . '.%2$s';\n            } else {\n                $this->_authorize = '%1$s.flex-object.%2$s';\n            }\n        }\n\n        return sprintf($this->_authorize, $scope, $action);\n    }\n\n    /**\n     * @param string $type_view\n     * @param string $context\n     * @return Blueprint\n     */\n    protected function getBlueprintInternal(string $type_view = '', string $context = '')\n    {\n        if (!isset($this->blueprints[$type_view])) {\n            if (!file_exists($this->blueprint_file)) {\n                throw new RuntimeException(sprintf('Flex: Blueprint file for %s is missing', $this->type));\n            }\n\n            $parts = explode('.', rtrim($type_view, '.'), 2);\n            $type = array_shift($parts);\n            $view = array_shift($parts) ?: '';\n\n            $blueprint = new Blueprint($this->getBlueprintFile($view));\n            $blueprint->addDynamicHandler('data', function (array &$field, $property, array &$call) {\n                $this->dynamicDataField($field, $property, $call);\n            });\n            $blueprint->addDynamicHandler('flex', function (array &$field, $property, array &$call) {\n                $this->dynamicFlexField($field, $property, $call);\n            });\n            $blueprint->addDynamicHandler('authorize', function (array &$field, $property, array &$call) {\n                $this->dynamicAuthorizeField($field, $property, $call);\n            });\n\n            if ($context) {\n                $blueprint->setContext($context);\n            }\n\n            $blueprint->load($type ?: null);\n            if ($blueprint->get('type') === 'flex-objects' && isset(Grav::instance()['admin'])) {\n                $blueprintBase = (new Blueprint('plugin://flex-objects/blueprints/flex-objects.yaml'))->load();\n                $blueprint->extend($blueprintBase, true);\n            }\n\n            $this->blueprints[$type_view] = $blueprint;\n        }\n\n        return $this->blueprints[$type_view];\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param array $call\n     * @return void\n     */\n    protected function dynamicDataField(array &$field, $property, array $call)\n    {\n        $params = $call['params'];\n        if (is_array($params)) {\n            $function = array_shift($params);\n        } else {\n            $function = $params;\n            $params = [];\n        }\n\n        $object = $call['object'];\n        if ($function === '\\Grav\\Common\\Page\\Pages::pageTypes') {\n            $params = [$object instanceof PageInterface && $object->isModule() ? 'modular' : 'standard'];\n        }\n\n        $data = null;\n        if (is_callable($function)) {\n            $data = call_user_func_array($function, $params);\n        }\n\n        // If function returns a value,\n        if (null !== $data) {\n            if (is_array($data) && isset($field[$property]) && is_array($field[$property])) {\n                // Combine field and @data-field together.\n                $field[$property] += $data;\n            } else {\n                // Or create/replace field with @data-field.\n                $field[$property] = $data;\n            }\n        }\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param array $call\n     * @return void\n     */\n    protected function dynamicFlexField(array &$field, $property, array $call): void\n    {\n        $params = (array)$call['params'];\n        $object = $call['object'] ?? null;\n        $method = array_shift($params);\n        $not = false;\n        if (str_starts_with($method, '!')) {\n            $method = substr($method, 1);\n            $not = true;\n        } elseif (str_starts_with($method, 'not ')) {\n            $method = substr($method, 4);\n            $not = true;\n        }\n        $method = trim($method);\n\n        if ($object && method_exists($object, $method)) {\n            $value = $object->{$method}(...$params);\n            if (is_array($value) && isset($field[$property]) && is_array($field[$property])) {\n                $value = $this->mergeArrays($field[$property], $value);\n            }\n            $value = $not ? !$value : $value;\n\n            if ($property === 'ignore' && $value) {\n                Blueprint::addPropertyRecursive($field, 'validate', ['ignore' => true]);\n            } else {\n                $field[$property] = $value;\n            }\n        }\n    }\n\n    /**\n     * @param array $field\n     * @param string $property\n     * @param array $call\n     * @return void\n     */\n    protected function dynamicAuthorizeField(array &$field, $property, array $call): void\n    {\n        $params = (array)$call['params'];\n        $object = $call['object'] ?? null;\n        $permission = array_shift($params);\n        $not = false;\n        if (str_starts_with($permission, '!')) {\n            $permission = substr($permission, 1);\n            $not = true;\n        } elseif (str_starts_with($permission, 'not ')) {\n            $permission = substr($permission, 4);\n            $not = true;\n        }\n        $permission = trim($permission);\n\n        if ($object) {\n            $value = $object->isAuthorized($permission) ?? false;\n\n            $field[$property] = $not ? !$value : $value;\n        }\n    }\n\n    /**\n     * @param array $array1\n     * @param array $array2\n     * @return array\n     */\n    protected function mergeArrays(array $array1, array $array2): array\n    {\n        foreach ($array2 as $key => $value) {\n            if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) {\n                $array1[$key] = $this->mergeArrays($array1[$key], $value);\n            } else {\n                $array1[$key] = $value;\n            }\n        }\n\n        return $array1;\n    }\n\n    /**\n     * @return FlexStorageInterface\n     */\n    protected function createStorage(): FlexStorageInterface\n    {\n        $this->collection = $this->createCollection([]);\n\n        $storage = $this->getConfig('data.storage');\n\n        if (!is_array($storage)) {\n            $storage = ['options' => ['folder' => $storage]];\n        }\n\n        $className = $storage['class'] ?? SimpleStorage::class;\n        $options = $storage['options'] ?? [];\n\n        if (!is_a($className, FlexStorageInterface::class, true)) {\n            throw new \\RuntimeException('Bad storage class: ' . $className);\n        }\n\n        return new $className($options);\n    }\n\n    /**\n     * @param string $keyField\n     * @return FlexIndexInterface\n     * @phpstan-return FlexIndexInterface<FlexObjectInterface>\n     */\n    protected function loadIndex(string $keyField): FlexIndexInterface\n    {\n        static $i = 0;\n\n        $index = $this->indexes[$keyField] ?? null;\n        if (null !== $index) {\n            return $index;\n        }\n\n        $index = $this->indexes['storage_key'] ?? null;\n        if (null === $index) {\n            $i++;\n            $j = $i;\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->startTimer('flex-keys-' . $this->type . $j, \"Flex: Loading {$this->type} index\");\n\n            $storage = $this->getStorage();\n            $cache = $this->getCache('index');\n\n            try {\n                $keys = $cache->get('__keys');\n            } catch (InvalidArgumentException $e) {\n                $debugger->addException($e);\n                $keys = null;\n            }\n\n            if (!is_array($keys)) {\n                /** @phpstan-var class-string $className */\n                $className = $this->getIndexClass();\n                $keys = $className::loadEntriesFromStorage($storage);\n                if (!$cache instanceof MemoryCache) {\n                    $debugger->addMessage(\n                        sprintf('Flex: Caching %s index of %d objects', $this->type, count($keys)),\n                        'debug'\n                    );\n                }\n                try {\n                    $cache->set('__keys', $keys);\n                } catch (InvalidArgumentException $e) {\n                    $debugger->addException($e);\n                    // TODO: log about the issue.\n                }\n            }\n\n            $ordering = $this->getConfig('data.ordering', []);\n\n            // We need to do this in two steps as orderBy() calls loadIndex() again and we do not want infinite loop.\n            $this->indexes['storage_key'] = $index = $this->createIndex($keys, 'storage_key');\n            if ($ordering) {\n                /** @var FlexCollectionInterface<FlexObjectInterface> $collection */\n                $collection = $this->indexes['storage_key']->orderBy($ordering);\n                $this->indexes['storage_key'] = $index = $collection->getIndex();\n            }\n\n            $debugger->stopTimer('flex-keys-' . $this->type . $j);\n        }\n\n        if ($keyField !== 'storage_key') {\n            $this->indexes[$keyField] = $index = $index->withKeyField($keyField ?: null);\n        }\n\n        return $index;\n    }\n\n    /**\n     * @param string $action\n     * @return string\n     */\n    protected function getAuthorizeAction(string $action): string\n    {\n        // Handle special action save, which can mean either update or create.\n        if ($action === 'save') {\n            $action = 'create';\n        }\n\n        return $action;\n    }\n    /**\n     * @return UserInterface|null\n     */\n    protected function getActiveUser(): ?UserInterface\n    {\n        /** @var UserInterface|null $user */\n        $user = Grav::instance()['user'] ?? null;\n\n        return $user;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getAuthorizeScope(): string\n    {\n        return isset(Grav::instance()['admin']) ? 'admin' : 'site';\n    }\n\n    // DEPRECATED METHODS\n\n    /**\n     * @return string\n     * @deprecated 1.6 Use ->getFlexType() method instead.\n     */\n    public function getType(): string\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED);\n\n        return $this->type;\n    }\n\n    /**\n     * @param array $data\n     * @param string|null $key\n     * @return FlexObjectInterface\n     * @deprecated 1.7 Use $object->update()->save() instead.\n     */\n    public function update(array $data, string $key = null): FlexObjectInterface\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() should not be used anymore: use $object->update()->save() instead.', E_USER_DEPRECATED);\n\n        $object = null !== $key ? $this->getIndex()->get($key): null;\n\n        $storage = $this->getStorage();\n\n        if (null === $object) {\n            $object = $this->createObject($data, $key ?? '', true);\n            $key = $object->getStorageKey();\n\n            if ($key) {\n                $storage->replaceRows([$key => $object->prepareStorage()]);\n            } else {\n                $storage->createRows([$object->prepareStorage()]);\n            }\n        } else {\n            $oldKey = $object->getStorageKey();\n            $object->update($data);\n            $newKey = $object->getStorageKey();\n\n            if ($oldKey !== $newKey) {\n                if (method_exists($object, 'triggerEvent')) {\n                    $object->triggerEvent('move');\n                }\n                $storage->renameRow($oldKey, $newKey);\n                // TODO: media support.\n            }\n\n            $object->save();\n        }\n\n        try {\n            $this->clearCache();\n        } catch (InvalidArgumentException $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            // Caching failed, but we can ignore that for now.\n        }\n\n        return $object;\n    }\n\n    /**\n     * @param string $key\n     * @return FlexObjectInterface|null\n     * @deprecated 1.7 Use $object->delete() instead.\n     */\n    public function remove(string $key): ?FlexObjectInterface\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() should not be used anymore: use $object->delete() instead.', E_USER_DEPRECATED);\n\n        $object = $this->getIndex()->get($key);\n        if (!$object) {\n            return null;\n        }\n\n        $object->delete();\n\n        return $object;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/FlexDirectoryForm.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex;\n\nuse ArrayAccess;\nuse Exception;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Twig\\Twig;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexDirectoryFormInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexFormInterface;\nuse Grav\\Framework\\Form\\Interfaces\\FormFlashInterface;\nuse Grav\\Framework\\Form\\Traits\\FormTrait;\nuse Grav\\Framework\\Route\\Route;\nuse JsonSerializable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\NestedArrayAccessWithGetters;\nuse RuntimeException;\nuse Twig\\Error\\LoaderError;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Template;\nuse Twig\\TemplateWrapper;\n\n/**\n * Class FlexForm\n * @package Grav\\Framework\\Flex\n */\nclass FlexDirectoryForm implements FlexDirectoryFormInterface, JsonSerializable\n{\n    use NestedArrayAccessWithGetters {\n        NestedArrayAccessWithGetters::get as private traitGet;\n        NestedArrayAccessWithGetters::set as private traitSet;\n    }\n    use FormTrait {\n        FormTrait::doSerialize as doTraitSerialize;\n        FormTrait::doUnserialize as doTraitUnserialize;\n    }\n\n    /** @var array|null */\n    private $form;\n    /** @var FlexDirectory */\n    private $directory;\n    /** @var string */\n    private $flexName;\n\n    /**\n     * @param array $options    Options to initialize the form instance:\n     *                          (string) name: Form name, allows you to use custom form.\n     *                          (string) unique_id: Unique id for this form instance.\n     *                          (array) form: Custom form fields.\n     *                          (FlexDirectory) directory: Flex Directory, mandatory.\n     *\n     * @return FlexFormInterface\n     */\n    public static function instance(array $options = []): FlexFormInterface\n    {\n        if (isset($options['directory'])) {\n            $directory = $options['directory'];\n            if (!$directory instanceof FlexDirectory) {\n                throw new RuntimeException(__METHOD__ . \"(): 'directory' should be instance of FlexDirectory\", 400);\n            }\n            unset($options['directory']);\n        } else {\n            throw new RuntimeException(__METHOD__ . \"(): You need to pass option 'directory'\", 400);\n        }\n\n        $name = $options['name'] ?? '';\n\n        return $directory->getDirectoryForm($name, $options);\n    }\n\n    /**\n     * FlexForm constructor.\n     * @param string $name\n     * @param FlexDirectory $directory\n     * @param array|null $options\n     */\n    public function __construct(string $name, FlexDirectory $directory, array $options = null)\n    {\n        $this->name = $name;\n        $this->setDirectory($directory);\n        $this->setName($directory->getFlexType(), $name);\n        $this->setId($this->getName());\n\n        $uniqueId = $options['unique_id'] ?? null;\n        if (!$uniqueId) {\n            $uniqueId = md5($directory->getFlexType() . '-directory-' . $this->name);\n        }\n        $this->setUniqueId($uniqueId);\n\n        $this->setFlashLookupFolder($directory->getDirectoryBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]');\n        $this->form = $options['form'] ?? null;\n\n        if (Utils::isPositive($this->form['disabled'] ?? false)) {\n            $this->disable();\n        }\n\n        $this->initialize();\n    }\n\n    /**\n     * @return $this\n     */\n    public function initialize()\n    {\n        $this->messages = [];\n        $this->submitted = false;\n        $this->data =  new Data($this->directory->loadDirectoryConfig($this->name), $this->getBlueprint());\n        $this->files = [];\n        $this->unsetFlash();\n\n        /** @var FlexFormFlash $flash */\n        $flash = $this->getFlash();\n        if ($flash->exists()) {\n            $data = $flash->getData();\n            $includeOriginal = (bool)($this->getBlueprint()->form()['images']['original'] ?? null);\n\n            $directory = $flash->getDirectory();\n            if (null === $directory) {\n                throw new RuntimeException('Flash has no directory');\n            }\n            $this->directory = $directory;\n            $this->data = $data ? new Data($data, $this->getBlueprint()) : null;\n            $this->files = $flash->getFilesByFields($includeOriginal);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $uniqueId\n     * @return void\n     */\n    public function setUniqueId(string $uniqueId): void\n    {\n        if ($uniqueId !== '') {\n            $this->uniqueid = $uniqueId;\n        }\n    }\n\n    /**\n     * @param string $name\n     * @param mixed $default\n     * @param string|null $separator\n     * @return mixed\n     */\n    public function get($name, $default = null, $separator = null)\n    {\n        switch (strtolower($name)) {\n            case 'id':\n            case 'uniqueid':\n            case 'name':\n            case 'noncename':\n            case 'nonceaction':\n            case 'action':\n            case 'data':\n            case 'files':\n            case 'errors';\n            case 'fields':\n            case 'blueprint':\n            case 'page':\n                $method = 'get' . $name;\n                return $this->{$method}();\n        }\n\n        return $this->traitGet($name, $default, $separator);\n    }\n\n    /**\n     * @param string $name\n     * @param mixed $value\n     * @param string|null $separator\n     * @return $this\n     */\n    public function set($name, $value, $separator = null)\n    {\n        switch (strtolower($name)) {\n            case 'id':\n            case 'uniqueid':\n                $method = 'set' . $name;\n                return $this->{$method}();\n        }\n\n        return $this->traitSet($name, $value, $separator);\n    }\n\n    /**\n     * @return string\n     */\n    public function getName(): string\n    {\n        return $this->flexName;\n    }\n\n    protected function setName(string $type, string $name): void\n    {\n        // Make sure that both type and name do not have dash (convert dashes to underscores).\n        $type = str_replace('-', '_', $type);\n        $name = str_replace('-', '_', $name);\n        $this->flexName = $name ? \"flex_conf-{$type}-{$name}\" : \"flex_conf-{$type}\";\n    }\n\n    /**\n     * @return Data|object\n     */\n    public function getData()\n    {\n        if (null === $this->data) {\n            $this->data = new Data([], $this->getBlueprint());\n        }\n\n        return $this->data;\n    }\n\n    /**\n     * Get a value from the form.\n     *\n     * Note: Used in form fields.\n     *\n     * @param string $name\n     * @return mixed\n     */\n    public function getValue(string $name)\n    {\n        // Attempt to get value from the form data.\n        $value = $this->data ? $this->data[$name] : null;\n\n        // Return the form data or fall back to the object property.\n        return $value ?? null;\n    }\n\n    /**\n     * @param string $name\n     * @return array|mixed|null\n     */\n    public function getDefaultValue(string $name)\n    {\n        return $this->getBlueprint()->getDefaultValue($name);\n    }\n\n    /**\n     * @return array\n     */\n    public function getDefaultValues(): array\n    {\n        return $this->getBlueprint()->getDefaults();\n    }\n    /**\n     * @return string\n     */\n    public function getFlexType(): string\n    {\n        return $this->directory->getFlexType();\n    }\n\n    /**\n     * Get form flash object.\n     *\n     * @return FormFlashInterface|FlexFormFlash\n     */\n    public function getFlash()\n    {\n        if (null === $this->flash) {\n            $grav = Grav::instance();\n            $config = [\n                'session_id' => $this->getSessionId(),\n                'unique_id' => $this->getUniqueId(),\n                'form_name' => $this->getName(),\n                'folder' => $this->getFlashFolder(),\n                'id' => $this->getFlashId(),\n                'directory' => $this->getDirectory()\n            ];\n\n            $this->flash = new FlexFormFlash($config);\n            $this->flash\n                ->setUrl($grav['uri']->url)\n                ->setUser($grav['user'] ?? null);\n        }\n\n        return $this->flash;\n    }\n\n    /**\n     * @return FlexDirectory\n     */\n    public function getDirectory(): FlexDirectory\n    {\n        return $this->directory;\n    }\n\n    /**\n     * @return Blueprint\n     */\n    public function getBlueprint(): Blueprint\n    {\n        if (null === $this->blueprint) {\n            try {\n                $blueprint = $this->getDirectory()->getDirectoryBlueprint();\n                if ($this->form) {\n                    // We have field overrides available.\n                    $blueprint->extend(['form' => $this->form], true);\n                    $blueprint->init();\n                }\n            } catch (RuntimeException $e) {\n                if (!isset($this->form['fields'])) {\n                    throw $e;\n                }\n\n                // Blueprint is not defined, but we have custom form fields available.\n                $blueprint = new Blueprint(null, ['form' => $this->form]);\n                $blueprint->load();\n                $blueprint->setScope('directory');\n                $blueprint->init();\n            }\n\n            $this->blueprint = $blueprint;\n        }\n\n        return $this->blueprint;\n    }\n\n    /**\n     * @return Route|null\n     */\n    public function getFileUploadAjaxRoute(): ?Route\n    {\n        return null;\n    }\n\n    /**\n     * @param string|null $field\n     * @param string|null $filename\n     * @return Route|null\n     */\n    public function getFileDeleteAjaxRoute($field = null, $filename = null): ?Route\n    {\n        return null;\n    }\n\n    /**\n     * @param array $params\n     * @param string|null $extension\n     * @return string\n     */\n    public function getMediaTaskRoute(array $params = [], string $extension = null): string\n    {\n        return '';\n    }\n\n    /**\n     * @param string $name\n     * @return mixed|null\n     */\n    #[\\ReturnTypeWillChange]\n    public function __get($name)\n    {\n        $method = \"get{$name}\";\n        if (method_exists($this, $method)) {\n            return $this->{$method}();\n        }\n\n        $form = $this->getBlueprint()->form();\n\n        return $form[$name] ?? null;\n    }\n\n    /**\n     * @param string $name\n     * @param mixed $value\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __set($name, $value)\n    {\n        $method = \"set{$name}\";\n        if (method_exists($this, $method)) {\n            $this->{$method}($value);\n        }\n    }\n\n    /**\n     * @param string $name\n     * @return bool\n     */\n    #[\\ReturnTypeWillChange]\n    public function __isset($name)\n    {\n        $method = \"get{$name}\";\n        if (method_exists($this, $method)) {\n            return true;\n        }\n\n        $form = $this->getBlueprint()->form();\n\n        return isset($form[$name]);\n    }\n\n    /**\n     * @param string $name\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __unset($name)\n    {\n    }\n\n    /**\n     * @return array|bool\n     */\n    protected function getUnserializeAllowedClasses()\n    {\n        return [FlexObject::class];\n    }\n\n    /**\n     * Note: this method clones the object.\n     *\n     * @param FlexDirectory $directory\n     * @return $this\n     */\n    protected function setDirectory(FlexDirectory $directory): self\n    {\n        $this->directory = $directory;\n\n        return $this;\n    }\n\n    /**\n     * @param string $layout\n     * @return Template|TemplateWrapper\n     * @throws LoaderError\n     * @throws SyntaxError\n     */\n    protected function getTemplate($layout)\n    {\n        $grav = Grav::instance();\n\n        /** @var Twig $twig */\n        $twig = $grav['twig'];\n\n        return $twig->twig()->resolveTemplate(\n            [\n                \"flex-objects/layouts/{$this->getFlexType()}/form/{$layout}.html.twig\",\n                \"flex-objects/layouts/_default/form/{$layout}.html.twig\",\n                \"forms/{$layout}/form.html.twig\",\n                'forms/default/form.html.twig'\n            ]\n        );\n    }\n\n    /**\n     * @param array $data\n     * @param array $files\n     * @return void\n     * @throws Exception\n     */\n    protected function doSubmit(array $data, array $files)\n    {\n        $this->directory->saveDirectoryConfig($this->name, $data);\n\n        $this->reset();\n    }\n\n    /**\n     * @return array\n     */\n    protected function doSerialize(): array\n    {\n        return $this->doTraitSerialize() + [\n                'form' => $this->form,\n                'directory' => $this->directory,\n                'flexName' => $this->flexName\n            ];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    protected function doUnserialize(array $data): void\n    {\n        $this->doTraitUnserialize($data);\n\n        $this->form = $data['form'];\n        $this->directory = $data['directory'];\n        $this->flexName = $data['flexName'];\n    }\n\n    /**\n     * Filter validated data.\n     *\n     * @param ArrayAccess|Data|null $data\n     * @phpstan-param ArrayAccess<string,mixed>|Data|null $data\n     */\n    protected function filterData($data = null): void\n    {\n        if ($data instanceof Data) {\n            $data->filter(false, true);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/FlexForm.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex;\n\nuse ArrayAccess;\nuse Exception;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Twig\\Twig;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexFormInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectFormInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Form\\Interfaces\\FormFlashInterface;\nuse Grav\\Framework\\Form\\Traits\\FormTrait;\nuse Grav\\Framework\\Route\\Route;\nuse JsonSerializable;\nuse RocketTheme\\Toolbox\\ArrayTraits\\NestedArrayAccessWithGetters;\nuse RuntimeException;\nuse Twig\\Error\\LoaderError;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Template;\nuse Twig\\TemplateWrapper;\n\n/**\n * Class FlexForm\n * @package Grav\\Framework\\Flex\n */\nclass FlexForm implements FlexObjectFormInterface, JsonSerializable\n{\n    use NestedArrayAccessWithGetters {\n        NestedArrayAccessWithGetters::get as private traitGet;\n        NestedArrayAccessWithGetters::set as private traitSet;\n    }\n    use FormTrait {\n        FormTrait::doSerialize as doTraitSerialize;\n        FormTrait::doUnserialize as doTraitUnserialize;\n    }\n\n    /** @var array */\n    private $items = [];\n\n    /** @var array|null */\n    private $form;\n    /** @var FlexObjectInterface */\n    private $object;\n    /** @var string */\n    private $flexName;\n    /** @var callable|null */\n    private $submitMethod;\n\n    /**\n     * @param array $options    Options to initialize the form instance:\n     *                          (string) name: Form name, allows you to use custom form.\n     *                          (string) unique_id: Unique id for this form instance.\n     *                          (array) form: Custom form fields.\n     *                          (FlexObjectInterface) object: Object instance.\n     *                          (string) key: Object key, used only if object instance isn't given.\n     *                          (FlexDirectory) directory: Flex Directory, mandatory if object isn't given.\n     *\n     * @return FlexFormInterface\n     */\n    public static function instance(array $options = [])\n    {\n        if (isset($options['object'])) {\n            $object = $options['object'];\n            if (!$object instanceof FlexObjectInterface) {\n                throw new RuntimeException(__METHOD__ . \"(): 'object' should be instance of FlexObjectInterface\", 400);\n            }\n        } elseif (isset($options['directory'])) {\n            $directory = $options['directory'];\n            if (!$directory instanceof FlexDirectory) {\n                throw new RuntimeException(__METHOD__ . \"(): 'directory' should be instance of FlexDirectory\", 400);\n            }\n            $key = $options['key'] ?? '';\n            $object = $directory->getObject($key) ?? $directory->createObject([], $key);\n        } else {\n            throw new RuntimeException(__METHOD__ . \"(): You need to pass option 'directory' or 'object'\", 400);\n        }\n\n        $name = $options['name'] ?? '';\n\n        // There is no reason to pass object and directory.\n        unset($options['object'], $options['directory']);\n\n        return $object->getForm($name, $options);\n    }\n\n    /**\n     * FlexForm constructor.\n     * @param string $name\n     * @param FlexObjectInterface $object\n     * @param array|null $options\n     */\n    public function __construct(string $name, FlexObjectInterface $object, array $options = null)\n    {\n        $this->name = $name;\n        $this->setObject($object);\n\n        if (isset($options['form']['name'])) {\n            // Use custom form name.\n            $this->flexName = $options['form']['name'];\n        } else {\n            // Use standard form name.\n            $this->setName($object->getFlexType(), $name);\n        }\n        $this->setId($this->getName());\n\n        $uniqueId = $options['unique_id'] ?? null;\n        if (!$uniqueId) {\n            if ($object->exists()) {\n                $uniqueId = $object->getStorageKey();\n            } elseif ($object->hasKey()) {\n                $uniqueId = \"{$object->getKey()}:new\";\n            } else {\n                $uniqueId = \"{$object->getFlexType()}:new\";\n            }\n            $uniqueId = md5($uniqueId);\n        }\n        $this->setUniqueId($uniqueId);\n\n        $directory = $object->getFlexDirectory();\n        $this->setFlashLookupFolder($options['flash_folder'] ?? $directory->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]');\n        $this->form = $options['form'] ?? null;\n\n        if (Utils::isPositive($this->items['disabled'] ?? $this->form['disabled'] ?? false)) {\n            $this->disable();\n        }\n\n        if (!empty($options['reset'])) {\n            $this->getFlash()->delete();\n        }\n\n        $this->initialize();\n    }\n\n    /**\n     * @return $this\n     */\n    public function initialize()\n    {\n        $this->messages = [];\n        $this->submitted = false;\n        $this->data = null;\n        $this->files = [];\n        $this->unsetFlash();\n\n        /** @var FlexFormFlash $flash */\n        $flash = $this->getFlash();\n        if ($flash->exists()) {\n            $data = $flash->getData();\n            if (null !== $data) {\n                $data = new Data($data, $this->getBlueprint());\n                $data->setKeepEmptyValues(true);\n                $data->setMissingValuesAsNull(true);\n            }\n\n            $object = $flash->getObject();\n            if (null === $object) {\n                throw new RuntimeException('Flash has no object');\n            }\n\n            $this->object = $object;\n            $this->data = $data;\n\n            $includeOriginal = (bool)($this->getBlueprint()->form()['images']['original'] ?? null);\n            $this->files = $flash->getFilesByFields($includeOriginal);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $uniqueId\n     * @return void\n     */\n    public function setUniqueId(string $uniqueId): void\n    {\n        if ($uniqueId !== '') {\n            $this->uniqueid = $uniqueId;\n        }\n    }\n\n    /**\n     * @param string $name\n     * @param mixed $default\n     * @param string|null $separator\n     * @return mixed\n     */\n    public function get($name, $default = null, $separator = null)\n    {\n        switch (strtolower($name)) {\n            case 'id':\n            case 'uniqueid':\n            case 'name':\n            case 'noncename':\n            case 'nonceaction':\n            case 'action':\n            case 'data':\n            case 'files':\n            case 'errors';\n            case 'fields':\n            case 'blueprint':\n            case 'page':\n                $method = 'get' . $name;\n                return $this->{$method}();\n        }\n\n        return $this->traitGet($name, $default, $separator);\n    }\n\n    /**\n     * @param string $name\n     * @param mixed $value\n     * @param string|null $separator\n     * @return FlexForm\n     */\n    public function set($name, $value, $separator = null)\n    {\n        switch (strtolower($name)) {\n            case 'id':\n            case 'uniqueid':\n                $method = 'set' . $name;\n                return $this->{$method}();\n        }\n\n        return $this->traitSet($name, $value, $separator);\n    }\n\n    /**\n     * @return string\n     */\n    public function getName(): string\n    {\n        return $this->flexName;\n    }\n\n    /**\n     * @param callable|null $submitMethod\n     */\n    public function setSubmitMethod(?callable $submitMethod): void\n    {\n        $this->submitMethod = $submitMethod;\n    }\n\n    /**\n     * @param string $type\n     * @param string $name\n     */\n    protected function setName(string $type, string $name): void\n    {\n        // Make sure that both type and name do not have dash (convert dashes to underscores).\n        $type = str_replace('-', '_', $type);\n        $name = str_replace('-', '_', $name);\n        $this->flexName = $name ? \"flex-{$type}-{$name}\" : \"flex-{$type}\";\n    }\n\n    /**\n     * @return Data|FlexObjectInterface|object\n     */\n    public function getData()\n    {\n        return $this->data ?? $this->getObject();\n    }\n\n    /**\n     * Get a value from the form.\n     *\n     * Note: Used in form fields.\n     *\n     * @param string $name\n     * @return mixed\n     */\n    public function getValue(string $name)\n    {\n        // Attempt to get value from the form data.\n        $value = $this->data ? $this->data[$name] : null;\n\n        // Return the form data or fall back to the object property.\n        return $value ?? $this->getObject()->getFormValue($name);\n    }\n\n    /**\n     * @param string $name\n     * @return array|mixed|null\n     */\n    public function getDefaultValue(string $name)\n    {\n        return $this->object->getDefaultValue($name);\n    }\n\n    /**\n     * @return array\n     */\n    public function getDefaultValues(): array\n    {\n        return $this->object->getDefaultValues();\n    }\n    /**\n     * @return string\n     */\n    public function getFlexType(): string\n    {\n        return $this->object->getFlexType();\n    }\n\n    /**\n     * Get form flash object.\n     *\n     * @return FormFlashInterface|FlexFormFlash\n     */\n    public function getFlash()\n    {\n        if (null === $this->flash) {\n            $grav = Grav::instance();\n            $config = [\n                'session_id' => $this->getSessionId(),\n                'unique_id' => $this->getUniqueId(),\n                'form_name' => $this->getName(),\n                'folder' => $this->getFlashFolder(),\n                'id' => $this->getFlashId(),\n                'object' => $this->getObject()\n            ];\n\n            $this->flash = new FlexFormFlash($config);\n            $this->flash\n                ->setUrl($grav['uri']->url)\n                ->setUser($grav['user'] ?? null);\n        }\n\n        return $this->flash;\n    }\n\n    /**\n     * @return FlexObjectInterface\n     */\n    public function getObject(): FlexObjectInterface\n    {\n        return $this->object;\n    }\n\n    /**\n     * @return FlexObjectInterface\n     */\n    public function updateObject(): FlexObjectInterface\n    {\n        $data = $this->data instanceof Data ? $this->data->toArray() : [];\n        $files = $this->files;\n\n        return $this->getObject()->update($data, $files);\n    }\n\n    /**\n     * @return Blueprint\n     */\n    public function getBlueprint(): Blueprint\n    {\n        if (null === $this->blueprint) {\n            try {\n                $blueprint = $this->getObject()->getBlueprint($this->name);\n                if ($this->form) {\n                    // We have field overrides available.\n                    $blueprint->extend(['form' => $this->form], true);\n                    $blueprint->init();\n                }\n            } catch (RuntimeException $e) {\n                if (!isset($this->form['fields'])) {\n                    throw $e;\n                }\n\n                // Blueprint is not defined, but we have custom form fields available.\n                $blueprint = new Blueprint(null, ['form' => $this->form]);\n                $blueprint->load();\n                $blueprint->setScope('object');\n                $blueprint->init();\n            }\n\n            $this->blueprint = $blueprint;\n        }\n\n        return $this->blueprint;\n    }\n\n    /**\n     * @return Route|null\n     */\n    public function getFileUploadAjaxRoute(): ?Route\n    {\n        $object = $this->getObject();\n        if (!method_exists($object, 'route')) {\n            /** @var Route $route */\n            $route = Grav::instance()['route'];\n\n            return $route->withExtension('json')->withGravParam('task', 'media.upload');\n        }\n\n        return $object->route('/edit.json/task:media.upload');\n    }\n\n    /**\n     * @param string|null $field\n     * @param string|null $filename\n     * @return Route|null\n     */\n    public function getFileDeleteAjaxRoute($field = null, $filename = null): ?Route\n    {\n        $object = $this->getObject();\n        if (!method_exists($object, 'route')) {\n            /** @var Route $route */\n            $route = Grav::instance()['route'];\n\n            return $route->withExtension('json')->withGravParam('task', 'media.delete');\n        }\n\n        return $object->route('/edit.json/task:media.delete');\n    }\n\n    /**\n     * @param array $params\n     * @param string|null $extension\n     * @return string\n     */\n    public function getMediaTaskRoute(array $params = [], string $extension = null): string\n    {\n        $grav = Grav::instance();\n        /** @var Flex $flex */\n        $flex = $grav['flex_objects'];\n\n        if (method_exists($flex, 'adminRoute')) {\n            return $flex->adminRoute($this->getObject(), $params, $extension ?? 'json');\n        }\n\n        return '';\n    }\n\n    /**\n     * @param string $name\n     * @return mixed|null\n     */\n    #[\\ReturnTypeWillChange]\n    public function __get($name)\n    {\n        $method = \"get{$name}\";\n        if (method_exists($this, $method)) {\n            return $this->{$method}();\n        }\n\n        $form = $this->getBlueprint()->form();\n\n        return $form[$name] ?? null;\n    }\n\n    /**\n     * @param string $name\n     * @param mixed $value\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __set($name, $value)\n    {\n        $method = \"set{$name}\";\n        if (method_exists($this, $method)) {\n            $this->{$method}($value);\n        }\n    }\n\n    /**\n     * @param string $name\n     * @return bool\n     */\n    #[\\ReturnTypeWillChange]\n    public function __isset($name)\n    {\n        $method = \"get{$name}\";\n        if (method_exists($this, $method)) {\n            return true;\n        }\n\n        $form = $this->getBlueprint()->form();\n\n        return isset($form[$name]);\n    }\n\n    /**\n     * @param string $name\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __unset($name)\n    {\n    }\n\n    /**\n     * @return array|bool\n     */\n    protected function getUnserializeAllowedClasses()\n    {\n        return [FlexObject::class];\n    }\n\n    /**\n     * Note: this method clones the object.\n     *\n     * @param FlexObjectInterface $object\n     * @return $this\n     */\n    protected function setObject(FlexObjectInterface $object): self\n    {\n        $this->object = clone $object;\n\n        return $this;\n    }\n\n    /**\n     * @param string $layout\n     * @return Template|TemplateWrapper\n     * @throws LoaderError\n     * @throws SyntaxError\n     */\n    protected function getTemplate($layout)\n    {\n        $grav = Grav::instance();\n\n        /** @var Twig $twig */\n        $twig = $grav['twig'];\n\n        return $twig->twig()->resolveTemplate(\n            [\n                \"flex-objects/layouts/{$this->getFlexType()}/form/{$layout}.html.twig\",\n                \"flex-objects/layouts/_default/form/{$layout}.html.twig\",\n                \"forms/{$layout}/form.html.twig\",\n                'forms/default/form.html.twig'\n            ]\n        );\n    }\n\n    /**\n     * @param array $data\n     * @param array $files\n     * @return void\n     * @throws Exception\n     */\n    protected function doSubmit(array $data, array $files)\n    {\n        /** @var FlexObject $object */\n        $object = clone $this->getObject();\n\n        $method = $this->submitMethod;\n        if ($method) {\n            $method($data, $files, $object);\n        } else {\n            $object->update($data, $files);\n            $object->save();\n        }\n\n        $this->setObject($object);\n        $this->reset();\n    }\n\n    /**\n     * @return array\n     */\n    protected function doSerialize(): array\n    {\n        return $this->doTraitSerialize() + [\n                'items' => $this->items,\n                'form' => $this->form,\n                'object' => $this->object,\n                'flexName' => $this->flexName,\n                'submitMethod' => $this->submitMethod,\n            ];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    protected function doUnserialize(array $data): void\n    {\n        $this->doTraitUnserialize($data);\n\n        $this->items = $data['items'] ?? null;\n        $this->form = $data['form'] ?? null;\n        $this->object = $data['object'] ?? null;\n        $this->flexName = $data['flexName'] ?? null;\n        $this->submitMethod = $data['submitMethod'] ?? null;\n    }\n\n    /**\n     * Filter validated data.\n     *\n     * @param ArrayAccess|Data|null $data\n     * @return void\n     * @phpstan-param ArrayAccess<string,mixed>|Data|null $data\n     */\n    protected function filterData($data = null): void\n    {\n        if ($data instanceof Data) {\n            $data->filter(true, true);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/FlexFormFlash.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex;\n\nuse Grav\\Framework\\Flex\\Interfaces\\FlexInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Form\\FormFlash;\n\n/**\n * Class FlexFormFlash\n * @package Grav\\Framework\\Flex\n */\nclass FlexFormFlash extends FormFlash\n{\n    /** @var FlexDirectory|null */\n    protected $directory;\n    /** @var FlexObjectInterface|null */\n    protected $object;\n\n    /** @var FlexInterface */\n    static protected $flex;\n\n    public static function setFlex(FlexInterface $flex): void\n    {\n        static::$flex = $flex;\n    }\n\n    /**\n     * @param FlexObjectInterface $object\n     * @return void\n     */\n    public function setObject(FlexObjectInterface $object): void\n    {\n        $this->object = $object;\n        $this->directory = $object->getFlexDirectory();\n    }\n\n    /**\n     * @return FlexObjectInterface|null\n     */\n    public function getObject(): ?FlexObjectInterface\n    {\n        return $this->object;\n    }\n\n    /**\n     * @param FlexDirectory $directory\n     */\n    public function setDirectory(FlexDirectory $directory): void\n    {\n        $this->directory = $directory;\n    }\n\n    /**\n     * @return FlexDirectory|null\n     */\n    public function getDirectory(): ?FlexDirectory\n    {\n        return $this->directory;\n    }\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        $serialized = parent::jsonSerialize();\n\n        $object = $this->getObject();\n        if ($object instanceof FlexObjectInterface) {\n            $serialized['object'] = [\n                'type' => $object->getFlexType(),\n                'key' => $object->getKey() ?: null,\n                'storage_key' => $object->getStorageKey(),\n                'timestamp' => $object->getTimestamp(),\n                'serialized' => $object->prepareStorage()\n            ];\n        } else {\n            $directory = $this->getDirectory();\n            if ($directory instanceof FlexDirectory) {\n                $serialized['directory'] = [\n                    'type' => $directory->getFlexType()\n                ];\n            }\n        }\n\n        return $serialized;\n    }\n\n    /**\n     * @param array|null $data\n     * @param array $config\n     * @return void\n     */\n    protected function init(?array $data, array $config): void\n    {\n        parent::init($data, $config);\n\n        $data = $data ?? [];\n        /** @var FlexObjectInterface|null $object */\n        $object = $config['object'] ?? null;\n        $create = true;\n        if ($object) {\n            $directory = $object->getFlexDirectory();\n            $create = !$object->exists();\n        } elseif (null === ($directory = $config['directory'] ?? null)) {\n            $flex = $config['flex'] ?? static::$flex;\n            $type = $data['object']['type'] ?? $data['directory']['type'] ?? null;\n            $directory = $flex && $type ? $flex->getDirectory($type) : null;\n        }\n\n        if ($directory && $create && isset($data['object']['serialized'])) {\n            // TODO: update instead of create new.\n            $object = $directory->createObject($data['object']['serialized'], $data['object']['key'] ?? '');\n        }\n\n        if ($object) {\n            $this->setObject($object);\n        } elseif ($directory) {\n            $this->setDirectory($directory);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/FlexIdentifier.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Flex;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Object\\Identifiers\\Identifier;\nuse RuntimeException;\n\n/**\n * Interface IdentifierInterface\n *\n * @template T of FlexObjectInterface\n * @extends Identifier<T>\n */\nclass FlexIdentifier extends Identifier\n{\n    /** @var string */\n    private $keyField;\n    /** @var FlexObjectInterface|null */\n    private $object = null;\n\n    /**\n     * @param FlexObjectInterface $object\n     * @return FlexIdentifier<T>\n     */\n    public static function createFromObject(FlexObjectInterface $object): FlexIdentifier\n    {\n        $instance = new static($object->getKey(), $object->getFlexType(), 'key');\n        $instance->setObject($object);\n\n        return $instance;\n    }\n\n    /**\n     * IdentifierInterface constructor.\n     * @param string $id\n     * @param string $type\n     * @param string $keyField\n     */\n    public function __construct(string $id, string $type, string $keyField = 'key')\n    {\n        parent::__construct($id, $type);\n\n        $this->keyField = $keyField;\n    }\n\n    /**\n     * @return T\n     */\n    public function getObject(): ?FlexObjectInterface\n    {\n        if (!isset($this->object)) {\n            /** @var Flex $flex */\n            $flex = Grav::instance()['flex'];\n\n            $this->object = $flex->getObject($this->getId(), $this->getType(), $this->keyField);\n        }\n\n        return $this->object;\n    }\n\n    /**\n     * @param T $object\n     */\n    public function setObject(FlexObjectInterface $object): void\n    {\n        $type = $this->getType();\n        if ($type !== $object->getFlexType()) {\n            throw new RuntimeException(sprintf('Object has to be type %s, %s given', $type, $object->getFlexType()));\n        }\n\n        $this->object = $object;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/FlexIndex.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex;\n\nuse Exception;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\File\\CompiledJsonFile;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Inflector;\nuse Grav\\Common\\Session;\nuse Grav\\Framework\\Cache\\CacheInterface;\nuse Grav\\Framework\\Collection\\CollectionInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexCollectionInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexIndexInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexStorageInterface;\nuse Grav\\Framework\\Object\\Interfaces\\ObjectInterface;\nuse Grav\\Framework\\Object\\ObjectIndex;\nuse Monolog\\Logger;\nuse Psr\\SimpleCache\\InvalidArgumentException;\nuse RuntimeException;\nuse function count;\nuse function get_class;\nuse function in_array;\n\n/**\n * Class FlexIndex\n * @package Grav\\Framework\\Flex\n * @template T of FlexObjectInterface\n * @template C of FlexCollectionInterface\n * @extends ObjectIndex<string,T,C>\n * @implements FlexIndexInterface<T>\n * @mixin C\n */\nclass FlexIndex extends ObjectIndex implements FlexIndexInterface\n{\n    const VERSION = 1;\n\n    /** @var FlexDirectory|null */\n    private $_flexDirectory;\n    /** @var string */\n    private $_keyField = 'storage_key';\n    /** @var array */\n    private $_indexKeys;\n\n    /**\n     * @param FlexDirectory $directory\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    public static function createFromStorage(FlexDirectory $directory)\n    {\n        return static::createFromArray(static::loadEntriesFromStorage($directory->getStorage()), $directory);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::createFromArray()\n     */\n    public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null)\n    {\n        $instance = new static($entries, $directory);\n        $instance->setKeyField($keyField);\n\n        return $instance;\n    }\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @return array\n     */\n    public static function loadEntriesFromStorage(FlexStorageInterface $storage): array\n    {\n        return $storage->getExistingKeys();\n    }\n\n    /**\n     * You can define indexes for fast lookup.\n     *\n     * Primary key: $meta['key']\n     * Secondary keys:  $meta['my_field']\n     *\n     * @param array $meta\n     * @param array $data\n     * @param FlexStorageInterface $storage\n     * @return void\n     */\n    public static function updateObjectMeta(array &$meta, array $data, FlexStorageInterface $storage)\n    {\n        // For backwards compatibility, no need to call this method when you override this method.\n        static::updateIndexData($meta, $data);\n    }\n\n    /**\n     * Initializes a new FlexIndex.\n     *\n     * @param array $entries\n     * @param FlexDirectory|null $directory\n     */\n    public function __construct(array $entries = [], FlexDirectory $directory = null)\n    {\n        // @phpstan-ignore-next-line\n        if (get_class($this) === __CLASS__) {\n            user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \\Grav\\Common\\Flex\\Types\\Generic\\GenericIndex or your own class instead', E_USER_DEPRECATED);\n        }\n\n        parent::__construct($entries);\n\n        $this->_flexDirectory = $directory;\n        $this->setKeyField(null);\n    }\n\n    /**\n     * @return string\n     */\n    public function getKey()\n    {\n        return $this->_key ?: $this->getFlexType() . '@@' . spl_object_hash($this);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCommonInterface::hasFlexFeature()\n     */\n    public function hasFlexFeature(string $name): bool\n    {\n        return in_array($name, $this->getFlexFeatures(), true);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCommonInterface::hasFlexFeature()\n     */\n    public function getFlexFeatures(): array\n    {\n        /** @var array $implements */\n        $implements = class_implements($this->getFlexDirectory()->getCollectionClass());\n\n        $list = [];\n        foreach ($implements as $interface) {\n            if ($pos = strrpos($interface, '\\\\')) {\n                $interface = substr($interface, $pos+1);\n            }\n\n            $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface));\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::search()\n     */\n    public function search(string $search, $properties = null, array $options = null)\n    {\n        $directory = $this->getFlexDirectory();\n        $properties = $directory->getSearchProperties($properties);\n        $options = $directory->getSearchOptions($options);\n\n        return $this->__call('search', [$search, $properties, $options]);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::sort()\n     */\n    public function sort(array $orderings)\n    {\n        return $this->orderBy($orderings);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::filterBy()\n     */\n    public function filterBy(array $filters)\n    {\n        return $this->__call('filterBy', [$filters]);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexType()\n     */\n    public function getFlexType(): string\n    {\n        return $this->getFlexDirectory()->getFlexType();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexDirectory()\n     */\n    public function getFlexDirectory(): FlexDirectory\n    {\n        if (null === $this->_flexDirectory) {\n            throw new RuntimeException('Flex Directory not defined, object is not fully defined');\n        }\n\n        return $this->_flexDirectory;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getTimestamp()\n     */\n    public function getTimestamp(): int\n    {\n        $timestamps = $this->getTimestamps();\n\n        return $timestamps ? max($timestamps) : time();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getCacheKey()\n     */\n    public function getCacheKey(): string\n    {\n        return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->_keyField);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getCacheChecksum()\n     */\n    public function getCacheChecksum(): string\n    {\n        $list = [];\n        foreach ($this->getEntries() as $key => $value) {\n            $list[$key] = $value['checksum'] ?? $value['storage_timestamp'];\n        }\n\n        return sha1((string)json_encode($list));\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getTimestamps()\n     */\n    public function getTimestamps(): array\n    {\n        return $this->getIndexMap('storage_timestamp');\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getStorageKeys()\n     */\n    public function getStorageKeys(): array\n    {\n        return $this->getIndexMap('storage_key');\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getFlexKeys()\n     */\n    public function getFlexKeys(): array\n    {\n        // Get storage keys for the objects.\n        $keys = [];\n        $type = $this->getFlexDirectory()->getFlexType() . '.obj:';\n\n        foreach ($this->getEntries() as $key => $value) {\n            $keys[$key] = $value['flex_key'] ?? $type . $value['storage_key'];\n        }\n\n        return $keys;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexIndexInterface::withKeyField()\n     */\n    public function withKeyField(string $keyField = null)\n    {\n        $keyField = $keyField ?: 'key';\n        if ($keyField === $this->getKeyField()) {\n            return $this;\n        }\n\n        $type = $keyField === 'flex_key' ? $this->getFlexDirectory()->getFlexType() . '.obj:' : '';\n        $entries = [];\n        foreach ($this->getEntries() as $key => $value) {\n            if (!isset($value['key'])) {\n                $value['key'] = $key;\n            }\n\n            if (isset($value[$keyField])) {\n                $entries[$value[$keyField]] = $value;\n            } elseif ($keyField === 'flex_key') {\n                $entries[$type . $value['storage_key']] = $value;\n            }\n        }\n\n        return $this->createFrom($entries, $keyField);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::getIndex()\n     */\n    public function getIndex()\n    {\n        return $this;\n    }\n\n    /**\n     * @return FlexCollectionInterface\n     * @phpstan-return C\n     */\n    public function getCollection()\n    {\n        return $this->loadCollection();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCollectionInterface::render()\n     */\n    public function render(string $layout = null, array $context = [])\n    {\n        return $this->__call('render', [$layout, $context]);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexIndexInterface::getFlexKeys()\n     */\n    public function getIndexMap(string $indexKey = null)\n    {\n        if (null === $indexKey) {\n            return $this->getEntries();\n        }\n\n        // Get storage keys for the objects.\n        $index = [];\n        foreach ($this->getEntries() as $key => $value) {\n            $index[$key] = $value[$indexKey] ?? null;\n        }\n\n        return $index;\n    }\n\n    /**\n     * @param string $key\n     * @return array\n     */\n    public function getMetaData($key): array\n    {\n        return $this->getEntries()[$key] ?? [];\n    }\n\n    /**\n     * @return string\n     */\n    public function getKeyField(): string\n    {\n        return $this->_keyField;\n    }\n\n    /**\n     * @param string|null $namespace\n     * @return CacheInterface\n     */\n    public function getCache(string $namespace = null)\n    {\n        return $this->getFlexDirectory()->getCache($namespace);\n    }\n\n    /**\n     * @param array $orderings\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    public function orderBy(array $orderings)\n    {\n        if (!$orderings || !$this->count()) {\n            return $this;\n        }\n\n        // Handle primary key alias.\n        $keyField = $this->getFlexDirectory()->getStorage()->getKeyField();\n        if ($keyField !== 'key' && $keyField !== 'storage_key' && isset($orderings[$keyField])) {\n            $orderings['key'] = $orderings[$keyField];\n            unset($orderings[$keyField]);\n        }\n\n        // Check if ordering needs to load the objects.\n        if (array_diff_key($orderings, $this->getIndexKeys())) {\n            return $this->__call('orderBy', [$orderings]);\n        }\n\n        // Ordering can be done by using index only.\n        $previous = null;\n        foreach (array_reverse($orderings) as $field => $ordering) {\n            $field = (string)$field;\n            if ($this->getKeyField() === $field) {\n                $keys = $this->getKeys();\n                $search = array_combine($keys, $keys) ?: [];\n            } elseif ($field === 'flex_key') {\n                $search = $this->getFlexKeys();\n            } else {\n                $search = $this->getIndexMap($field);\n            }\n\n            // Update current search to match the previous ordering.\n            if (null !== $previous) {\n                $search = array_replace($previous, $search);\n            }\n\n            // Order by current field.\n            if (strtoupper($ordering) === 'DESC') {\n                arsort($search, SORT_NATURAL | SORT_FLAG_CASE);\n            } else {\n                asort($search, SORT_NATURAL | SORT_FLAG_CASE);\n            }\n\n            $previous = $search;\n        }\n\n        return $this->createFrom(array_replace($previous ?? [], $this->getEntries()));\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function call($method, array $arguments = [])\n    {\n        return $this->__call('call', [$method, $arguments]);\n    }\n\n    /**\n     * @param string $name\n     * @param array $arguments\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function __call($name, $arguments)\n    {\n        /** @var Debugger $debugger */\n        $debugger = Grav::instance()['debugger'];\n\n        /** @phpstan-var class-string $className */\n        $className = $this->getFlexDirectory()->getCollectionClass();\n        $cachedMethods = $className::getCachedMethods();\n\n        $flexType = $this->getFlexType();\n\n        if (!empty($cachedMethods[$name])) {\n            $type = $cachedMethods[$name];\n            if ($type === 'session') {\n                /** @var Session $session */\n                $session = Grav::instance()['session'];\n                $cacheKey = $session->getId() . ($session->user->username ?? '');\n            } else {\n                $cacheKey = '';\n            }\n            $key = \"{$flexType}.idx.\" . sha1($name . '.' . $cacheKey . json_encode($arguments) . $this->getCacheKey());\n            $checksum = $this->getCacheChecksum();\n\n            $cache = $this->getCache('object');\n\n            try {\n                $cached = $cache->get($key);\n                $test = $cached[0] ?? null;\n                $result = $test === $checksum ? ($cached[1] ?? null) : null;\n\n                // Make sure the keys aren't changed if the returned type is the same index type.\n                if ($result instanceof self && $flexType === $result->getFlexType()) {\n                    $result = $result->withKeyField($this->getKeyField());\n                }\n            } catch (InvalidArgumentException $e) {\n                $debugger->addException($e);\n            }\n\n            if (!isset($result)) {\n                $collection = $this->loadCollection();\n                $result = $collection->{$name}(...$arguments);\n                $debugger->addMessage(\"Cache miss: '{$flexType}::{$name}()'\", 'debug');\n\n                try {\n                    // If flex collection is returned, convert it back to flex index.\n                    if ($result instanceof FlexCollection) {\n                        $cached = $result->getFlexDirectory()->getIndex($result->getKeys(), $this->getKeyField());\n                    } else {\n                        $cached = $result;\n                    }\n\n                    $cache->set($key, [$checksum, $cached]);\n                } catch (InvalidArgumentException $e) {\n                    $debugger->addException($e);\n\n                    // TODO: log error.\n                }\n            }\n        } else {\n            $collection = $this->loadCollection();\n            if (\\is_callable([$collection, $name])) {\n                $result = $collection->{$name}(...$arguments);\n                if (!isset($cachedMethods[$name])) {\n                    $debugger->addMessage(\"Call '{$flexType}:{$name}()' isn't cached\", 'debug');\n                }\n            } else {\n                $result = null;\n            }\n        }\n\n        return $result;\n    }\n\n    /**\n     * @return array\n     */\n    public function __serialize(): array\n    {\n        return ['type' => $this->getFlexType(), 'entries' => $this->getEntries()];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    public function __unserialize(array $data): void\n    {\n        $this->_flexDirectory = Grav::instance()['flex']->getDirectory($data['type']);\n        $this->setEntries($data['entries']);\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function __debugInfo()\n    {\n        return [\n            'type:private' => $this->getFlexType(),\n            'key:private' => $this->getKey(),\n            'entries_key:private' => $this->getKeyField(),\n            'entries:private' => $this->getEntries()\n        ];\n    }\n\n    /**\n     * @param array $entries\n     * @param string|null $keyField\n     * @return static\n     * @phpstan-return static<T,C>\n     */\n    protected function createFrom(array $entries, string $keyField = null)\n    {\n        /** @phpstan-var static<T,C> $index */\n        $index = new static($entries, $this->getFlexDirectory());\n        $index->setKeyField($keyField ?? $this->_keyField);\n\n        return $index;\n    }\n\n    /**\n     * @param string|null $keyField\n     * @return void\n     */\n    protected function setKeyField(string $keyField = null)\n    {\n        $this->_keyField = $keyField ?? 'storage_key';\n    }\n\n    /**\n     * @return array\n     */\n    protected function getIndexKeys()\n    {\n        if (null === $this->_indexKeys) {\n            $entries = $this->getEntries();\n            $first = reset($entries);\n            if ($first) {\n                $keys = array_keys($first);\n                $keys = array_combine($keys, $keys) ?: [];\n            } else {\n                $keys = [];\n            }\n\n            $this->setIndexKeys($keys);\n        }\n\n        return $this->_indexKeys;\n    }\n\n    /**\n     * @param array $indexKeys\n     * @return void\n     */\n    protected function setIndexKeys(array $indexKeys)\n    {\n        // Add defaults.\n        $indexKeys += [\n            'key' => 'key',\n            'storage_key' => 'storage_key',\n            'storage_timestamp' => 'storage_timestamp',\n            'flex_key' => 'flex_key'\n        ];\n\n\n        $this->_indexKeys = $indexKeys;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getTypePrefix()\n    {\n        return 'i.';\n    }\n\n    /**\n     * @param string $key\n     * @param mixed $value\n     * @return ObjectInterface|null\n     * @phpstan-return T|null\n     */\n    protected function loadElement($key, $value): ?ObjectInterface\n    {\n        /** @phpstan-var T[] $objects */\n        $objects = $this->getFlexDirectory()->loadObjects([$key => $value]);\n\n        return $objects ? reset($objects): null;\n    }\n\n    /**\n     * @param array|null $entries\n     * @return ObjectInterface[]\n     * @phpstan-return T[]\n     */\n    protected function loadElements(array $entries = null): array\n    {\n        /** @phpstan-var T[] $objects */\n        $objects = $this->getFlexDirectory()->loadObjects($entries ?? $this->getEntries());\n\n        return $objects;\n    }\n\n    /**\n     * @param array|null $entries\n     * @return CollectionInterface\n     * @phpstan-return C\n     */\n    protected function loadCollection(array $entries = null): CollectionInterface\n    {\n        /** @var C $collection */\n        $collection = $this->getFlexDirectory()->loadCollection($entries ?? $this->getEntries(), $this->_keyField);\n\n        return $collection;\n    }\n\n    /**\n     * @param mixed $value\n     * @return bool\n     */\n    protected function isAllowedElement($value): bool\n    {\n        return $value instanceof FlexObject;\n    }\n\n    /**\n     * @param FlexObjectInterface $object\n     * @return mixed\n     */\n    protected function getElementMeta($object)\n    {\n        return $object->getMetaData();\n    }\n\n    /**\n     * @param FlexObjectInterface $element\n     * @return string\n     */\n    protected function getCurrentKey($element)\n    {\n        $keyField = $this->getKeyField();\n        if ($keyField === 'storage_key') {\n            return $element->getStorageKey();\n        }\n        if ($keyField === 'flex_key') {\n            return $element->getFlexKey();\n        }\n        if ($keyField === 'key') {\n            return $element->getKey();\n        }\n\n        return $element->getKey();\n    }\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @param array $index      Saved index\n     * @param array $entries    Updated index\n     * @param array $options\n     * @return array            Compiled list of entries\n     */\n    protected static function updateIndexFile(FlexStorageInterface $storage, array $index, array $entries, array $options = []): array\n    {\n        $indexFile = static::getIndexFile($storage);\n        if (null === $indexFile) {\n            return $entries;\n        }\n\n        // Calculate removed objects.\n        $removed = array_diff_key($index, $entries);\n\n        // First get rid of all removed objects.\n        if ($removed) {\n            $index = array_diff_key($index, $removed);\n        }\n\n        if ($entries && empty($options['force_update'])) {\n            // Calculate difference between saved index and current data.\n            foreach ($index as $key => $entry) {\n                $storage_key = $entry['storage_key'] ?? null;\n                if (isset($entries[$storage_key]) && $entries[$storage_key]['storage_timestamp'] === $entry['storage_timestamp']) {\n                    // Entry is up to date, no update needed.\n                    unset($entries[$storage_key]);\n                }\n            }\n\n            if (empty($entries) && empty($removed)) {\n                // No objects were added, updated or removed.\n                return $index;\n            }\n        } elseif (!$removed) {\n            // There are no objects and nothing was removed.\n            return [];\n        }\n\n        // Index should be updated, lock the index file for saving.\n        $indexFile->lock();\n\n        // Read all the data rows into an array using chunks of 100.\n        $keys = array_fill_keys(array_keys($entries), null);\n        $chunks = array_chunk($keys, 100, true);\n        $updated = $added = [];\n        foreach ($chunks as $keys) {\n            $rows = $storage->readRows($keys);\n\n            $keyField = $storage->getKeyField();\n\n            // Go through all the updated objects and refresh their index data.\n            foreach ($rows as $key => $row) {\n                if (null !== $row || !empty($options['include_missing'])) {\n                    $entry = $entries[$key] + ['key' => $key];\n                    if ($keyField !== 'storage_key' && isset($row[$keyField])) {\n                        $entry['key'] = $row[$keyField];\n                    }\n                    static::updateObjectMeta($entry, $row ?? [], $storage);\n                    if (isset($row['__ERROR'])) {\n                        $entry['__ERROR'] = true;\n                        static::onException(new RuntimeException(sprintf('Object failed to load: %s (%s)', $key,\n                            $row['__ERROR'])));\n                    }\n                    if (isset($index[$key])) {\n                        // Update object in the index.\n                        $updated[$key] = $entry;\n                    } else {\n                        // Add object into the index.\n                        $added[$key] = $entry;\n                    }\n\n                    // Either way, update the entry.\n                    $index[$key] = $entry;\n                } elseif (isset($index[$key])) {\n                    // Remove object from the index.\n                    $removed[$key] = $index[$key];\n                    unset($index[$key]);\n                }\n            }\n            unset($rows);\n        }\n\n        // Sort the index before saving it.\n        ksort($index, SORT_NATURAL | SORT_FLAG_CASE);\n\n        static::onChanges($index, $added, $updated, $removed);\n\n        $indexFile->save(['version' => static::VERSION, 'timestamp' => time(), 'count' => count($index), 'index' => $index]);\n        $indexFile->unlock();\n\n        return $index;\n    }\n\n    /**\n     * @param array $entry\n     * @param array $data\n     * @return void\n     * @deprecated 1.7 Use static ::updateObjectMeta() method instead.\n     */\n    protected static function updateIndexData(array &$entry, array $data)\n    {\n    }\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @return array\n     */\n    protected static function loadIndex(FlexStorageInterface $storage)\n    {\n        $indexFile = static::getIndexFile($storage);\n\n        if ($indexFile) {\n            $data = [];\n            try {\n                $data = (array)$indexFile->content();\n                $version = $data['version'] ?? null;\n                if ($version !== static::VERSION) {\n                    $data = [];\n                }\n            } catch (Exception $e) {\n                $e = new RuntimeException(sprintf('Index failed to load: %s', $e->getMessage()), $e->getCode(), $e);\n\n                static::onException($e);\n            }\n\n            if ($data) {\n                return $data;\n            }\n        }\n\n        return ['version' => static::VERSION, 'timestamp' => 0, 'count' => 0, 'index' => []];\n    }\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @return array\n     */\n    protected static function loadEntriesFromIndex(FlexStorageInterface $storage)\n    {\n        $data = static::loadIndex($storage);\n\n        return $data['index'] ?? [];\n    }\n\n    /**\n     * @param FlexStorageInterface $storage\n     * @return CompiledYamlFile|CompiledJsonFile|null\n     */\n    protected static function getIndexFile(FlexStorageInterface $storage)\n    {\n        if (!method_exists($storage, 'isIndexed') || !$storage->isIndexed()) {\n            return null;\n        }\n\n        $path = $storage->getStoragePath();\n        if (!$path) {\n            return null;\n        }\n\n        // Load saved index file.\n        $grav = Grav::instance();\n        $locator = $grav['locator'];\n        $filename = $locator->findResource(\"{$path}/index.yaml\", true, true);\n\n        return CompiledYamlFile::instance($filename);\n    }\n\n    /**\n     * @param Exception $e\n     * @return void\n     */\n    protected static function onException(Exception $e)\n    {\n        $grav = Grav::instance();\n\n        /** @var Logger $logger */\n        $logger = $grav['log'];\n        $logger->addAlert($e->getMessage());\n\n        /** @var Debugger $debugger */\n        $debugger = $grav['debugger'];\n        $debugger->addException($e);\n        $debugger->addMessage($e, 'error');\n    }\n\n    /**\n     * @param array $entries\n     * @param array $added\n     * @param array $updated\n     * @param array $removed\n     * @return void\n     */\n    protected static function onChanges(array $entries, array $added, array $updated, array $removed)\n    {\n        $addedCount = count($added);\n        $updatedCount = count($updated);\n        $removedCount = count($removed);\n\n        if ($addedCount + $updatedCount + $removedCount) {\n            $message = sprintf('Index updated, %d objects (%d added, %d updated, %d removed).', count($entries), $addedCount, $updatedCount, $removedCount);\n\n            $grav = Grav::instance();\n\n            /** @var Debugger $debugger */\n            $debugger = $grav['debugger'];\n            $debugger->addMessage($message, 'debug');\n        }\n    }\n\n    // DEPRECATED METHODS\n\n    /**\n     * @param bool $prefix\n     * @return string\n     * @deprecated 1.6 Use `->getFlexType()` instead.\n     */\n    public function getType($prefix = false)\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED);\n\n        $type = $prefix ? $this->getTypePrefix() : '';\n\n        return $type . $this->getFlexType();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/FlexObject.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex;\n\nuse ArrayAccess;\nuse Exception;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Inflector;\nuse Grav\\Common\\Twig\\Twig;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Cache\\CacheInterface;\nuse Grav\\Framework\\ContentBlock\\HtmlBlock;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexAuthorizeInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexFormInterface;\nuse Grav\\Framework\\Flex\\Traits\\FlexAuthorizeTrait;\nuse Grav\\Framework\\Flex\\Traits\\FlexRelatedDirectoryTrait;\nuse Grav\\Framework\\Object\\Access\\NestedArrayAccessTrait;\nuse Grav\\Framework\\Object\\Access\\NestedPropertyTrait;\nuse Grav\\Framework\\Object\\Access\\OverloadedPropertyTrait;\nuse Grav\\Framework\\Object\\Base\\ObjectTrait;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Object\\Interfaces\\ObjectInterface;\nuse Grav\\Framework\\Object\\Property\\LazyPropertyTrait;\nuse Psr\\SimpleCache\\InvalidArgumentException;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse RuntimeException;\nuse Twig\\Error\\LoaderError;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Template;\nuse Twig\\TemplateWrapper;\nuse function get_class;\nuse function in_array;\nuse function is_array;\nuse function is_object;\nuse function is_scalar;\nuse function is_string;\nuse function json_encode;\n\n/**\n * Class FlexObject\n * @package Grav\\Framework\\Flex\n */\nclass FlexObject implements FlexObjectInterface, FlexAuthorizeInterface\n{\n    use ObjectTrait;\n    use LazyPropertyTrait {\n        LazyPropertyTrait::__construct as private objectConstruct;\n    }\n    use NestedPropertyTrait;\n    use OverloadedPropertyTrait;\n    use NestedArrayAccessTrait;\n    use FlexAuthorizeTrait;\n    use FlexRelatedDirectoryTrait;\n\n    /** @var FlexDirectory */\n    private $_flexDirectory;\n    /** @var FlexFormInterface[] */\n    private $_forms = [];\n    /** @var Blueprint[] */\n    private $_blueprint = [];\n    /** @var array|null */\n    private $_meta;\n    /** @var array|null */\n    protected $_original;\n    /** @var string|null */\n    protected $storage_key;\n    /** @var int|null */\n    protected $storage_timestamp;\n\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n            'getTypePrefix' => true,\n            'getType' => true,\n            'getFlexType' => true,\n            'getFlexDirectory' => true,\n            'hasFlexFeature' => true,\n            'getFlexFeatures' => true,\n            'getCacheKey' => true,\n            'getCacheChecksum' => false,\n            'getTimestamp' => true,\n            'value' => true,\n            'exists' => true,\n            'hasProperty' => true,\n            'getProperty' => true,\n\n            // FlexAclTrait\n            'isAuthorized' => 'session',\n        ];\n    }\n\n    /**\n     * @param array $elements\n     * @param array $storage\n     * @param FlexDirectory $directory\n     * @param bool $validate\n     * @return static\n     */\n    public static function createFromStorage(array $elements, array $storage, FlexDirectory $directory, bool $validate = false)\n    {\n        $instance = new static($elements, $storage['key'], $directory, $validate);\n        $instance->setMetaData($storage);\n\n        return $instance;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::__construct()\n     */\n    public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false)\n    {\n        if (get_class($this) === __CLASS__) {\n            user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \\Grav\\Common\\Flex\\Types\\Generic\\GenericObject or your own class instead', E_USER_DEPRECATED);\n        }\n\n        $this->_flexDirectory = $directory;\n\n        if (isset($elements['__META'])) {\n            $this->setMetaData($elements['__META']);\n            unset($elements['__META']);\n        }\n\n        if ($validate) {\n            $blueprint = $this->getFlexDirectory()->getBlueprint();\n\n            $blueprint->validate($elements, ['xss_check' => false]);\n\n            $elements = $blueprint->filter($elements, true, true);\n        }\n\n        $this->filterElements($elements);\n\n        $this->objectConstruct($elements, $key);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCommonInterface::hasFlexFeature()\n     */\n    public function hasFlexFeature(string $name): bool\n    {\n        return in_array($name, $this->getFlexFeatures(), true);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexCommonInterface::hasFlexFeature()\n     */\n    public function getFlexFeatures(): array\n    {\n        /** @var array $implements */\n        $implements = class_implements($this);\n\n        $list = [];\n        foreach ($implements as $interface) {\n            if ($pos = strrpos($interface, '\\\\')) {\n                $interface = substr($interface, $pos+1);\n            }\n\n            $list[] = Inflector::hyphenize(str_replace('Interface', '', $interface));\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getFlexType()\n     */\n    public function getFlexType(): string\n    {\n        return $this->_flexDirectory->getFlexType();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getFlexDirectory()\n     */\n    public function getFlexDirectory(): FlexDirectory\n    {\n        return $this->_flexDirectory;\n    }\n\n    /**\n     * Refresh object from the storage.\n     *\n     * @param bool $keepMissing\n     * @return bool True if the object was refreshed\n     */\n    public function refresh(bool $keepMissing = false): bool\n    {\n        $key = $this->getStorageKey();\n        if ('' === $key) {\n            return false;\n        }\n\n        $storage = $this->getFlexDirectory()->getStorage();\n        $meta = $storage->getMetaData([$key])[$key] ?? null;\n\n        $newChecksum = $meta['checksum'] ?? $meta['storage_timestamp'] ?? null;\n        $curChecksum = $this->_meta['checksum'] ?? $this->_meta['storage_timestamp'] ?? null;\n\n        // Check if object is up to date with the storage.\n        if (null === $newChecksum || $newChecksum === $curChecksum) {\n            return false;\n        }\n\n        // Get current elements (if requested).\n        $current = $keepMissing ? $this->getElements() : [];\n        // Get elements from the filesystem.\n        $elements = $storage->readRows([$key => null])[$key] ?? null;\n        if (null !== $elements) {\n            $meta = $elements['__META'] ?? $meta;\n            unset($elements['__META']);\n            $this->filterElements($elements);\n            $newKey = $meta['key'] ?? $this->getKey();\n            if ($meta) {\n                $this->setMetaData($meta);\n            }\n            $this->objectConstruct($elements, $newKey);\n\n            if ($current) {\n                // Inject back elements which are missing in the filesystem.\n                $data = $this->getBlueprint()->flattenData($current);\n                foreach ($data as $property => $value) {\n                    if (strpos($property, '.') === false) {\n                        $this->defProperty($property, $value);\n                    } else {\n                        $this->defNestedProperty($property, $value);\n                    }\n                }\n            }\n\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addMessage(\"Refreshed {$this->getFlexType()} object {$this->getKey()}\", 'debug');\n        }\n\n        return true;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getTimestamp()\n     */\n    public function getTimestamp(): int\n    {\n        return $this->_meta['storage_timestamp'] ?? 0;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getCacheKey()\n     */\n    public function getCacheKey(): string\n    {\n        return $this->hasKey() ? $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getKey() : '';\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getCacheChecksum()\n     */\n    public function getCacheChecksum(): string\n    {\n        return (string)($this->_meta['checksum'] ?? $this->getTimestamp());\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::search()\n     */\n    public function search(string $search, $properties = null, array $options = null): float\n    {\n        $directory = $this->getFlexDirectory();\n        $properties = $directory->getSearchProperties($properties);\n        $options = $directory->getSearchOptions($options);\n\n        $weight = 0;\n        foreach ($properties as $property) {\n            if (strpos($property, '.')) {\n                $weight += $this->searchNestedProperty($property, $search, $options);\n            } else {\n                $weight += $this->searchProperty($property, $search, $options);\n            }\n        }\n\n        return $weight > 0 ? min($weight, 1) : 0;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see ObjectInterface::getFlexKey()\n     */\n    public function getKey()\n    {\n        return (string)$this->_key;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getFlexKey()\n     */\n    public function getFlexKey(): string\n    {\n        $key = $this->_meta['flex_key'] ?? null;\n\n        if (!$key && $key = $this->getStorageKey()) {\n            $key = $this->_flexDirectory->getFlexType() . '.obj:' . $key;\n        }\n\n        return (string)$key;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getStorageKey()\n     */\n    public function getStorageKey(): string\n    {\n        return (string)($this->storage_key ?? $this->_meta['storage_key'] ?? null);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getMetaData()\n     */\n    public function getMetaData(): array\n    {\n        return $this->_meta ?? [];\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::exists()\n     */\n    public function exists(): bool\n    {\n        $key = $this->getStorageKey();\n\n        return $key && $this->getFlexDirectory()->getStorage()->hasKey($key);\n    }\n\n    /**\n     * @param string $property\n     * @param string $search\n     * @param array|null $options\n     * @return float\n     */\n    public function searchProperty(string $property, string $search, array $options = null): float\n    {\n        $options = $options ?? (array)$this->getFlexDirectory()->getConfig('data.search.options');\n        $value = $this->getProperty($property);\n\n        return $this->searchValue($property, $value, $search, $options);\n    }\n\n    /**\n     * @param string $property\n     * @param string $search\n     * @param array|null $options\n     * @return float\n     */\n    public function searchNestedProperty(string $property, string $search, array $options = null): float\n    {\n        $options = $options ?? (array)$this->getFlexDirectory()->getConfig('data.search.options');\n        if ($property === 'key') {\n            $value = $this->getKey();\n        } else {\n            $value = $this->getNestedProperty($property);\n        }\n\n        return $this->searchValue($property, $value, $search, $options);\n    }\n\n    /**\n     * @param string $name\n     * @param mixed $value\n     * @param string $search\n     * @param array|null $options\n     * @return float\n     */\n    protected function searchValue(string $name, $value, string $search, array $options = null): float\n    {\n        $options = $options ?? [];\n\n        // Ignore empty search strings.\n        $search = trim($search);\n        if ($search === '') {\n            return 0;\n        }\n\n        // Search only non-empty string values.\n        if (!is_string($value) || $value === '') {\n            return 0;\n        }\n\n        $caseSensitive = $options['case_sensitive'] ?? false;\n\n        $tested = false;\n        if (($tested |= !empty($options['same_as']))) {\n            if ($caseSensitive) {\n                if ($value === $search) {\n                    return (float)$options['same_as'];\n                }\n            } elseif (mb_strtolower($value) === mb_strtolower($search)) {\n                return (float)$options['same_as'];\n            }\n        }\n        if (($tested |= !empty($options['starts_with'])) && Utils::startsWith($value, $search, $caseSensitive)) {\n            return (float)$options['starts_with'];\n        }\n        if (($tested |= !empty($options['ends_with'])) && Utils::endsWith($value, $search, $caseSensitive)) {\n            return (float)$options['ends_with'];\n        }\n        if ((!$tested || !empty($options['contains'])) && Utils::contains($value, $search, $caseSensitive)) {\n            return (float)($options['contains'] ?? 1);\n        }\n\n        return 0;\n    }\n\n    /**\n     * Get original data before update\n     *\n     * @return array\n     */\n    public function getOriginalData(): array\n    {\n        return $this->_original ?? [];\n    }\n\n    /**\n     * Get diff array from the object.\n     *\n     * @return array\n     */\n    public function getDiff(): array\n    {\n        $blueprint = $this->getBlueprint();\n\n        $flattenOriginal = $blueprint->flattenData($this->getOriginalData());\n        $flattenElements = $blueprint->flattenData($this->getElements());\n        $removedElements = array_diff_key($flattenOriginal, $flattenElements);\n        $diff = [];\n\n        // Include all added or changed keys.\n        foreach ($flattenElements as $key => $value) {\n            $orig = $flattenOriginal[$key] ?? null;\n            if ($orig !== $value) {\n                $diff[$key] = ['old' => $orig, 'new' => $value];\n            }\n        }\n\n        // Include all removed keys.\n        foreach ($removedElements as $key => $value) {\n            $diff[$key] = ['old' => $value, 'new' => null];\n        }\n\n        return $diff;\n    }\n\n    /**\n     * Get any changes from the object.\n     *\n     * @return array\n     */\n    public function getChanges(): array\n    {\n        $diff = $this->getDiff();\n\n        $data = new Data();\n        foreach ($diff as $key => $change) {\n            $data->set($key, $change['new']);\n        }\n\n        return $data->toArray();\n    }\n\n    /**\n     * @return string\n     */\n    protected function getTypePrefix(): string\n    {\n        return 'o.';\n    }\n\n    /**\n     * Alias of getBlueprint()\n     *\n     * @return Blueprint\n     * @deprecated 1.6 Admin compatibility\n     */\n    public function blueprints()\n    {\n        return $this->getBlueprint();\n    }\n\n    /**\n     * @param string|null $namespace\n     * @return CacheInterface\n     */\n    public function getCache(string $namespace = null)\n    {\n        return $this->_flexDirectory->getCache($namespace);\n    }\n\n    /**\n     * @param string|null $key\n     * @return $this\n     */\n    public function setStorageKey($key = null)\n    {\n        $this->storage_key = $key ?? '';\n\n        return $this;\n    }\n\n    /**\n     * @param int $timestamp\n     * @return $this\n     */\n    public function setTimestamp($timestamp = null)\n    {\n        $this->storage_timestamp = $timestamp ?? time();\n\n        return $this;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::render()\n     */\n    public function render(string $layout = null, array $context = [])\n    {\n        if (!$layout) {\n            $config = $this->getTemplateConfig();\n            $layout = $config['object']['defaults']['layout'] ?? 'default';\n        }\n\n        $type = $this->getFlexType();\n\n        $grav = Grav::instance();\n\n        /** @var Debugger $debugger */\n        $debugger = $grav['debugger'];\n        $debugger->startTimer('flex-object-' . ($debugKey =  uniqid($type, false)), 'Render Object ' . $type . ' (' . $layout . ')');\n\n        $key = $this->getCacheKey();\n\n        // Disable caching if context isn't all scalars.\n        if ($key) {\n            foreach ($context as $value) {\n                if (!is_scalar($value)) {\n                    $key = '';\n                    break;\n                }\n            }\n        }\n\n        if ($key) {\n            // Create a new key which includes layout and context.\n            $key = md5($key . '.' . $layout . json_encode($context));\n            $cache = $this->getCache('render');\n        } else {\n            $cache = null;\n        }\n\n        try {\n            $data = $cache ? $cache->get($key) : null;\n\n            $block = $data ? HtmlBlock::fromArray($data) : null;\n        } catch (InvalidArgumentException $e) {\n            $debugger->addException($e);\n\n            $block = null;\n        } catch (\\InvalidArgumentException $e) {\n            $debugger->addException($e);\n\n            $block = null;\n        }\n\n        $checksum = $this->getCacheChecksum();\n        if ($block && $checksum !== $block->getChecksum()) {\n            $block = null;\n        }\n\n        if (!$block) {\n            $block = HtmlBlock::create($key ?: null);\n            $block->setChecksum($checksum);\n            if (!$cache) {\n                $block->disableCache();\n            }\n\n            $event = new Event([\n                'type' => 'flex',\n                'directory' => $this->getFlexDirectory(),\n                'object' => $this,\n                'layout' => &$layout,\n                'context' => &$context\n            ]);\n            $this->triggerEvent('onRender', $event);\n\n            $output = $this->getTemplate($layout)->render(\n                [\n                    'grav' => $grav,\n                    'config' => $grav['config'],\n                    'block' => $block,\n                    'directory' => $this->getFlexDirectory(),\n                    'object' => $this,\n                    'layout' => $layout\n                ] + $context\n            );\n\n            if ($debugger->enabled() &&\n                !($grav['uri']->getContentType() === 'application/json' || $grav['uri']->extension() === 'json')) {\n                $name = $this->getKey() . ' (' . $type . ')';\n                $output = \"\\n<!–– START {$name} object ––>\\n{$output}\\n<!–– END {$name} object ––>\\n\";\n            }\n\n            $block->setContent($output);\n\n            try {\n                $cache && $block->isCached() && $cache->set($key, $block->toArray());\n            } catch (InvalidArgumentException $e) {\n                $debugger->addException($e);\n            }\n        }\n\n        $debugger->stopTimer('flex-object-' . $debugKey);\n\n        return $block;\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->getElements();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::prepareStorage()\n     */\n    public function prepareStorage(): array\n    {\n        return ['__META' => $this->getMetaData()] + $this->getElements();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::update()\n     */\n    public function update(array $data, array $files = [])\n    {\n        if ($data) {\n            // Get currently stored data.\n            $elements = $this->getElements();\n\n            // Store original version of the object.\n            if ($this->_original === null) {\n                $this->_original = $elements;\n            }\n\n            $blueprint = $this->getBlueprint();\n\n            // Process updated data through the object filters.\n            $this->filterElements($data);\n\n            // Merge existing object to the test data to be validated.\n            $test = $blueprint->mergeData($elements, $data);\n\n            // Validate and filter elements and throw an error if any issues were found.\n            $blueprint->validate($test + ['storage_key' => $this->getStorageKey(), 'timestamp' => $this->getTimestamp()], ['xss_check' => false]);\n            $data = $blueprint->filter($data, true, true);\n\n            // Finally update the object.\n            $flattenData = $blueprint->flattenData($data);\n            foreach ($flattenData as $key => $value) {\n                if ($value === null) {\n                    $this->unsetNestedProperty($key);\n                } else {\n                    $this->setNestedProperty($key, $value);\n                }\n            }\n        }\n\n        if ($files && method_exists($this, 'setUpdatedMedia')) {\n            $this->setUpdatedMedia($files);\n        }\n\n        return $this;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::create()\n     */\n    public function create(string $key = null)\n    {\n        if ($key) {\n            $this->setStorageKey($key);\n        }\n\n        if ($this->exists()) {\n            throw new RuntimeException('Cannot create new object (Already exists)');\n        }\n\n        return $this->save();\n    }\n\n    /**\n     * @param string|null $key\n     * @return FlexObject|FlexObjectInterface\n     */\n    public function createCopy(string $key = null)\n    {\n        $this->markAsCopy();\n\n        return $this->create($key);\n    }\n\n    /**\n     * @param UserInterface|null $user\n     */\n    public function check(UserInterface $user = null): void\n    {\n        // If user has been provided, check if the user has permissions to save this object.\n        if ($user && !$this->isAuthorized('save', null, $user)) {\n            throw new \\RuntimeException('Forbidden', 403);\n        }\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::save()\n     */\n    public function save()\n    {\n        $this->triggerEvent('onBeforeSave');\n\n        $storage = $this->getFlexDirectory()->getStorage();\n\n        $storageKey = $this->getStorageKey() ?:  '@@' . spl_object_hash($this);\n\n        $result = $storage->replaceRows([$storageKey => $this->prepareStorage()]);\n\n        if (method_exists($this, 'clearMediaCache')) {\n            $this->clearMediaCache();\n        }\n\n        $value = reset($result);\n        $meta = $value['__META'] ?? null;\n        if ($meta) {\n            /** @phpstan-var class-string $indexClass */\n            $indexClass = $this->getFlexDirectory()->getIndexClass();\n            $indexClass::updateObjectMeta($meta, $value, $storage);\n            $this->_meta = $meta;\n        }\n\n        if ($value) {\n            $storageKey = $meta['storage_key'] ?? (string)key($result);\n            if ($storageKey !== '') {\n                $this->setStorageKey($storageKey);\n            }\n\n            $newKey = $meta['key'] ?? ($this->hasKey() ? $this->getKey() : null);\n            $this->setKey($newKey ?? $storageKey);\n        }\n\n        // FIXME: For some reason locator caching isn't cleared for the file, investigate!\n        $locator = Grav::instance()['locator'];\n        $locator->clearCache();\n\n        if (method_exists($this, 'saveUpdatedMedia')) {\n            $this->saveUpdatedMedia();\n        }\n\n        try {\n            $this->getFlexDirectory()->reloadIndex();\n            if (method_exists($this, 'clearMediaCache')) {\n                $this->clearMediaCache();\n            }\n        } catch (Exception $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            // Caching failed, but we can ignore that for now.\n        }\n\n        $this->triggerEvent('onAfterSave');\n\n        return $this;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::delete()\n     */\n    public function delete()\n    {\n        if (!$this->exists()) {\n            return $this;\n        }\n\n        $this->triggerEvent('onBeforeDelete');\n\n        $this->getFlexDirectory()->getStorage()->deleteRows([$this->getStorageKey() => $this->prepareStorage()]);\n\n        try {\n            $this->getFlexDirectory()->reloadIndex();\n            if (method_exists($this, 'clearMediaCache')) {\n                $this->clearMediaCache();\n            }\n        } catch (Exception $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            // Caching failed, but we can ignore that for now.\n        }\n\n        $this->triggerEvent('onAfterDelete');\n\n        return $this;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getBlueprint()\n     */\n    public function getBlueprint(string $name = '')\n    {\n        if (!isset($this->_blueprint[$name])) {\n            $blueprint = $this->doGetBlueprint($name);\n            $blueprint->setScope('object');\n            $blueprint->setObject($this);\n\n            $this->_blueprint[$name] = $blueprint->init();\n        }\n\n        return $this->_blueprint[$name];\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getForm()\n     */\n    public function getForm(string $name = '', array $options = null)\n    {\n        $hash = $name . '-' . md5(json_encode($options, JSON_THROW_ON_ERROR));\n        if (!isset($this->_forms[$hash])) {\n            $this->_forms[$hash] = $this->createFormObject($name, $options);\n        }\n\n        return $this->_forms[$hash];\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getDefaultValue()\n     */\n    public function getDefaultValue(string $name, string $separator = null)\n    {\n        $separator = $separator ?: '.';\n        $path = explode($separator, $name);\n        $offset = array_shift($path);\n\n        $current = $this->getDefaultValues();\n\n        if (!isset($current[$offset])) {\n            return null;\n        }\n\n        $current = $current[$offset];\n\n        while ($path) {\n            $offset = array_shift($path);\n\n            if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) {\n                $current = $current[$offset];\n            } elseif (is_object($current) && isset($current->{$offset})) {\n                $current = $current->{$offset};\n            } else {\n                return null;\n            }\n        };\n\n        return $current;\n    }\n\n    /**\n     * @return array\n     */\n    public function getDefaultValues(): array\n    {\n        return $this->getBlueprint()->getDefaults();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getFormValue()\n     */\n    public function getFormValue(string $name, $default = null, string $separator = null)\n    {\n        if ($name === 'storage_key') {\n            return $this->getStorageKey();\n        }\n        if ($name === 'storage_timestamp') {\n            return $this->getTimestamp();\n        }\n\n        return $this->getNestedProperty($name, $default, $separator);\n    }\n\n    /**\n     * @param FlexDirectory $directory\n     */\n    public function setFlexDirectory(FlexDirectory $directory): void\n    {\n        $this->_flexDirectory = $directory;\n    }\n\n    /**\n     * Returns a string representation of this object.\n     *\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return $this->getFlexKey();\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function __debugInfo()\n    {\n        return [\n            'type:private' => $this->getFlexType(),\n            'storage_key:protected' => $this->getStorageKey(),\n            'storage_timestamp:protected' => $this->getTimestamp(),\n            'key:private' => $this->getKey(),\n            'elements:private' => $this->getElements(),\n            'storage:private' => $this->getMetaData()\n        ];\n    }\n\n    /**\n     * Clone object.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __clone()\n    {\n        // Allows future compatibility as parent::__clone() works.\n    }\n\n    protected function markAsCopy(): void\n    {\n        $meta = $this->getMetaData();\n        $meta['copy'] = true;\n        $this->_meta = $meta;\n    }\n\n    /**\n     * @param string $name\n     * @return Blueprint\n     */\n    protected function doGetBlueprint(string $name = ''): Blueprint\n    {\n        return $this->_flexDirectory->getBlueprint($name ? '.' . $name : $name);\n    }\n\n    /**\n     * @param array $meta\n     */\n    protected function setMetaData(array $meta): void\n    {\n        $this->_meta = $meta;\n    }\n\n    /**\n     * @return array\n     */\n    protected function doSerialize(): array\n    {\n        return [\n            'type' => $this->getFlexType(),\n            'key' => $this->getKey(),\n            'elements' => $this->getElements(),\n            'storage' => $this->getMetaData()\n        ];\n    }\n\n    /**\n     * @param array $serialized\n     * @param FlexDirectory|null $directory\n     * @return void\n     */\n    protected function doUnserialize(array $serialized, FlexDirectory $directory = null): void\n    {\n        $type = $serialized['type'] ?? 'unknown';\n\n        if (!isset($serialized['key'], $serialized['type'], $serialized['elements'])) {\n            throw new \\InvalidArgumentException(\"Cannot unserialize '{$type}': Bad data\");\n        }\n\n        if (null === $directory) {\n            $directory = $this->getFlexContainer()->getDirectory($type);\n            if (!$directory) {\n                throw new \\InvalidArgumentException(\"Cannot unserialize Flex type '{$type}': Directory not found\");\n            }\n        }\n\n        $this->setFlexDirectory($directory);\n        $this->setMetaData($serialized['storage']);\n        $this->setKey($serialized['key']);\n        $this->setElements($serialized['elements']);\n    }\n\n    /**\n     * @return array\n     */\n    protected function getTemplateConfig()\n    {\n        $config = $this->getFlexDirectory()->getConfig('site.templates', []);\n        $defaults = array_replace($config['defaults'] ?? [], $config['object']['defaults'] ?? []);\n        $config['object']['defaults'] = $defaults;\n\n        return $config;\n    }\n\n    /**\n     * @param string $layout\n     * @return array\n     */\n    protected function getTemplatePaths(string $layout): array\n    {\n        $config = $this->getTemplateConfig();\n        $type = $this->getFlexType();\n        $defaults = $config['object']['defaults'] ?? [];\n\n        $ext = $defaults['ext'] ?? '.html.twig';\n        $types = array_unique(array_merge([$type], (array)($defaults['type'] ?? null)));\n        $paths = $config['object']['paths'] ?? [\n                'flex/{TYPE}/object/{LAYOUT}{EXT}',\n                'flex-objects/layouts/{TYPE}/object/{LAYOUT}{EXT}'\n            ];\n        $table = ['TYPE' => '%1$s', 'LAYOUT' => '%2$s', 'EXT' => '%3$s'];\n\n        $lookups = [];\n        foreach ($paths as $path) {\n            $path = Utils::simpleTemplate($path, $table);\n            foreach ($types as $type) {\n                $lookups[] = sprintf($path, $type, $layout, $ext);\n            }\n        }\n\n        return array_unique($lookups);\n    }\n\n    /**\n     * Filter data coming to constructor or $this->update() request.\n     *\n     * NOTE: The incoming data can be an arbitrary array so do not assume anything from its content.\n     *\n     * @param array $elements\n     */\n    protected function filterElements(array &$elements): void\n    {\n        if (isset($elements['storage_key'])) {\n            $elements['storage_key'] = trim($elements['storage_key']);\n        }\n        if (isset($elements['storage_timestamp'])) {\n            $elements['storage_timestamp'] = (int)$elements['storage_timestamp'];\n        }\n\n        unset($elements['_post_entries_save']);\n    }\n\n    /**\n     * This methods allows you to override form objects in child classes.\n     *\n     * @param string $name Form name\n     * @param array|null $options Form optiosn\n     * @return FlexFormInterface\n     */\n    protected function createFormObject(string $name, array $options = null)\n    {\n        return new FlexForm($name, $this, $options);\n    }\n\n    /**\n     * @param string $action\n     * @return string\n     */\n    protected function getAuthorizeAction(string $action): string\n    {\n        // Handle special action save, which can mean either update or create.\n        if ($action === 'save') {\n            $action = $this->exists() ? 'update' : 'create';\n        }\n\n        return $action;\n    }\n\n    /**\n     * Method to reset blueprints if the type changes.\n     *\n     * @return void\n     * @since 1.7.18\n     */\n    protected function resetBlueprints(): void\n    {\n        $this->_blueprint = [];\n    }\n\n    // DEPRECATED METHODS\n\n    /**\n     * @param bool $prefix\n     * @return string\n     * @deprecated 1.6 Use `->getFlexType()` instead.\n     */\n    public function getType($prefix = false)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED);\n\n        $type = $prefix ? $this->getTypePrefix() : '';\n\n        return $type . $this->getFlexType();\n    }\n\n    /**\n     * @param string $name\n     * @param mixed|null $default\n     * @param string|null $separator\n     * @return mixed\n     *\n     * @deprecated 1.6 Use ->getFormValue() method instead.\n     */\n    public function value($name, $default = null, $separator = null)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.6, use ->getFormValue() method instead', E_USER_DEPRECATED);\n\n        return $this->getFormValue($name, $default, $separator);\n    }\n\n    /**\n     * @param string $name\n     * @param object|null $event\n     * @return $this\n     * @deprecated 1.7 Moved to \\Grav\\Common\\Flex\\Traits\\FlexObjectTrait\n     */\n    public function triggerEvent(string $name, $event = null)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \\Grav\\Common\\Flex\\Traits\\FlexObjectTrait', E_USER_DEPRECATED);\n\n        if (null === $event) {\n            $event = new Event([\n                'type' => 'flex',\n                'directory' => $this->getFlexDirectory(),\n                'object' => $this\n            ]);\n        }\n        if (strpos($name, 'onFlexObject') !== 0 && strpos($name, 'on') === 0) {\n            $name = 'onFlexObject' . substr($name, 2);\n        }\n\n        $grav = Grav::instance();\n        if ($event instanceof Event) {\n            $grav->fireEvent($name, $event);\n        } else {\n            $grav->dispatchEvent($event);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param array $storage\n     * @deprecated 1.7 Use `->setMetaData()` instead.\n     */\n    protected function setStorage(array $storage): void\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->setMetaData() method instead', E_USER_DEPRECATED);\n\n        $this->setMetaData($storage);\n    }\n\n    /**\n     * @return array\n     * @deprecated 1.7 Use `->getMetaData()` instead.\n     */\n    protected function getStorage(): array\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->getMetaData() method instead', E_USER_DEPRECATED);\n\n        return $this->getMetaData();\n    }\n\n    /**\n     * @param string $layout\n     * @return Template|TemplateWrapper\n     * @throws LoaderError\n     * @throws SyntaxError\n     * @deprecated 1.7 Moved to \\Grav\\Common\\Flex\\Traits\\GravTrait\n     */\n    protected function getTemplate($layout)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \\Grav\\Common\\Flex\\Traits\\GravTrait', E_USER_DEPRECATED);\n\n        $grav = Grav::instance();\n\n        /** @var Twig $twig */\n        $twig = $grav['twig'];\n\n        try {\n            return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout));\n        } catch (LoaderError $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            return $twig->twig()->resolveTemplate(['flex/404.html.twig']);\n        }\n    }\n\n    /**\n     * @return Flex\n     * @deprecated 1.7 Moved to \\Grav\\Common\\Flex\\Traits\\GravTrait\n     */\n    protected function getFlexContainer(): Flex\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \\Grav\\Common\\Flex\\Traits\\GravTrait', E_USER_DEPRECATED);\n\n        /** @var Flex $flex */\n        $flex = Grav::instance()['flex'];\n\n        return $flex;\n    }\n\n    /**\n     * @return UserInterface|null\n     * @deprecated 1.7 Moved to \\Grav\\Common\\Flex\\Traits\\GravTrait\n     */\n    protected function getActiveUser(): ?UserInterface\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \\Grav\\Common\\Flex\\Traits\\GravTrait', E_USER_DEPRECATED);\n\n        /** @var UserInterface|null $user */\n        $user = Grav::instance()['user'] ?? null;\n\n        return $user;\n    }\n\n    /**\n     * @return string\n     * @deprecated 1.7 Moved to \\Grav\\Common\\Flex\\Traits\\GravTrait\n     */\n    protected function getAuthorizeScope(): string\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \\Grav\\Common\\Flex\\Traits\\GravTrait', E_USER_DEPRECATED);\n\n        return isset(Grav::instance()['admin']) ? 'admin' : 'site';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexAuthorizeInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\n\n/**\n * Defines authorization checks for Flex Objects.\n */\ninterface FlexAuthorizeInterface\n{\n    /**\n     * Check if user is authorized for the action.\n     *\n     * Note: There are two deny values: denied (false), not set (null). This allows chaining multiple rules together\n     * when the previous rules were not matched.\n     *\n     * @param string $action\n     * @param string|null $scope\n     * @param UserInterface|null $user\n     * @return bool|null\n     */\n    public function isAuthorized(string $action, string $scope = null, UserInterface $user = null): ?bool;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexCollectionInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\nuse Grav\\Framework\\Flex\\Flex;\nuse Grav\\Framework\\Object\\Interfaces\\NestedObjectInterface;\nuse Grav\\Framework\\Object\\Interfaces\\ObjectCollectionInterface;\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse InvalidArgumentException;\n\n/**\n * Defines a collection of Flex Objects.\n *\n * @used-by \\Grav\\Framework\\Flex\\FlexCollection\n * @since 1.6\n * @template T\n * @extends ObjectCollectionInterface<string,T>\n */\ninterface FlexCollectionInterface extends FlexCommonInterface, ObjectCollectionInterface, NestedObjectInterface\n{\n    /**\n     * Creates a Flex Collection from an array.\n     *\n     * @used-by FlexDirectory::createCollection()   Official method to create a Flex Collection.\n     *\n     * @param FlexObjectInterface[] $entries    Associated array of Flex Objects to be included in the collection.\n     * @param FlexDirectory         $directory  Flex Directory where all the objects belong into.\n     * @param string|null               $keyField   Key field used to index the collection.\n     * @return static                           Returns a new Flex Collection.\n     */\n    public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null);\n\n    /**\n     * Creates a new Flex Collection.\n     *\n     * @used-by FlexDirectory::createCollection()   Official method to create Flex Collection.\n     *\n     * @param FlexObjectInterface[] $entries    Associated array of Flex Objects to be included in the collection.\n     * @param FlexDirectory|null        $directory  Flex Directory where all the objects belong into.\n     * @throws InvalidArgumentException\n     */\n    public function __construct(array $entries = [], FlexDirectory $directory = null);\n\n    /**\n     * Search a string from the collection.\n     *\n     * @param string                $search     Search string.\n     * @param string|string[]|null  $properties Properties to search for, defaults to configured properties.\n     * @param array|null            $options    Search options, defaults to configured options.\n     * @return FlexCollectionInterface          Returns a Flex Collection with only matching objects.\n     * @phpstan-return static<T>\n     * @api\n     */\n    public function search(string $search, $properties = null, array $options = null);\n\n    /**\n     * Sort the collection.\n     *\n     * @param array $orderings Pair of [property => 'ASC'|'DESC', ...].\n     *\n     * @return FlexCollectionInterface Returns a sorted version from the collection.\n     * @phpstan-return static<T>\n     */\n    public function sort(array $orderings);\n\n    /**\n     * Filter collection by filter array with keys and values.\n     *\n     * @param array $filters\n     * @return FlexCollectionInterface\n     * @phpstan-return static<T>\n     */\n    public function filterBy(array $filters);\n\n    /**\n     * Get timestamps from all the objects in the collection.\n     *\n     * This method can be used for example in caching.\n     *\n     * @return int[] Returns [key => timestamp, ...] pairs.\n     */\n    public function getTimestamps(): array;\n\n    /**\n     * Get storage keys from all the objects in the collection.\n     *\n     * @see FlexDirectory::getObject()  If you want to get Flex Object from the Flex Directory.\n     *\n     * @return string[] Returns [key => storage_key, ...] pairs.\n     */\n    public function getStorageKeys(): array;\n\n    /**\n     * Get Flex keys from all the objects in the collection.\n     *\n     * @see Flex::getObjects()  If you want to get list of Flex Objects from any Flex Directory.\n     *\n     * @return string[] Returns[key => flex_key, ...] pairs.\n     */\n    public function getFlexKeys(): array;\n\n    /**\n     * Return new collection with a different key.\n     *\n     * @param string|null $keyField Switch key field of the collection.\n     * @return FlexCollectionInterface  Returns a new Flex Collection with new key field.\n     * @phpstan-return static<T>\n     * @api\n     */\n    public function withKeyField(string $keyField = null);\n\n    /**\n     * Get Flex Index from the Flex Collection.\n     *\n     * @return FlexIndexInterface   Returns a Flex Index from the current collection.\n     * @phpstan-return FlexIndexInterface<T>\n     */\n    public function getIndex();\n\n    /**\n     * Load all the objects into memory,\n     *\n     * @return FlexCollectionInterface\n     * @phpstan-return static<T>\n     */\n    public function getCollection();\n\n    /**\n     * Get metadata associated to the object\n     *\n     * @param string $key Key.\n     * @return array\n     */\n    public function getMetaData($key): array;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexCommonInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse Grav\\Framework\\Interfaces\\RenderInterface;\n\n/**\n * Defines common interface shared with both Flex Objects and Collections.\n *\n * @used-by \\Grav\\Framework\\Flex\\FlexObject\n * @since 1.6\n */\ninterface FlexCommonInterface extends RenderInterface\n{\n    /**\n     * Get Flex Type of the object / collection.\n     *\n     * @return string Returns Flex Type of the collection.\n     * @api\n     */\n    public function getFlexType(): string;\n\n    /**\n     * Get Flex Directory for the object / collection.\n     *\n     * @return FlexDirectory    Returns associated Flex Directory.\n     * @api\n     */\n    public function getFlexDirectory(): FlexDirectory;\n\n    /**\n     * Test whether the feature is implemented in the object / collection.\n     *\n     * @param string $name\n     * @return bool\n     */\n    public function hasFlexFeature(string $name): bool;\n\n    /**\n     * Get full list of features the object / collection implements.\n     *\n     * @return array\n     */\n    public function getFlexFeatures(): array;\n\n    /**\n     * Get last updated timestamp for the object / collection.\n     *\n     * @return int Returns Unix timestamp.\n     * @api\n     */\n    public function getTimestamp(): int;\n\n    /**\n     * Get a cache key which is used for caching the object / collection.\n     *\n     * @return string Returns cache key.\n     */\n    public function getCacheKey(): string;\n\n    /**\n     * Get cache checksum for the object / collection.\n     *\n     * If checksum changes, cache gets invalided.\n     *\n     * @return string Returns cache checksum.\n     */\n    public function getCacheChecksum(): string;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryFormInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\n/**\n * Defines Forms for Flex Objects.\n *\n * @used-by \\Grav\\Framework\\Flex\\FlexForm\n * @since 1.7\n */\ninterface FlexDirectoryFormInterface extends FlexFormInterface\n{\n    /**\n     * Get object associated to the form.\n     *\n     * @return FlexObjectInterface  Returns Flex Object associated to the form.\n     * @api\n     */\n    public function getDirectory();\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexDirectoryInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\nuse Exception;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Framework\\Cache\\CacheInterface;\n\n/**\n * Interface FlexDirectoryInterface\n * @package Grav\\Framework\\Flex\\Interfaces\n */\ninterface FlexDirectoryInterface extends FlexAuthorizeInterface\n{\n    /**\n     * @return bool\n     */\n    public function isListed(): bool;\n\n    /**\n     * @return bool\n     */\n    public function isEnabled(): bool;\n\n    /**\n     * @return string\n     */\n    public function getFlexType(): string;\n\n    /**\n     * @return string\n     */\n    public function getTitle(): string;\n\n    /**\n     * @return string\n     */\n    public function getDescription(): string;\n\n    /**\n     * @param string|null $name\n     * @param mixed $default\n     * @return mixed\n     */\n    public function getConfig(string $name = null, $default = null);\n\n    /**\n     * @param string|null $name\n     * @param array $options\n     * @return FlexFormInterface\n     * @internal\n     */\n    public function getDirectoryForm(string $name = null, array $options = []);\n\n    /**\n     * @return Blueprint\n     * @internal\n     */\n    public function getDirectoryBlueprint();\n\n    /**\n     * @param string $name\n     * @param array $data\n     * @return void\n     * @throws Exception\n     * @internal\n     */\n    public function saveDirectoryConfig(string $name, array $data);\n\n    /**\n     * @param string|null $name\n     * @return string\n     */\n    public function getDirectoryConfigUri(string $name = null): string;\n\n    /**\n     * Returns a new uninitialized instance of blueprint.\n     *\n     * Always use $object->getBlueprint() or $object->getForm()->getBlueprint() instead.\n     *\n     * @param string $type\n     * @param string $context\n     * @return Blueprint\n     */\n    public function getBlueprint(string $type = '', string $context = '');\n\n    /**\n     * @param string $view\n     * @return string\n     */\n    public function getBlueprintFile(string $view = ''): string;\n\n    /**\n     * Get collection. In the site this will be filtered by the default filters (published etc).\n     *\n     * Use $directory->getIndex() if you want unfiltered collection.\n     *\n     * @param array|null $keys  Array of keys.\n     * @param string|null $keyField  Field to be used as the key.\n     * @return FlexCollectionInterface\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>\n     */\n    public function getCollection(array $keys = null, string $keyField = null): FlexCollectionInterface;\n\n    /**\n     * Get the full collection of all stored objects.\n     *\n     * Use $directory->getCollection() if you want a filtered collection.\n     *\n     * @param array|null $keys  Array of keys.\n     * @param string|null $keyField  Field to be used as the key.\n     * @return FlexIndexInterface\n     * @phpstan-return FlexIndexInterface<FlexObjectInterface>\n     */\n    public function getIndex(array $keys = null, string $keyField = null): FlexIndexInterface;\n\n    /**\n     * Returns an object if it exists. If no arguments are passed (or both of them are null), method creates a new empty object.\n     *\n     * Note: It is not safe to use the object without checking if the user can access it.\n     *\n     * @param string|null $key\n     * @param string|null $keyField  Field to be used as the key.\n     * @return FlexObjectInterface|null\n     */\n    public function getObject($key = null, string $keyField = null): ?FlexObjectInterface;\n\n    /**\n     * @param string|null $namespace\n     * @return CacheInterface\n     */\n    public function getCache(string $namespace = null);\n\n    /**\n     * @return $this\n     */\n    public function clearCache();\n\n    /**\n     * @param string|null $key\n     * @return string|null\n     */\n    public function getStorageFolder(string $key = null): ?string;\n\n    /**\n     * @param string|null $key\n     * @return string|null\n     */\n    public function getMediaFolder(string $key = null): ?string;\n\n    /**\n     * @return FlexStorageInterface\n     */\n    public function getStorage(): FlexStorageInterface;\n\n    /**\n     * @param array $data\n     * @param string $key\n     * @param bool $validate\n     * @return FlexObjectInterface\n     */\n    public function createObject(array $data, string $key = '', bool $validate = false): FlexObjectInterface;\n\n    /**\n     * @param array $entries\n     * @param string|null $keyField\n     * @return FlexCollectionInterface\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>\n     */\n    public function createCollection(array $entries, string $keyField = null): FlexCollectionInterface;\n\n    /**\n     * @param array $entries\n     * @param string|null $keyField\n     * @return FlexIndexInterface\n     * @phpstan-return FlexIndexInterface<FlexObjectInterface>\n     */\n    public function createIndex(array $entries, string $keyField = null): FlexIndexInterface;\n\n    /**\n     * @return string\n     */\n    public function getObjectClass(): string;\n\n    /**\n     * @return string\n     */\n    public function getCollectionClass(): string;\n\n    /**\n     * @return string\n     */\n    public function getIndexClass(): string;\n\n    /**\n     * @param array $entries\n     * @param string|null $keyField\n     * @return FlexCollectionInterface\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>\n     */\n    public function loadCollection(array $entries, string $keyField = null): FlexCollectionInterface;\n\n    /**\n     * @param array $entries\n     * @return FlexObjectInterface[]\n     * @internal\n     */\n    public function loadObjects(array $entries): array;\n\n    /**\n     * @return void\n     */\n    public function reloadIndex(): void;\n\n    /**\n     * @param string $scope\n     * @param string $action\n     * @return string\n     */\n    public function getAuthorizeRule(string $scope, string $action): string;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexFormInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\nuse Grav\\Framework\\Form\\Interfaces\\FormInterface;\nuse Grav\\Framework\\Route\\Route;\nuse Serializable;\n\n/**\n * Defines Forms for Flex Objects.\n *\n * @used-by \\Grav\\Framework\\Flex\\FlexForm\n * @since 1.6\n */\ninterface FlexFormInterface extends Serializable, FormInterface\n{\n    /**\n     * Get media task route.\n     *\n     * @return string   Returns admin route for media tasks.\n     */\n    public function getMediaTaskRoute(): string;\n\n    /**\n     * Get route for uploading files by AJAX.\n     *\n     * @return Route|null       Returns Route object or null if file uploads are not enabled.\n     */\n    public function getFileUploadAjaxRoute();\n\n    /**\n     * Get route for deleting files by AJAX.\n     *\n     * @param string|null $field     Field where the file is associated into.\n     * @param string|null $filename  Filename for the file.\n     * @return Route|null       Returns Route object or null if file uploads are not enabled.\n     */\n    public function getFileDeleteAjaxRoute($field, $filename);\n\n//    /**\n//     * @return FlexObjectInterface\n//     */\n//    public function getObject();\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexIndexInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\nuse Grav\\Framework\\Flex\\FlexDirectory;\n\n/**\n * Defines Indexes for Flex Objects.\n *\n * Flex indexes are similar to database indexes, they contain indexed fields which can be used to quickly look up or\n * find the objects without loading them.\n *\n * @used-by \\Grav\\Framework\\Flex\\FlexIndex\n * @since 1.6\n * @template T\n * @extends FlexCollectionInterface<T>\n */\ninterface FlexIndexInterface extends FlexCollectionInterface\n{\n    /**\n     * Helper method to create Flex Index.\n     *\n     * @used-by FlexDirectory::getIndex()   Official method to get Index from a Flex Directory.\n     *\n     * @param FlexDirectory $directory Flex directory.\n     * @return static Returns a new Flex Index.\n     */\n    public static function createFromStorage(FlexDirectory $directory);\n\n    /**\n     * Method to load index from the object storage, usually filesystem.\n     *\n     * @used-by FlexDirectory::getIndex()   Official method to get Index from a Flex Directory.\n     *\n     * @param FlexStorageInterface $storage Flex Storage associated to the directory.\n     * @return array Returns a list of existing objects [storage_key => [storage_key => xxx, storage_timestamp => 123456, ...]]\n     */\n    public static function loadEntriesFromStorage(FlexStorageInterface $storage): array;\n\n    /**\n     * Return new collection with a different key.\n     *\n     * @param string|null $keyField Switch key field of the collection.\n     * @return static  Returns a new Flex Collection with new key field.\n     * @phpstan-return static<T>\n     * @api\n     */\n    public function withKeyField(string $keyField = null);\n\n    /**\n     * @param string|null $indexKey\n     * @return array\n     */\n    public function getIndexMap(string $indexKey = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\nuse Countable;\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse RuntimeException;\n\n/**\n * Interface FlexInterface\n * @package Grav\\Framework\\Flex\\Interfaces\n */\ninterface FlexInterface extends Countable\n{\n    /**\n     * @param string $type\n     * @param string $blueprint\n     * @param array  $config\n     * @return $this\n     */\n    public function addDirectoryType(string $type, string $blueprint, array $config = []);\n\n    /**\n     * @param FlexDirectory $directory\n     * @return $this\n     */\n    public function addDirectory(FlexDirectory $directory);\n\n    /**\n     * @param string $type\n     * @return bool\n     */\n    public function hasDirectory(string $type): bool;\n\n    /**\n     * @param array|string[]|null $types\n     * @param bool $keepMissing\n     * @return array<FlexDirectory|null>\n     */\n    public function getDirectories(array $types = null, bool $keepMissing = false): array;\n\n    /**\n     * @param string $type\n     * @return FlexDirectory|null\n     */\n    public function getDirectory(string $type): ?FlexDirectory;\n\n    /**\n     * @param string $type\n     * @param array|null $keys\n     * @param string|null $keyField\n     * @return FlexCollectionInterface|null\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>|null\n     */\n    public function getCollection(string $type, array $keys = null, string $keyField = null): ?FlexCollectionInterface;\n\n    /**\n     * @param array $keys\n     * @param array $options            In addition to the options in getObjects(), following options can be passed:\n     *                                  collection_class:   Class to be used to create the collection. Defaults to ObjectCollection.\n     * @return FlexCollectionInterface\n     * @throws RuntimeException\n     * @phpstan-return FlexCollectionInterface<FlexObjectInterface>\n     */\n    public function getMixedCollection(array $keys, array $options = []): FlexCollectionInterface;\n\n    /**\n     * @param array $keys\n     * @param array $options    Following optional options can be passed:\n     *                          types:          List of allowed types.\n     *                          type:           Allowed type if types isn't defined, otherwise acts as default_type.\n     *                          default_type:   Set default type for objects given without type (only used if key_field isn't set).\n     *                          keep_missing:   Set to true if you want to return missing objects as null.\n     *                          key_field:      Key field which is used to match the objects.\n     * @return array\n     */\n    public function getObjects(array $keys, array $options = []): array;\n\n    /**\n     * @param string $key\n     * @param string|null $type\n     * @param string|null $keyField\n     * @return FlexObjectInterface|null\n     */\n    public function getObject(string $key, string $type = null, string $keyField = null): ?FlexObjectInterface;\n\n    /**\n     * @return int\n     */\n    public function count(): int;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexObjectFormInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\n/**\n * Defines Forms for Flex Objects.\n *\n * @used-by \\Grav\\Framework\\Flex\\FlexForm\n * @since 1.7\n */\ninterface FlexObjectFormInterface extends FlexFormInterface\n{\n    /**\n     * Get object associated to the form.\n     *\n     * @return FlexObjectInterface  Returns Flex Object associated to the form.\n     * @api\n     */\n    public function getObject();\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexObjectInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\nuse ArrayAccess;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Framework\\Flex\\Flex;\nuse Grav\\Framework\\Object\\Interfaces\\NestedObjectInterface;\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse InvalidArgumentException;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse RuntimeException;\n\n/**\n * Defines Flex Objects.\n *\n * @extends ArrayAccess<string,mixed>\n * @used-by \\Grav\\Framework\\Flex\\FlexObject\n * @since 1.6\n */\ninterface FlexObjectInterface extends FlexCommonInterface, NestedObjectInterface, ArrayAccess\n{\n    /**\n     * Construct a new Flex Object instance.\n     *\n     * @used-by FlexDirectory::createObject()   Method to create Flex Object.\n     *\n     * @param array $elements Array of object properties.\n     * @param string $key Identifier key for the new object.\n     * @param FlexDirectory $directory Flex Directory the object belongs into.\n     * @param bool $validate True if the object should be validated against blueprint.\n     * @throws InvalidArgumentException\n     */\n    public function __construct(array $elements, $key, FlexDirectory $directory, bool $validate = false);\n\n    /**\n     * Search a string from the object, returns weight between 0 and 1.\n     *\n     * Note: If you override this function, make sure you return value in range 0...1!\n     *\n     * @used-by FlexCollectionInterface::search()   If you want to search a string from a Flex Collection.\n     *\n     * @param string                $search     Search string.\n     * @param string|string[]|null  $properties Properties to search for, defaults to configured properties.\n     * @param array|null            $options    Search options, defaults to configured options.\n     * @return float                Returns a weight between 0 and 1.\n     * @api\n     */\n    public function search(string $search, $properties = null, array $options = null): float;\n\n    /**\n     * Returns true if object has a key.\n     *\n     * @return bool\n     */\n    public function hasKey();\n\n    /**\n     * Get a unique key for the object.\n     *\n     * Flex Keys can be used without knowing the Directory the Object belongs into.\n     *\n     * @see Flex::getObject()   If you want to get Flex Object from any Flex Directory.\n     * @see Flex::getObjects()  If you want to get list of Flex Objects from any Flex Directory.\n     *\n     * NOTE: Please do not override the method!\n     *\n     * @return string Returns Flex Key of the object.\n     * @api\n     */\n    public function getFlexKey(): string;\n\n    /**\n     * Get an unique storage key (within the directory) which is used for figuring out the filename or database id.\n     *\n     * @see FlexDirectory::getObject()      If you want to get Flex Object from the Flex Directory.\n     * @see FlexDirectory::getCollection()  If you want to get Flex Collection with selected keys from the Flex Directory.\n     *\n     * @return string Returns storage key of the Object.\n     * @api\n     */\n    public function getStorageKey(): string;\n\n    /**\n     * Get index data associated to the object.\n     *\n     * @return array Returns metadata of the object.\n     */\n    public function getMetaData(): array;\n\n    /**\n     * Returns true if the object exists in the storage.\n     *\n     * @return bool Returns `true` if the object exists, `false` otherwise.\n     * @api\n     */\n    public function exists(): bool;\n\n    /**\n     * Prepare object for saving into the storage.\n     *\n     * @return array Returns an array of object properties containing only scalars and arrays.\n     */\n    public function prepareStorage(): array;\n\n    /**\n     * Updates object in the memory.\n     *\n     * @see FlexObjectInterface::save() You need to save the object after calling this method.\n     *\n     * @param array $data   Data containing updated properties with their values. To unset a value, use `null`.\n     * @param array|UploadedFileInterface[] $files List of uploaded files to be saved within the object.\n     * @return static\n     * @throws RuntimeException\n     * @api\n     */\n    public function update(array $data, array $files = []);\n\n    /**\n     * Create new object into the storage.\n     *\n     * @see FlexDirectory::createObject() If you want to create a new object instance.\n     * @see FlexObjectInterface::update() If you want to update properties of the object.\n     *\n     * @param string|null $key Optional new key. If key isn't given, random key will be associated to the object.\n     * @return static\n     * @throws RuntimeException if object already exists.\n     * @api\n     */\n    public function create(string $key = null);\n\n    /**\n     * Save object into the storage.\n     *\n     * @see FlexObjectInterface::update() If you want to update properties of the object.\n     *\n     * @return static\n     * @api\n     */\n    public function save();\n\n    /**\n     * Delete object from the storage.\n     *\n     * @return static\n     * @api\n     */\n    public function delete();\n\n    /**\n     * Returns the blueprint of the object.\n     *\n     * @see FlexObjectInterface::getForm()\n     * @used-by FlexForm::getBlueprint()\n     *\n     * @param string $name Name of the Blueprint form. Used to create customized forms for different use cases.\n     * @return Blueprint Returns a Blueprint.\n     */\n    public function getBlueprint(string $name = '');\n\n    /**\n     * Returns a form instance for the object.\n     *\n     * @param string $name Name of the form. Can be used to create customized forms for different use cases.\n     * @param array|null $options  Options can be used to further customize the form.\n     * @return FlexFormInterface Returns a Form.\n     * @api\n     */\n    public function getForm(string $name = '', array $options = null);\n\n    /**\n     * Returns default value suitable to be used in a form for the given property.\n     *\n     * @see FlexObjectInterface::getForm()\n     *\n     * @param  string $name         Property name.\n     * @param  string|null $separator   Optional nested property separator.\n     * @return mixed|null           Returns default value of the field, null if there is no default value.\n     */\n    public function getDefaultValue(string $name, string $separator = null);\n\n    /**\n     * Returns default values suitable to be used in a form for the given property.\n     *\n     * @see FlexObjectInterface::getForm()\n     *\n     * @return array                Returns default values.\n     */\n    public function getDefaultValues(): array;\n\n    /**\n     * Returns raw value suitable to be used in a form for the given property.\n     *\n     * @see FlexObjectInterface::getForm()\n     *\n     * @param  string $name         Property name.\n     * @param  mixed  $default      Default value.\n     * @param  string|null $separator   Optional nested property separator.\n     * @return mixed                Returns value of the field.\n     */\n    public function getFormValue(string $name, $default = null, string $separator = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexStorageInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\n/**\n * Defines Flex Storage layer.\n *\n * @since 1.6\n */\ninterface FlexStorageInterface\n{\n    /**\n     * StorageInterface constructor.\n     * @param array $options\n     */\n    public function __construct(array $options);\n\n    /**\n     * @return string\n     */\n    public function getKeyField(): string;\n\n    /**\n     * @param string[] $keys\n     * @param bool $reload\n     * @return array\n     */\n    public function getMetaData(array $keys, bool $reload = false): array;\n\n    /**\n     * Returns associated array of all existing storage keys with a timestamp.\n     *\n     * @return  array Returns all existing keys as `[key => [storage_key => key, storage_timestamp => timestamp], ...]`.\n     */\n    public function getExistingKeys(): array;\n\n    /**\n     * Check if the key exists in the storage.\n     *\n     * @param string $key Storage key of an object.\n     * @return bool Returns `true` if the key exists in the storage, `false` otherwise.\n     */\n    public function hasKey(string $key): bool;\n\n    /**\n     * Check if the key exists in the storage.\n     *\n     * @param string[] $keys Storage key of an object.\n     * @return bool[] Returns keys with `true` if the key exists in the storage, `false` otherwise.\n     */\n    public function hasKeys(array $keys): array;\n\n    /**\n     * Create new rows into the storage.\n     *\n     * New keys will be assigned when the objects are created.\n     *\n     * @param  array  $rows  List of rows as `[row, ...]`.\n     * @return array  Returns created rows as `[key => row, ...] pairs.\n     */\n    public function createRows(array $rows): array;\n\n    /**\n     * Read rows from the storage.\n     *\n     * If you pass object or array as value, that value will be used to save I/O.\n     *\n     * @param  array  $rows  Array of `[key => row, ...]` pairs.\n     * @param  array|null $fetched  Optional reference to store only fetched items.\n     * @return array  Returns rows. Note that non-existing rows will have `null` as their value.\n     */\n    public function readRows(array $rows, array &$fetched = null): array;\n\n    /**\n     * Update existing rows in the storage.\n     *\n     * @param  array  $rows  Array of `[key => row, ...]` pairs.\n     * @return array  Returns updated rows. Note that non-existing rows will not be saved and have `null` as their value.\n     */\n    public function updateRows(array $rows): array;\n\n    /**\n     * Delete rows from the storage.\n     *\n     * @param  array  $rows  Array of `[key => row, ...]` pairs.\n     * @return array  Returns deleted rows. Note that non-existing rows have `null` as their value.\n     */\n    public function deleteRows(array $rows): array;\n\n    /**\n     * Replace rows regardless if they exist or not.\n     *\n     * All rows should have a specified key for replace to work properly.\n     *\n     * @param  array $rows  Array of `[key => row, ...]` pairs.\n     * @return array  Returns both created and updated rows.\n     */\n    public function replaceRows(array $rows): array;\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     */\n    public function copyRow(string $src, string $dst): bool;\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     */\n    public function renameRow(string $src, string $dst): bool;\n\n    /**\n     * Get filesystem path for the collection or object storage.\n     *\n     * @param  string|null $key Optional storage key.\n     * @return string|null Path in the filesystem. Can be URI or null if storage is not filesystem based.\n     */\n    public function getStoragePath(string $key = null): ?string;\n\n    /**\n     * Get filesystem path for the collection or object media.\n     *\n     * @param  string|null $key Optional storage key.\n     * @return string|null Path in the filesystem. Can be URI or null if media isn't supported.\n     */\n    public function getMediaPath(string $key = null): ?string;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Interfaces/FlexTranslateInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Interfaces;\n\n/**\n * Implements PageTranslateInterface\n */\ninterface FlexTranslateInterface\n{\n    /**\n     * Returns true if object has a translation in given language (or any of its fallback languages).\n     *\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return bool\n     */\n    public function hasTranslation(string $languageCode = null, bool $fallback = null): bool;\n\n    /**\n     * Get translation.\n     *\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return static|null\n     */\n    public function getTranslation(string $languageCode = null, bool $fallback = null);\n\n    /**\n     * Returns all translated languages.\n     *\n     * @param bool $includeDefault If set to true, return separate entries for '' and 'en' (default) language.\n     * @return array\n     */\n    public function getLanguages(bool $includeDefault = false): array;\n\n    /**\n     * Get used language.\n     *\n     * @return string\n     */\n    public function getLanguage(): string;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Pages/FlexPageCollection.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Pages;\n\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Framework\\Flex\\FlexCollection;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse function array_search;\nuse function assert;\nuse function is_int;\n\n/**\n * Class FlexPageCollection\n * @package Grav\\Plugin\\FlexObjects\\Types\\FlexPages\n * @template T of FlexObjectInterface\n * @extends FlexCollection<T>\n */\nclass FlexPageCollection extends FlexCollection\n{\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n            // Collection filtering\n            'withPublished' => true,\n            'withVisible' => true,\n            'withRoutable' => true,\n\n            'isFirst' => true,\n            'isLast' => true,\n\n            // Find objects\n            'prevSibling' => false,\n            'nextSibling' => false,\n            'adjacentSibling' => false,\n            'currentPosition' => true,\n\n            'getNextOrder' => false,\n        ] + parent::getCachedMethods();\n    }\n\n    /**\n     * @param bool $bool\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function withPublished(bool $bool = true)\n    {\n        /** @var string[] $list */\n        $list = array_keys(array_filter($this->call('isPublished', [$bool])));\n\n        /** @phpstan-var static<T> */\n        return $this->select($list);\n    }\n\n    /**\n     * @param bool $bool\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function withVisible(bool $bool = true)\n    {\n        /** @var string[] $list */\n        $list = array_keys(array_filter($this->call('isVisible', [$bool])));\n\n        /** @phpstan-var static<T> */\n        return $this->select($list);\n    }\n\n    /**\n     * @param bool $bool\n     * @return static\n     * @phpstan-return static<T>\n     */\n    public function withRoutable(bool $bool = true)\n    {\n        /** @var string[] $list */\n        $list = array_keys(array_filter($this->call('isRoutable', [$bool])));\n\n        /** @phpstan-var static<T> */\n        return $this->select($list);\n    }\n\n    /**\n     * Check to see if this item is the first in the collection.\n     *\n     * @param  string $path\n     * @return bool True if item is first.\n     */\n    public function isFirst($path): bool\n    {\n        $keys = $this->getKeys();\n        $first = reset($keys);\n\n        return $path === $first;\n    }\n\n    /**\n     * Check to see if this item is the last in the collection.\n     *\n     * @param  string $path\n     * @return bool True if item is last.\n     */\n    public function isLast($path): bool\n    {\n        $keys = $this->getKeys();\n        $last = end($keys);\n\n        return $path === $last;\n    }\n\n    /**\n     * Gets the previous sibling based on current position.\n     *\n     * @param  string $path\n     * @return PageInterface|false  The previous item.\n     * @phpstan-return T|false\n     */\n    public function prevSibling($path)\n    {\n        return $this->adjacentSibling($path, -1);\n    }\n\n    /**\n     * Gets the next sibling based on current position.\n     *\n     * @param  string $path\n     * @return PageInterface|false The next item.\n     * @phpstan-return T|false\n     */\n    public function nextSibling($path)\n    {\n        return $this->adjacentSibling($path, 1);\n    }\n\n    /**\n     * Returns the adjacent sibling based on a direction.\n     *\n     * @param  string  $path\n     * @param  int $direction either -1 or +1\n     * @return PageInterface|false    The sibling item.\n     * @phpstan-return T|false\n     */\n    public function adjacentSibling($path, $direction = 1)\n    {\n        $keys = $this->getKeys();\n        $direction = (int)$direction;\n        $pos = array_search($path, $keys, true);\n\n        if (is_int($pos)) {\n            $pos += $direction;\n            if (isset($keys[$pos])) {\n                return $this[$keys[$pos]];\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Returns the item in the current position.\n     *\n     * @param  string $path the path the item\n     * @return int|null The index of the current page, null if not found.\n     */\n    public function currentPosition($path): ?int\n    {\n        $pos = array_search($path, $this->getKeys(), true);\n\n        return is_int($pos) ? $pos : null;\n    }\n\n    /**\n     * @return string\n     */\n    public function getNextOrder()\n    {\n        $directory = $this->getFlexDirectory();\n\n        $collection = $directory->getIndex();\n        $keys = $collection->getStorageKeys();\n\n        // Assign next free order.\n        $last = null;\n        $order = 0;\n        foreach ($keys as $folder => $key) {\n            preg_match(FlexPageIndex::ORDER_PREFIX_REGEX, $folder, $test);\n            $test = $test[0] ?? null;\n            if ($test && $test > $order) {\n                $order = $test;\n                $last = $key;\n            }\n        }\n\n        /** @var FlexPageObject|null $last */\n        $last = $collection[$last];\n\n        return sprintf('%d.', $last ? $last->getFormValue('order') + 1 : 1);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Pages/FlexPageIndex.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Pages;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Framework\\Flex\\FlexIndex;\n\n/**\n * Class FlexPageObject\n * @package Grav\\Plugin\\FlexObjects\\Types\\FlexPages\n *\n * @method FlexPageIndex withRoutable(bool $bool = true)\n * @method FlexPageIndex withPublished(bool $bool = true)\n * @method FlexPageIndex withVisible(bool $bool = true)\n *\n * @template T of FlexPageObject\n * @template C of FlexPageCollection\n * @extends FlexIndex<T,C>\n */\nclass FlexPageIndex extends FlexIndex\n{\n    public const ORDER_PREFIX_REGEX = '/^\\d+\\./u';\n\n    /**\n     * @param string $route\n     * @return string\n     * @internal\n     */\n    public static function normalizeRoute(string $route)\n    {\n        static $case_insensitive;\n\n        if (null === $case_insensitive) {\n            $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls', false);\n        }\n\n        return $case_insensitive ? mb_strtolower($route) : $route;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Pages/FlexPageObject.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Pages;\n\nuse DateTime;\nuse Exception;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Traits\\PageFormTrait;\nuse Grav\\Common\\User\\Interfaces\\UserCollectionInterface;\nuse Grav\\Framework\\Flex\\FlexObject;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexTranslateInterface;\nuse Grav\\Framework\\Flex\\Pages\\Traits\\PageAuthorsTrait;\nuse Grav\\Framework\\Flex\\Pages\\Traits\\PageContentTrait;\nuse Grav\\Framework\\Flex\\Pages\\Traits\\PageLegacyTrait;\nuse Grav\\Framework\\Flex\\Pages\\Traits\\PageRoutableTrait;\nuse Grav\\Framework\\Flex\\Pages\\Traits\\PageTranslateTrait;\nuse Grav\\Framework\\Flex\\Traits\\FlexMediaTrait;\nuse RuntimeException;\nuse stdClass;\nuse function array_key_exists;\nuse function is_array;\n\n/**\n * Class FlexPageObject\n * @package Grav\\Plugin\\FlexObjects\\Types\\FlexPages\n */\nclass FlexPageObject extends FlexObject implements PageInterface, FlexTranslateInterface\n{\n    use PageAuthorsTrait;\n    use PageContentTrait;\n    use PageFormTrait;\n    use PageLegacyTrait;\n    use PageRoutableTrait;\n    use PageTranslateTrait;\n    use FlexMediaTrait;\n\n    public const PAGE_ORDER_REGEX = '/^(\\d+)\\.(.*)$/u';\n    public const PAGE_ORDER_PREFIX_REGEX = '/^[0-9]+\\./u';\n\n    /** @var array|null */\n    protected $_reorder;\n    /** @var FlexPageObject|null */\n    protected $_originalObject;\n\n    /**\n     * Clone page.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __clone()\n    {\n        parent::__clone();\n\n        if (isset($this->header)) {\n            $this->header = clone($this->header);\n        }\n    }\n\n    /**\n     * @return array\n     */\n    public static function getCachedMethods(): array\n    {\n        return [\n            // Page Content Interface\n            'header' => false,\n            'summary' => true,\n            'content' => true,\n            'value' => false,\n            'media' => false,\n            'title' => true,\n            'menu' => true,\n            'visible' => true,\n            'published' => true,\n            'publishDate' => true,\n            'unpublishDate' => true,\n            'process' => true,\n            'slug' => true,\n            'order' => true,\n            'id' => true,\n            'modified' => true,\n            'lastModified' => true,\n            'folder' => true,\n            'date' => true,\n            'dateformat' => true,\n            'taxonomy' => true,\n            'shouldProcess' => true,\n            'isPage' => true,\n            'isDir' => true,\n            'folderExists' => true,\n\n            // Page\n            'isPublished' => true,\n            'isOrdered' => true,\n            'isVisible' => true,\n            'isRoutable' => true,\n            'getCreated_Timestamp' => true,\n            'getPublish_Timestamp' => true,\n            'getUnpublish_Timestamp' => true,\n            'getUpdated_Timestamp' => true,\n        ] + parent::getCachedMethods();\n    }\n\n    /**\n     * @param bool $test\n     * @return bool\n     */\n    public function isPublished(bool $test = true): bool\n    {\n        $time = time();\n        $start = $this->getPublish_Timestamp();\n        $stop = $this->getUnpublish_Timestamp();\n\n        return $this->published() && $start <= $time && (!$stop || $time <= $stop) === $test;\n    }\n\n    /**\n     * @param bool $test\n     * @return bool\n     */\n    public function isOrdered(bool $test = true): bool\n    {\n        return ($this->order() !== false) === $test;\n    }\n\n    /**\n     * @param bool $test\n     * @return bool\n     */\n    public function isVisible(bool $test = true): bool\n    {\n        return $this->visible() === $test;\n    }\n\n    /**\n     * @param bool $test\n     * @return bool\n     */\n    public function isRoutable(bool $test = true): bool\n    {\n        return $this->routable() === $test;\n    }\n\n    /**\n     * @return int\n     */\n    public function getCreated_Timestamp(): int\n    {\n        return $this->getFieldTimestamp('created_date') ?? 0;\n    }\n\n    /**\n     * @return int\n     */\n    public function getPublish_Timestamp(): int\n    {\n        return $this->getFieldTimestamp('publish_date') ?? $this->getCreated_Timestamp();\n    }\n\n    /**\n     * @return int|null\n     */\n    public function getUnpublish_Timestamp(): ?int\n    {\n        return $this->getFieldTimestamp('unpublish_date');\n    }\n\n    /**\n     * @return int\n     */\n    public function getUpdated_Timestamp(): int\n    {\n        return $this->getFieldTimestamp('updated_date') ?? $this->getPublish_Timestamp();\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getFormValue(string $name, $default = null, string $separator = null)\n    {\n        $test = new stdClass();\n\n        $value = $this->pageContentValue($name, $test);\n        if ($value !== $test) {\n            return $value;\n        }\n\n        switch ($name) {\n            case 'name':\n                return $this->getProperty('template');\n            case 'route':\n                return $this->hasKey() ? '/' . $this->getKey() : null;\n            case 'header.permissions.groups':\n                $encoded = json_encode($this->getPermissions());\n                if ($encoded === false) {\n                    throw new RuntimeException('json_encode(): failed to encode group permissions');\n                }\n\n                return json_decode($encoded, true);\n        }\n\n        return parent::getFormValue($name, $default, $separator);\n    }\n\n    /**\n     * Get master storage key.\n     *\n     * @return string\n     * @see FlexObjectInterface::getStorageKey()\n     */\n    public function getMasterKey(): string\n    {\n        $key = (string)($this->storage_key ?? $this->getMetaData()['storage_key'] ?? null);\n        if (($pos = strpos($key, '|')) !== false) {\n            $key = substr($key, 0, $pos);\n        }\n\n        return $key;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexObjectInterface::getCacheKey()\n     */\n    public function getCacheKey(): string\n    {\n        return $this->hasKey() ? $this->getTypePrefix() . $this->getFlexType() . '.' . $this->getKey() . '.' . $this->getLanguage() : '';\n    }\n\n    /**\n     * @param string|null $key\n     * @return FlexObjectInterface\n     */\n    public function createCopy(string $key = null)\n    {\n        $this->copy();\n\n        return parent::createCopy($key);\n    }\n\n    /**\n     * @param array|bool $reorder\n     * @return FlexObject|FlexObjectInterface\n     */\n    public function save($reorder = true)\n    {\n        return parent::save();\n    }\n\n    /**\n     * Gets the Page Unmodified (original) version of the page.\n     *\n     * Assumes that object has been cloned before modifying it.\n     *\n     * @return FlexPageObject|null The original version of the page.\n     */\n    public function getOriginal()\n    {\n        return $this->_originalObject;\n    }\n\n    /**\n     * Store the Page Unmodified (original) version of the page.\n     *\n     * Can be called multiple times, only the first call matters.\n     *\n     * @return void\n     */\n    public function storeOriginal(): void\n    {\n        if (null === $this->_originalObject) {\n            $this->_originalObject = clone $this;\n        }\n    }\n\n    /**\n     * Get display order for the associated media.\n     *\n     * @return array\n     */\n    public function getMediaOrder(): array\n    {\n        $order = $this->getNestedProperty('header.media_order');\n\n        if (is_array($order)) {\n            return $order;\n        }\n\n        if (!$order) {\n            return [];\n        }\n\n        return array_map('trim', explode(',', $order));\n    }\n\n    // Overrides for header properties.\n\n    /**\n     * Common logic to load header properties.\n     *\n     * @param string $property\n     * @param mixed $var\n     * @param callable $filter\n     * @return mixed|null\n     */\n    protected function loadHeaderProperty(string $property, $var, callable $filter)\n    {\n        // We have to use parent methods in order to avoid loops.\n        $value = null === $var ? parent::getProperty($property) : null;\n        if (null === $value) {\n            $value = $filter($var ?? $this->getProperty('header')->get($property));\n\n            parent::setProperty($property, $value);\n            if ($this->doHasProperty($property)) {\n                $value = parent::getProperty($property);\n            }\n        }\n\n        return $value;\n    }\n\n    /**\n     * Common logic to load header properties.\n     *\n     * @param string $property\n     * @param mixed $var\n     * @param callable $filter\n     * @return mixed|null\n     */\n    protected function loadProperty(string $property, $var, callable $filter)\n    {\n        // We have to use parent methods in order to avoid loops.\n        $value = null === $var ? parent::getProperty($property) : null;\n        if (null === $value) {\n            $value = $filter($var);\n\n            parent::setProperty($property, $value);\n            if ($this->doHasProperty($property)) {\n                $value = parent::getProperty($property);\n            }\n        }\n\n        return $value;\n    }\n\n    /**\n     * @param string $property\n     * @param mixed $default\n     * @return mixed\n     */\n    public function getProperty($property, $default = null)\n    {\n        $method = static::$headerProperties[$property] ?? static::$calculatedProperties[$property] ?? null;\n        if ($method && method_exists($this, $method)) {\n            return $this->{$method}();\n        }\n\n        return parent::getProperty($property, $default);\n    }\n\n    /**\n     * @param string $property\n     * @param mixed $value\n     * @return $this\n     */\n    public function setProperty($property, $value)\n    {\n        $method = static::$headerProperties[$property] ?? static::$calculatedProperties[$property] ?? null;\n        if ($method && method_exists($this, $method)) {\n            $this->{$method}($value);\n\n            return $this;\n        }\n\n        parent::setProperty($property, $value);\n\n        return $this;\n    }\n\n    /**\n     * @param string $property\n     * @param mixed $value\n     * @param string|null $separator\n     * @return $this\n     */\n    public function setNestedProperty($property, $value, $separator = null)\n    {\n        $separator = $separator ?: '.';\n        if (strpos($property, 'header' . $separator) === 0) {\n            $this->getProperty('header')->set(str_replace('header' . $separator, '', $property), $value, $separator);\n\n            return $this;\n        }\n\n        parent::setNestedProperty($property, $value, $separator);\n\n        return $this;\n    }\n\n    /**\n     * @param string $property\n     * @param string|null $separator\n     * @return $this\n     */\n    public function unsetNestedProperty($property, $separator = null)\n    {\n        $separator = $separator ?: '.';\n        if (strpos($property, 'header' . $separator) === 0) {\n            $this->getProperty('header')->undef(str_replace('header' . $separator, '', $property), $separator);\n\n            return $this;\n        }\n\n        parent::unsetNestedProperty($property, $separator);\n\n        return $this;\n    }\n\n    /**\n     * @param array $elements\n     * @param bool $extended\n     * @return void\n     */\n    protected function filterElements(array &$elements, bool $extended = false): void\n    {\n        // Markdown storage conversion to page structure.\n        if (array_key_exists('content', $elements)) {\n            $elements['markdown'] = $elements['content'];\n            unset($elements['content']);\n        }\n\n        if (!$extended) {\n            $folder = !empty($elements['folder']) ? trim($elements['folder']) : '';\n\n            if ($folder) {\n                $order = !empty($elements['order']) ? (int)$elements['order'] : null;\n                // TODO: broken\n                $elements['storage_key'] = $order ? sprintf('%02d.%s', $order, $folder) : $folder;\n            }\n        }\n\n        parent::filterElements($elements);\n    }\n\n    /**\n     * @param string $field\n     * @return int|null\n     */\n    protected function getFieldTimestamp(string $field): ?int\n    {\n        $date = $this->getFieldDateTime($field);\n\n        return $date ? $date->getTimestamp() : null;\n    }\n\n    /**\n     * @param string $field\n     * @return DateTime|null\n     */\n    protected function getFieldDateTime(string $field): ?DateTime\n    {\n        try {\n            $value = $this->getProperty($field);\n            if (is_numeric($value)) {\n                $value = '@' . $value;\n            }\n            $date = $value ? new DateTime($value) : null;\n        } catch (Exception $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            $date = null;\n        }\n\n        return $date;\n    }\n\n    /**\n     * @return UserCollectionInterface|null\n     * @internal\n     */\n    protected function loadAccounts()\n    {\n        return Grav::instance()['accounts'] ?? null;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Pages\\Traits;\n\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Framework\\Acl\\Access;\nuse InvalidArgumentException;\nuse function in_array;\nuse function is_array;\nuse function is_bool;\nuse function is_string;\n\n/**\n * Trait PageAuthorsTrait\n * @package Grav\\Framework\\Flex\\Pages\\Traits\n */\ntrait PageAuthorsTrait\n{\n    /** @var array<int,UserInterface> */\n    private $_authors;\n    /** @var array|null */\n    private $_permissionsCache;\n\n    /**\n     * Returns true if object has the named author.\n     *\n     * @param string $username\n     * @return bool\n     */\n    public function hasAuthor(string $username): bool\n    {\n        $authors = (array)$this->getNestedProperty('header.permissions.authors');\n        if (empty($authors)) {\n            return false;\n        }\n\n        foreach ($authors as $author) {\n            if ($username === $author) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Get list of all author objects.\n     *\n     * @return array<int,UserInterface>\n     */\n    public function getAuthors(): array\n    {\n        if (null === $this->_authors) {\n            $this->_authors = $this->loadAuthors($this->getNestedProperty('header.permissions.authors', []));\n        }\n\n        return $this->_authors;\n    }\n\n    /**\n     * @param bool $inherit\n     * @return array\n     */\n    public function getPermissions(bool $inherit = false)\n    {\n        if (null === $this->_permissionsCache) {\n            $permissions = [];\n            if ($inherit && $this->getNestedProperty('header.permissions.inherit', true)) {\n                $parent = $this->parent();\n                if ($parent && method_exists($parent, 'getPermissions')) {\n                    $permissions = $parent->getPermissions($inherit);\n                }\n            }\n\n            $this->_permissionsCache = $this->loadPermissions($permissions);\n        }\n\n        return $this->_permissionsCache;\n    }\n\n    /**\n     * @param iterable $authors\n     * @return array<int,UserInterface>\n     */\n    protected function loadAuthors(iterable $authors): array\n    {\n        $accounts = $this->loadAccounts();\n        if (null === $accounts || empty($authors)) {\n            return [];\n        }\n\n        $list = [];\n        foreach ($authors as $username) {\n            if (!is_string($username)) {\n                throw new InvalidArgumentException('Iterable should return username (string).', 500);\n            }\n            $list[] = $accounts->load($username);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $action\n     * @param string|null $scope\n     * @param UserInterface|null $user\n     * @param bool $isAuthor\n     * @return bool|null\n     */\n    public function isParentAuthorized(string $action, string $scope = null, UserInterface $user = null, bool $isAuthor = false): ?bool\n    {\n        $scope = $scope ?? $this->getAuthorizeScope();\n\n        $isMe = null === $user;\n        if ($isMe) {\n            $user = $this->getActiveUser();\n        }\n\n        if (null === $user) {\n            return false;\n        }\n\n        return $this->isAuthorizedByGroup($user, $action, $scope, $isMe, $isAuthor);\n    }\n\n    /**\n     * @param UserInterface $user\n     * @param string $action\n     * @param string $scope\n     * @param bool $isMe\n     * @return bool|null\n     */\n    protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool\n    {\n        if ($action === 'delete' && $this->root()) {\n            // Do not allow deleting root.\n            return false;\n        }\n\n        $isAuthor = !$isMe || $user->authorized ? $this->hasAuthor($user->username) : false;\n\n        return $this->isAuthorizedByGroup($user, $action, $scope, $isMe, $isAuthor) ?? parent::isAuthorizedOverride($user, $action, $scope, $isMe);\n    }\n\n    /**\n     * Group authorization works as follows:\n     *\n     * 1. if any of the groups deny access, return false\n     * 2. else if any of the groups allow access, return true\n     * 3. else return null\n     *\n     * @param UserInterface $user\n     * @param string $action\n     * @param string $scope\n     * @param bool $isMe\n     * @param bool $isAuthor\n     * @return bool|null\n     */\n    protected function isAuthorizedByGroup(UserInterface $user, string $action, string $scope, bool $isMe, bool $isAuthor): ?bool\n    {\n        $authorized = null;\n\n        // In admin we want to check against group permissions.\n        $pageGroups = $this->getPermissions();\n        $userGroups = (array)$user->groups;\n\n        /** @var Access $access */\n        foreach ($pageGroups as $group => $access) {\n            if ($group === 'defaults') {\n                // Special defaults permissions group does not apply to guest.\n                if ($isMe && !$user->authorized) {\n                    continue;\n                }\n            } elseif ($group === 'authors') {\n                if (!$isAuthor) {\n                    continue;\n                }\n            } elseif (!in_array($group, $userGroups, true)) {\n                continue;\n            }\n\n            $auth = $access->authorize($action);\n            if (is_bool($auth)) {\n                if ($auth === false) {\n                    return false;\n                }\n\n                $authorized = true;\n            }\n        }\n\n        if (null === $authorized && $this->getNestedProperty('header.permissions.inherit', true)) {\n            // Authorize against parent page.\n            $parent = $this->parent();\n            if ($parent && method_exists($parent, 'isParentAuthorized')) {\n                $authorized = $parent->isParentAuthorized($action, $scope, !$isMe ? $user : null, $isAuthor);\n            }\n        }\n\n        return $authorized;\n    }\n\n    /**\n     * @param array $parent\n     * @return array\n     */\n    protected function loadPermissions(array $parent = []): array\n    {\n        static $rules = [\n            'c' => 'create',\n            'r' => 'read',\n            'u' => 'update',\n            'd' => 'delete',\n            'p' => 'publish',\n            'l' => 'list'\n        ];\n\n        $permissions = $this->getNestedProperty('header.permissions.groups');\n        $name = $this->root() ? '<root>' : '/' . $this->getKey();\n\n        $list = [];\n        if (is_array($permissions)) {\n            foreach ($permissions as $group => $access) {\n                $list[$group] = new Access($access, $rules, $name);\n            }\n        }\n        foreach ($parent as $group => $access) {\n            if (isset($list[$group])) {\n                $object = $list[$group];\n            } else {\n                $object = new Access([], $rules, $name);\n                $list[$group] = $object;\n            }\n\n            $object->inherit($access);\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Pages/Traits/PageContentTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Pages\\Traits;\n\nuse Exception;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Markdown\\Parsedown;\nuse Grav\\Common\\Markdown\\ParsedownExtra;\nuse Grav\\Common\\Page\\Header;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Markdown\\Excerpts;\nuse Grav\\Common\\Page\\Media;\nuse Grav\\Common\\Twig\\Twig;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\File\\Formatter\\YamlFormatter;\nuse RocketTheme\\Toolbox\\Event\\Event;\nuse stdClass;\nuse function in_array;\nuse function is_array;\nuse function is_string;\n\n/**\n * Implements PageContentInterface.\n */\ntrait PageContentTrait\n{\n    /** @var array */\n    protected static $headerProperties = [\n        'slug'              => 'slug',\n        'routes'            => false,\n        'title'             => 'title',\n        'language'          => 'language',\n        'template'          => 'template',\n        'menu'              => 'menu',\n        'routable'          => 'routable',\n        'visible'           => 'visible',\n        'redirect'          => 'redirect',\n        'external_url'      => false,\n        'order_dir'         => 'orderDir',\n        'order_by'          => 'orderBy',\n        'order_manual'      => 'orderManual',\n        'dateformat'        => 'dateformat',\n        'date'              => 'date',\n        'markdown_extra'    => false,\n        'taxonomy'          => 'taxonomy',\n        'max_count'         => 'maxCount',\n        'process'           => 'process',\n        'published'         => 'published',\n        'publish_date'      => 'publishDate',\n        'unpublish_date'    => 'unpublishDate',\n        'expires'           => 'expires',\n        'cache_control'     => 'cacheControl',\n        'etag'              => 'eTag',\n        'last_modified'     => 'lastModified',\n        'ssl'               => 'ssl',\n        'template_format'   => 'templateFormat',\n        'debugger'          => false,\n    ];\n\n    /** @var array */\n    protected static $calculatedProperties = [\n        'name' => 'name',\n        'parent' => 'parent',\n        'parent_key' => 'parentStorageKey',\n        'folder' => 'folder',\n        'order' => 'order',\n        'template' => 'template',\n    ];\n\n    /** @var object|null */\n    protected $header;\n\n    /** @var string|null */\n    protected $_summary;\n\n    /** @var string|null */\n    protected $_content;\n\n    /**\n     * Method to normalize the route.\n     *\n     * @param string $route\n     * @return string\n     * @internal\n     */\n    public static function normalizeRoute($route): string\n    {\n        $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls');\n\n        return $case_insensitive ? mb_strtolower($route) : $route;\n    }\n\n    /**\n     * @inheritdoc\n     * @return Header\n     */\n    public function header($var = null)\n    {\n        if (null !== $var) {\n            $this->setProperty('header', $var);\n        }\n\n        return $this->getProperty('header');\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function summary($size = null, $textOnly = false): string\n    {\n        return $this->processSummary($size, $textOnly);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function setSummary($summary): void\n    {\n        $this->_summary = $summary;\n    }\n\n    /**\n     * @inheritdoc\n     * @throws Exception\n     */\n    public function content($var = null): string\n    {\n        if (null !== $var) {\n            $this->_content = $var;\n        }\n\n        return $this->_content ?? $this->processContent($this->getRawContent());\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getRawContent(): string\n    {\n        return $this->_content ?? $this->getArrayProperty('markdown') ?? '';\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function setRawContent($content): void\n    {\n        $this->_content = $content ?? '';\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function rawMarkdown($var = null): string\n    {\n        if ($var !== null) {\n            $this->setProperty('markdown', $var);\n        }\n\n        return $this->getProperty('markdown') ?? '';\n    }\n\n    /**\n     * @inheritdoc\n     *\n     * Implement by calling:\n     *\n     * $test = new \\stdClass();\n     * $value = $this->pageContentValue($name, $test);\n     * if ($value !== $test) {\n     *     return $value;\n     * }\n     * return parent::value($name, $default);\n     */\n    abstract public function value($name, $default = null, $separator = null);\n\n    /**\n     * @inheritdoc\n     */\n    public function media($var = null): Media\n    {\n        if ($var instanceof Media) {\n            $this->setProperty('media', $var);\n        }\n\n        return $this->getProperty('media');\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function title($var = null): string\n    {\n        return $this->loadHeaderProperty(\n            'title',\n            $var,\n            function ($value) {\n                return trim($value ?? ($this->root() ? '<root>' : ucfirst($this->slug())));\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function menu($var = null): string\n    {\n        return $this->loadHeaderProperty(\n            'menu',\n            $var,\n            function ($value) {\n                return trim($value ?: $this->title());\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function visible($var = null): bool\n    {\n        $value = $this->loadHeaderProperty(\n            'visible',\n            $var,\n            function ($value) {\n                return ($value ?? $this->order() !== false) && !$this->isModule();\n            }\n        );\n\n        return $value && $this->published();\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function published($var = null): bool\n    {\n        return $this->loadHeaderProperty(\n            'published',\n            $var,\n            static function ($value) {\n                return (bool)($value ?? true);\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function publishDate($var = null): ?int\n    {\n        return $this->loadHeaderProperty(\n            'publish_date',\n            $var,\n            function ($value) {\n                return $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : null;\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function unpublishDate($var = null): ?int\n    {\n        return $this->loadHeaderProperty(\n            'unpublish_date',\n            $var,\n            function ($value) {\n                return $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : null;\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function process($var = null): array\n    {\n        return $this->loadHeaderProperty(\n            'process',\n            $var,\n            function ($value) {\n                $value = array_replace(Grav::instance()['config']->get('system.pages.process', []), is_array($value) ? $value : []);\n                foreach ($value as $process => $status) {\n                    $value[$process] = (bool)$status;\n                }\n\n                return $value;\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function slug($var = null)\n    {\n        return $this->loadHeaderProperty(\n            'slug',\n            $var,\n            function ($value) {\n                if (is_string($value)) {\n                    return $value;\n                }\n\n                $folder = $this->folder();\n                if (null === $folder) {\n                    return null;\n                }\n\n                $folder = preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $folder);\n                if (null === $folder) {\n                    return null;\n                }\n\n                return static::normalizeRoute($folder);\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function order($var = null)\n    {\n        $property = $this->loadProperty(\n            'order',\n            $var,\n            function ($value) {\n                if (null === $value) {\n                    $folder = $this->folder();\n                    if (null !== $folder) {\n                        preg_match(static::PAGE_ORDER_REGEX, $folder, $order);\n                    }\n\n                    $value = $order[1] ?? false;\n                }\n\n                if ($value === '') {\n                    $value = false;\n                }\n                if ($value !== false) {\n                    $value = (int)$value;\n                }\n\n                return $value;\n            }\n        );\n\n        return $property !== false ? sprintf('%02d.', $property) : false;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function id($var = null): string\n    {\n        $property = 'id';\n        $value = null === $var ? $this->getProperty($property) : null;\n        if (null === $value) {\n            $value = $this->language() . ($var ?? ($this->modified() . md5('flex-' . $this->getFlexType() . '-' . $this->getKey())));\n\n            $this->setProperty($property, $value);\n            if ($this->doHasProperty($property)) {\n                $value = $this->getProperty($property);\n            }\n        }\n\n        return $value;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function modified($var = null): int\n    {\n        $property = 'modified';\n        $value = null === $var ? $this->getProperty($property) : null;\n        if (null === $value) {\n            $value = (int)($var ?: $this->getTimestamp());\n\n            $this->setProperty($property, $value);\n            if ($this->doHasProperty($property)) {\n                $value = $this->getProperty($property);\n            }\n        }\n\n        return $value;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function lastModified($var = null): bool\n    {\n        return $this->loadHeaderProperty(\n            'last_modified',\n            $var,\n            static function ($value) {\n                return (bool)($value ?? Grav::instance()['config']->get('system.pages.last_modified'));\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function date($var = null): int\n    {\n        return $this->loadHeaderProperty(\n            'date',\n            $var,\n            function ($value) {\n                $value = $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : false;\n\n                return $value ?: $this->modified();\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function dateformat($var = null): ?string\n    {\n        return $this->loadHeaderProperty(\n            'dateformat',\n            $var,\n            static function ($value) {\n                return $value;\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function taxonomy($var = null): array\n    {\n        return $this->loadHeaderProperty(\n            'taxonomy',\n            $var,\n            static function ($value) {\n                if (is_array($value)) {\n                    // make sure first level are arrays\n                    array_walk($value, static function (&$val) {\n                        $val = (array) $val;\n                    });\n                    // make sure all values are strings\n                    array_walk_recursive($value, static function (&$val) {\n                        $val = (string) $val;\n                    });\n                }\n\n                return $value ?? [];\n            }\n        );\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function shouldProcess($process): bool\n    {\n        $test = $this->process();\n\n        return !empty($test[$process]);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function isPage(): bool\n    {\n        return !in_array($this->template(), ['', 'folder'], true);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function isDir(): bool\n    {\n        return !$this->isPage();\n    }\n\n    /**\n     * @return bool\n     */\n    public function isModule(): bool\n    {\n        return $this->modularTwig();\n    }\n\n    /**\n     * @param Header|stdClass|array|null $value\n     * @return Header\n     */\n    protected function offsetLoad_header($value)\n    {\n        if ($value instanceof Header) {\n            return $value;\n        }\n\n        if (null === $value) {\n            $value = [];\n        } elseif ($value instanceof stdClass) {\n            $value = (array)$value;\n        }\n\n        return new Header($value);\n    }\n\n    /**\n     * @param Header|stdClass|array|null $value\n     * @return Header\n     */\n    protected function offsetPrepare_header($value)\n    {\n        return $this->offsetLoad_header($value);\n    }\n\n    /**\n     * @param Header|null $value\n     * @return array\n     */\n    protected function offsetSerialize_header(?Header $value)\n    {\n        return $value ? $value->toArray() : [];\n    }\n\n    /**\n     * @param string $name\n     * @param mixed|null $default\n     * @return mixed\n     */\n    protected function pageContentValue($name, $default = null)\n    {\n        switch ($name) {\n            case 'frontmatter':\n                $frontmatter = $this->getArrayProperty('frontmatter');\n                if ($frontmatter === null) {\n                    $header = $this->prepareStorage()['header'] ?? null;\n                    if ($header) {\n                        $formatter = new YamlFormatter();\n                        $frontmatter = $formatter->encode($header);\n                    } else {\n                        $frontmatter = '';\n                    }\n                }\n                return $frontmatter;\n            case 'content':\n                return $this->getProperty('markdown');\n            case 'order':\n                return (string)$this->order();\n            case 'menu':\n                return $this->menu();\n            case 'ordering':\n                return $this->order() !== false ? '1' : '0';\n            case 'folder':\n                $folder = $this->folder();\n\n                return null !== $folder ? preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $folder) : '';\n            case 'slug':\n                return $this->slug();\n            case 'published':\n                return $this->published();\n            case 'visible':\n                return $this->visible();\n            case 'media':\n                return $this->media()->all();\n            case 'media.file':\n                return $this->media()->files();\n            case 'media.video':\n                return $this->media()->videos();\n            case 'media.image':\n                return $this->media()->images();\n            case 'media.audio':\n                return $this->media()->audios();\n        }\n\n        return $default;\n    }\n\n    /**\n     * @param int|null $size\n     * @param bool $textOnly\n     * @return string\n     */\n    protected function processSummary($size = null, $textOnly = false): string\n    {\n        $config = (array)Grav::instance()['config']->get('site.summary');\n        $config_page = (array)$this->getNestedProperty('header.summary');\n        if ($config_page) {\n            $config = array_merge($config, $config_page);\n        }\n\n        // Summary is not enabled, return the whole content.\n        if (empty($config['enabled'])) {\n            return $this->content();\n        }\n\n        $content = $this->_summary ?? $this->content();\n        if ($textOnly) {\n            $content =  strip_tags($content);\n        }\n        $content_size = mb_strwidth($content, 'utf-8');\n        $summary_size = $this->_summary !== null ? $content_size : $this->getProperty('summary_size');\n\n        // Return calculated summary based on summary divider's position.\n        $format = $config['format'] ?? '';\n\n        // Return entire page content on wrong/unknown format.\n        if ($format !== 'long' && $format !== 'short') {\n            return $content;\n        }\n\n        if ($format === 'short' && null !== $summary_size) {\n            // Slice the string on breakpoint.\n            if ($content_size > $summary_size) {\n                return mb_substr($content, 0, $summary_size);\n            }\n\n            return $content;\n        }\n\n        // If needed, get summary size from the config.\n        $size = $size ?? $config['size'] ?? null;\n\n        // Return calculated summary based on defaults.\n        $size = is_numeric($size) ? (int)$size : -1;\n        if ($size < 0) {\n            $size = 300;\n        }\n\n        // If the size is zero or smaller than the summary limit, return the entire page content.\n        if ($size === 0 || $content_size <= $size) {\n            return $content;\n        }\n\n        // Only return string but not html, wrap whatever html tag you want when using.\n        if ($textOnly) {\n            return mb_strimwidth($content, 0, $size, '...', 'UTF-8');\n        }\n\n        $summary = Utils::truncateHTML($content, $size);\n\n        return html_entity_decode($summary, ENT_COMPAT | ENT_HTML5, 'UTF-8');\n    }\n\n    /**\n     * Gets and Sets the content based on content portion of the .md file\n     *\n     * @param  string $content\n     * @return string\n     * @throws Exception\n     */\n    protected function processContent($content): string\n    {\n        $content = is_string($content) ? $content : '';\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        $process_markdown = $this->shouldProcess('markdown');\n        $process_twig = $this->shouldProcess('twig') || $this->isModule();\n        $cache_enable = $this->getNestedProperty('header.cache_enable') ?? $config->get('system.cache.enabled', true);\n\n        $twig_first = $this->getNestedProperty('header.twig_first') ?? $config->get('system.pages.twig_first', false);\n        $never_cache_twig = $this->getNestedProperty('header.never_cache_twig') ?? $config->get('system.pages.never_cache_twig', false);\n\n        if ($cache_enable) {\n            $cache = $this->getCache('render');\n            $key = md5($this->getCacheKey() . '-content');\n            $cached = $cache->get($key);\n            if ($cached && $cached['checksum'] === $this->getCacheChecksum()) {\n                $this->_content = $cached['content'] ?? '';\n                $this->_content_meta = $cached['content_meta'] ?? null;\n\n                if ($process_twig && $never_cache_twig) {\n                    $this->_content = $this->processTwig($this->_content);\n                }\n            }\n        }\n\n        if (null === $this->_content) {\n            $markdown_options = [];\n            if ($process_markdown) {\n                // Build markdown options.\n                $markdown_options = (array)$config->get('system.pages.markdown');\n                $markdown_page_options = (array)$this->getNestedProperty('header.markdown');\n                if ($markdown_page_options) {\n                    $markdown_options = array_merge($markdown_options, $markdown_page_options);\n                }\n\n                // pages.markdown_extra is deprecated, but still check it...\n                if (!isset($markdown_options['extra'])) {\n                    $extra = $this->getNestedProperty('header.markdown_extra') ?? $config->get('system.pages.markdown_extra');\n                    if (null !== $extra) {\n                        user_error('Configuration option \\'system.pages.markdown_extra\\' is deprecated since Grav 1.5, use \\'system.pages.markdown.extra\\' instead', E_USER_DEPRECATED);\n\n                        $markdown_options['extra'] = $extra;\n                    }\n                }\n            }\n            $options = [\n                'markdown' => $markdown_options,\n                'images' => $config->get('system.images', [])\n            ];\n\n            $this->_content = $content;\n            $grav->fireEvent('onPageContentRaw', new Event(['page' => $this]));\n\n            if ($twig_first && !$never_cache_twig) {\n                if ($process_twig) {\n                    $this->_content = $this->processTwig($this->_content);\n                }\n\n                if ($process_markdown) {\n                    $this->_content = $this->processMarkdown($this->_content, $options);\n                }\n\n                // Content Processed but not cached yet\n                $grav->fireEvent('onPageContentProcessed', new Event(['page' => $this]));\n            } else {\n                if ($process_markdown) {\n                    $options['keep_twig'] = $process_twig;\n                    $this->_content = $this->processMarkdown($this->_content, $options);\n                }\n\n                // Content Processed but not cached yet\n                $grav->fireEvent('onPageContentProcessed', new Event(['page' => $this]));\n\n                if ($cache_enable && $never_cache_twig) {\n                    $this->cachePageContent();\n                }\n\n                if ($process_twig) {\n                    \\assert(is_string($this->_content));\n                    $this->_content = $this->processTwig($this->_content);\n                }\n            }\n\n            if ($cache_enable && !$never_cache_twig) {\n                $this->cachePageContent();\n            }\n        }\n\n        \\assert(is_string($this->_content));\n\n        // Handle summary divider\n        $delimiter = $config->get('site.summary.delimiter', '===');\n        $divider_pos = mb_strpos($this->_content, \"<p>{$delimiter}</p>\");\n        if ($divider_pos !== false) {\n            $this->setProperty('summary_size', $divider_pos);\n            $this->_content = str_replace(\"<p>{$delimiter}</p>\", '', $this->_content);\n        }\n\n        // Fire event when Page::content() is called\n        $grav->fireEvent('onPageContent', new Event(['page' => $this]));\n\n        return $this->_content;\n    }\n\n    /**\n     * Process the Twig page content.\n     *\n     * @param  string $content\n     * @return string\n     */\n    protected function processTwig($content): string\n    {\n        /** @var Twig $twig */\n        $twig = Grav::instance()['twig'];\n\n        /** @var PageInterface $this */\n        return $twig->processPage($this, $content);\n    }\n\n    /**\n     * Process the Markdown content.\n     *\n     * Uses Parsedown or Parsedown Extra depending on configuration.\n     *\n     * @param string $content\n     * @param array  $options\n     * @return string\n     * @throws Exception\n     */\n    protected function processMarkdown($content, array $options = []): string\n    {\n        /** @var PageInterface $self */\n        $self = $this;\n\n        $excerpts = new Excerpts($self, $options);\n\n        // Initialize the preferred variant of markdown parser.\n        if (isset($options['extra'])) {\n            $parsedown = new ParsedownExtra($excerpts);\n        } else {\n            $parsedown = new Parsedown($excerpts);\n        }\n\n        $keepTwig = (bool)($options['keep_twig'] ?? false);\n        if ($keepTwig) {\n            $token = [\n                '/' . Utils::generateRandomString(3),\n                Utils::generateRandomString(3) . '/'\n            ];\n            // Base64 encode any twig.\n            $content = preg_replace_callback(\n                ['/({#.*?#})/mu', '/({{.*?}})/mu', '/({%.*?%})/mu'],\n                static function ($matches) use ($token) { return $token[0] . base64_encode($matches[1]) . $token[1]; },\n                $content\n            );\n        }\n\n        $content = $parsedown->text($content);\n\n        if ($keepTwig) {\n            // Base64 decode the encoded twig.\n            $content = preg_replace_callback(\n                ['`' . $token[0] . '([A-Za-z0-9+/]+={0,2})' . $token[1] . '`mu'],\n                static function ($matches) { return base64_decode($matches[1]); },\n                $content\n            );\n        }\n\n        return $content;\n    }\n\n    abstract protected function loadHeaderProperty(string $property, $var, callable $filter);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Pages\\Traits;\n\nuse Exception;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Collection;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Utils;\nuse Grav\\Common\\Yaml;\nuse Grav\\Framework\\File\\Formatter\\MarkdownFormatter;\nuse Grav\\Framework\\File\\Formatter\\YamlFormatter;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexCollectionInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexIndexInterface;\nuse Grav\\Framework\\Flex\\Pages\\FlexPageCollection;\nuse Grav\\Framework\\Flex\\Pages\\FlexPageIndex;\nuse Grav\\Framework\\Flex\\Pages\\FlexPageObject;\nuse InvalidArgumentException;\nuse RocketTheme\\Toolbox\\File\\MarkdownFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse SplFileInfo;\nuse function in_array;\nuse function is_array;\nuse function is_string;\nuse function strlen;\n\n/**\n * Implements PageLegacyInterface\n */\ntrait PageLegacyTrait\n{\n    /** @var array|null */\n    private $_content_meta;\n    /** @var array|null */\n    private $_metadata;\n\n    /**\n     * Initializes the page instance variables based on a file\n     *\n     * @param  SplFileInfo $file The file information for the .md file that the page represents\n     * @param  string|null $extension\n     * @return $this\n     */\n    public function init(SplFileInfo $file, $extension = null)\n    {\n        // TODO:\n        throw new RuntimeException(__METHOD__ . '(): Not Implemented');\n    }\n\n    /**\n     * Gets and Sets the raw data\n     *\n     * @param  string|null $var Raw content string\n     * @return string      Raw content string\n     */\n    public function raw($var = null): string\n    {\n        if (null !== $var) {\n            // TODO:\n            throw new RuntimeException(__METHOD__ . '(string): Not Implemented');\n        }\n\n        $storage = $this->getFlexDirectory()->getStorage();\n        if (method_exists($storage, 'readRaw')) {\n            return $storage->readRaw($this->getStorageKey());\n        }\n\n        $array = $this->prepareStorage();\n        $formatter = new MarkdownFormatter();\n\n        return $formatter->encode($array);\n    }\n\n    /**\n     * Gets and Sets the page frontmatter\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function frontmatter($var = null): string\n    {\n        if (null !== $var) {\n            $formatter = new YamlFormatter();\n            $this->setProperty('frontmatter', $var);\n            $this->setProperty('header', $formatter->decode($var));\n\n            return $var;\n        }\n\n        $storage = $this->getFlexDirectory()->getStorage();\n        if (method_exists($storage, 'readFrontmatter')) {\n            return $storage->readFrontmatter($this->getStorageKey());\n        }\n\n        $array = $this->prepareStorage();\n        $formatter = new YamlFormatter();\n\n        return $formatter->encode($array['header'] ?? []);\n    }\n\n    /**\n     * Modify a header value directly\n     *\n     * @param string $key\n     * @param string|array $value\n     * @return void\n     */\n    public function modifyHeader($key, $value): void\n    {\n        $this->setNestedProperty(\"header.{$key}\", $value);\n    }\n\n    /**\n     * @return int\n     */\n    public function httpResponseCode(): int\n    {\n        $code = (int)$this->getNestedProperty('header.http_response_code');\n\n        return $code ?: 200;\n    }\n\n    /**\n     * @return array\n     */\n    public function httpHeaders(): array\n    {\n        $headers = [];\n\n        $format = $this->templateFormat();\n        $cache_control = $this->cacheControl();\n        $expires = $this->expires();\n\n        // Set Content-Type header.\n        $headers['Content-Type'] = Utils::getMimeByExtension($format, 'text/html');\n\n        // Calculate Expires Headers if set to > 0.\n        if ($expires > 0) {\n            $expires_date = gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT';\n            if (!$cache_control) {\n                $headers['Cache-Control'] = 'max-age=' . $expires;\n            }\n            $headers['Expires'] = $expires_date;\n        }\n\n        // Set Cache-Control header.\n        if ($cache_control) {\n            $headers['Cache-Control'] = strtolower($cache_control);\n        }\n\n        // Set Last-Modified header.\n        if ($this->lastModified()) {\n            $last_modified_date = gmdate('D, d M Y H:i:s', $this->modified()) . ' GMT';\n            $headers['Last-Modified'] = $last_modified_date;\n        }\n\n        // Calculate ETag based on the serialized page and modified time.\n        if ($this->eTag()) {\n            $headers['ETag'] = '1';\n        }\n\n        // Set Vary: Accept-Encoding header.\n        $grav = Grav::instance();\n        if ($grav['config']->get('system.pages.vary_accept_encoding', false)) {\n            $headers['Vary'] = 'Accept-Encoding';\n        }\n\n        return $headers;\n    }\n\n    /**\n     * Get the contentMeta array and initialize content first if it's not already\n     *\n     * @return array\n     */\n    public function contentMeta(): array\n    {\n        // Content meta is generated during the content is being rendered, so make sure we have done it.\n        $this->content();\n\n        return $this->_content_meta ?? [];\n    }\n\n    /**\n     * Add an entry to the page's contentMeta array\n     *\n     * @param string $name\n     * @param string $value\n     * @return void\n     */\n    public function addContentMeta($name, $value): void\n    {\n        $this->_content_meta[$name] = $value;\n    }\n\n    /**\n     * Return the whole contentMeta array as it currently stands\n     *\n     * @param string|null $name\n     * @return string|array|null\n     */\n    public function getContentMeta($name = null)\n    {\n        if ($name) {\n            return $this->_content_meta[$name] ?? null;\n        }\n\n        return $this->_content_meta ?? [];\n    }\n\n    /**\n     * Sets the whole content meta array in one shot\n     *\n     * @param array $content_meta\n     * @return array\n     */\n    public function setContentMeta($content_meta): array\n    {\n        return $this->_content_meta = $content_meta;\n    }\n\n    /**\n     * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page\n     */\n    public function cachePageContent(): void\n    {\n        $value = [\n            'checksum' => $this->getCacheChecksum(),\n            'content' => $this->_content,\n            'content_meta' => $this->_content_meta\n        ];\n\n        $cache = $this->getCache('render');\n        $key = md5($this->getCacheKey() . '-content');\n\n        $cache->set($key, $value);\n    }\n\n    /**\n     * Get file object to the page.\n     *\n     * @return MarkdownFile|null\n     */\n    public function file(): ?MarkdownFile\n    {\n        // TODO:\n        throw new RuntimeException(__METHOD__ . '(): Not Implemented');\n    }\n\n    /**\n     * Prepare move page to new location. Moves also everything that's under the current page.\n     *\n     * You need to call $this->save() in order to perform the move.\n     *\n     * @param PageInterface $parent New parent page.\n     * @return $this\n     */\n    public function move(PageInterface $parent)\n    {\n        if ($this->route() === $parent->route()) {\n            throw new RuntimeException('Failed: Cannot set page parent to self');\n        }\n        $rawRoute = $this->rawRoute();\n        if ($rawRoute && Utils::startsWith($parent->rawRoute(), $rawRoute)) {\n            throw new RuntimeException('Failed: Cannot set page parent to a child of current page');\n        }\n\n        $this->storeOriginal();\n\n        // TODO:\n        throw new RuntimeException(__METHOD__ . '(): Not Implemented');\n    }\n\n    /**\n     * Prepare a copy from the page. Copies also everything that's under the current page.\n     *\n     * Returns a new Page object for the copy.\n     * You need to call $this->save() in order to perform the move.\n     *\n     * @param PageInterface|null $parent New parent page.\n     * @return $this\n     */\n    public function copy(PageInterface $parent = null)\n    {\n        $this->storeOriginal();\n\n        $filesystem = Filesystem::getInstance(false);\n\n        $parentStorageKey = ltrim($filesystem->dirname(\"/{$this->getMasterKey()}\"), '/');\n\n        /** @var FlexPageIndex<FlexPageObject,FlexPageCollection<FlexPageObject>> $index */\n        $index = $this->getFlexDirectory()->getIndex();\n\n        if ($parent) {\n            if ($parent instanceof FlexPageObject) {\n                $k = $parent->getMasterKey();\n                if ($k !== $parentStorageKey) {\n                    $parentStorageKey = $k;\n                }\n            } else {\n                throw new RuntimeException('Cannot copy page, parent is of unknown type');\n            }\n        } else {\n            $parent = $parentStorageKey\n                ? $this->getFlexDirectory()->getObject($parentStorageKey, 'storage_key')\n                : (method_exists($index, 'getRoot') ? $index->getRoot() : null);\n        }\n\n        // Find non-existing key.\n        $parentKey = $parent ? $parent->getKey() : '';\n        if ($this instanceof FlexPageObject) {\n            $key = trim($parentKey . '/' . $this->folder(), '/');\n            $key = preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $key);\n            \\assert(is_string($key));\n        } else {\n            $key = trim($parentKey . '/' . Utils::basename($this->getKey()), '/');\n        }\n\n        if ($index->containsKey($key)) {\n            $key = preg_replace('/\\d+$/', '', $key);\n            $i = 1;\n            do {\n                $i++;\n                $test = \"{$key}{$i}\";\n            } while ($index->containsKey($test));\n            $key = $test;\n        }\n        $folder = Utils::basename($key);\n\n        // Get the folder name.\n        $order = $this->getProperty('order');\n        if ($order) {\n            $order++;\n        }\n\n        $parts = [];\n        if ($parentStorageKey !== '') {\n            $parts[] = $parentStorageKey;\n        }\n        $parts[] = $order ? sprintf('%02d.%s', $order, $folder) : $folder;\n\n        // Finally update the object.\n        $this->setKey($key);\n        $this->setStorageKey(implode('/', $parts));\n\n        $this->markAsCopy();\n\n        return $this;\n    }\n\n    /**\n     * Get the blueprint name for this page.  Use the blueprint form field if set\n     *\n     * @return string\n     */\n    public function blueprintName(): string\n    {\n        if (!isset($_POST['blueprint'])) {\n            return $this->template();\n        }\n\n        $post_value = $_POST['blueprint'];\n        $sanitized_value = htmlspecialchars(strip_tags($post_value), ENT_QUOTES, 'UTF-8');\n\n        return $sanitized_value ?: $this->template();\n    }\n\n    /**\n     * Validate page header.\n     *\n     * @return void\n     * @throws Exception\n     */\n    public function validate(): void\n    {\n        $blueprint = $this->getBlueprint();\n        $blueprint->validate($this->toArray());\n    }\n\n    /**\n     * Filter page header from illegal contents.\n     *\n     * @return void\n     */\n    public function filter(): void\n    {\n        $blueprints = $this->getBlueprint();\n        $values = $blueprints->filter($this->toArray());\n        if ($values && isset($values['header'])) {\n            $this->header($values['header']);\n        }\n    }\n\n    /**\n     * Get unknown header variables.\n     *\n     * @return array\n     */\n    public function extra(): array\n    {\n        $data = $this->prepareStorage();\n\n        return $this->getBlueprint()->extra((array)($data['header'] ?? []), 'header.');\n    }\n\n    /**\n     * Convert page to an array.\n     *\n     * @return array\n     */\n    public function toArray(): array\n    {\n        return [\n            'header' => (array)$this->header(),\n            'content' => (string)$this->getFormValue('content')\n        ];\n    }\n\n    /**\n     * Convert page to YAML encoded string.\n     *\n     * @return string\n     */\n    public function toYaml(): string\n    {\n        return Yaml::dump($this->toArray(), 20);\n    }\n\n    /**\n     * Convert page to JSON encoded string.\n     *\n     * @return string\n     */\n    public function toJson(): string\n    {\n        $json = json_encode($this->toArray());\n        if (!is_string($json)) {\n            throw new RuntimeException('Internal error');\n        }\n\n        return $json;\n    }\n\n    /**\n     * Gets and sets the name field.  If no name field is set, it will return 'default.md'.\n     *\n     * @param  string|null $var The name of this page.\n     * @return string      The name of this page.\n     */\n    public function name($var = null): string\n    {\n        return $this->loadProperty(\n            'name',\n            $var,\n            function ($value) {\n                $value = $value ?? $this->getMetaData()['template'] ?? 'default';\n                if (!preg_match('/\\.md$/', $value)) {\n                    $language = $this->language();\n                    if ($language) {\n                        // TODO: better language support\n                        $value .= \".{$language}\";\n                    }\n                    $value .= '.md';\n                }\n                $value = preg_replace('|^modular/|', '', $value);\n\n                $this->unsetProperty('template');\n\n                return $value;\n            }\n        );\n    }\n\n    /**\n     * Returns child page type.\n     *\n     * @return string\n     */\n    public function childType(): string\n    {\n        return (string)$this->getNestedProperty('header.child_type');\n    }\n\n    /**\n     * Gets and sets the template field. This is used to find the correct Twig template file to render.\n     * If no field is set, it will return the name without the .md extension\n     *\n     * @param  string|null $var the template name\n     * @return string      the template name\n     */\n    public function template($var = null): string\n    {\n        return $this->loadHeaderProperty(\n            'template',\n            $var,\n            function ($value) {\n                return trim($value ?? (($this->isModule() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name())));\n            }\n        );\n    }\n\n    /**\n     * Allows a page to override the output render format, usually the extension provided in the URL.\n     * (e.g. `html`, `json`, `xml`, etc).\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function templateFormat($var = null): string\n    {\n        return $this->loadHeaderProperty(\n            'template_format',\n            $var,\n            function ($value) {\n                return ltrim($value ?? $this->getNestedProperty('header.append_url_extension') ?: Utils::getPageFormat(), '.');\n            }\n        );\n    }\n\n    /**\n     * Gets and sets the extension field.\n     *\n     * @param string|null $var\n     * @return string\n     */\n    public function extension($var = null): string\n    {\n        if (null !== $var) {\n            $this->setProperty('format', $var);\n        }\n\n        $language = $this->language();\n        if ($language) {\n            $language = '.' . $language;\n        }\n        $format = '.' . ($this->getProperty('format') ?? Utils::pathinfo($this->name(), PATHINFO_EXTENSION));\n\n        return $language . $format;\n    }\n\n    /**\n     * Gets and sets the expires field. If not set will return the default\n     *\n     * @param  int|null $var The new expires value.\n     * @return int      The expires value\n     */\n    public function expires($var = null): int\n    {\n        return $this->loadHeaderProperty(\n            'expires',\n            $var,\n            static function ($value) {\n                return (int)($value ?? Grav::instance()['config']->get('system.pages.expires'));\n            }\n        );\n    }\n\n    /**\n     * Gets and sets the cache-control property.  If not set it will return the default value (null)\n     * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options\n     *\n     * @param string|null $var\n     * @return string|null\n     */\n    public function cacheControl($var = null): ?string\n    {\n        return $this->loadHeaderProperty(\n            'cache_control',\n            $var,\n            static function ($value) {\n                return ((string)($value ?? Grav::instance()['config']->get('system.pages.cache_control'))) ?: null;\n            }\n        );\n    }\n\n    /**\n     * @param bool|null $var\n     * @return bool|null\n     */\n    public function ssl($var = null): ?bool\n    {\n        return $this->loadHeaderProperty(\n            'ssl',\n            $var,\n            static function ($value) {\n                return $value ? (bool)$value : null;\n            }\n        );\n    }\n\n    /**\n     * Returns the state of the debugger override setting for this page\n     *\n     * @return bool\n     */\n    public function debugger(): bool\n    {\n        return (bool)$this->getNestedProperty('header.debugger', true);\n    }\n\n    /**\n     * Function to merge page metadata tags and build an array of Metadata objects\n     * that can then be rendered in the page.\n     *\n     * @param  array|null $var an Array of metadata values to set\n     * @return array      an Array of metadata values for the page\n     */\n    public function metadata($var = null): array\n    {\n        if ($var !== null) {\n            $this->_metadata = (array)$var;\n        }\n\n        // if not metadata yet, process it.\n        if (null === $this->_metadata) {\n            $this->_metadata = [];\n\n            $config = Grav::instance()['config'];\n\n            // Set the Generator tag\n            $defaultMetadata = ['generator' => 'GravCMS'];\n            $siteMetadata = $config->get('site.metadata', []);\n            $headerMetadata = $this->getNestedProperty('header.metadata', []);\n\n            // Get initial metadata for the page\n            $metadata = array_merge($defaultMetadata, $siteMetadata, $headerMetadata);\n\n            $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible', 'content-security-policy'];\n            $escape = !$config->get('system.strict_mode.twig_compat', false) || $config->get('system.twig.autoescape', true);\n\n            // Build an array of meta objects..\n            foreach ($metadata as $key => $value) {\n                // Lowercase the key\n                $key = strtolower($key);\n\n                // If this is a property type metadata: \"og\", \"twitter\", \"facebook\" etc\n                // Backward compatibility for nested arrays in metas\n                if (is_array($value)) {\n                    foreach ($value as $property => $prop_value) {\n                        $prop_key = $key . ':' . $property;\n                        $this->_metadata[$prop_key] = [\n                            'name' => $prop_key,\n                            'property' => $prop_key,\n                            'content' => $escape ? htmlspecialchars($prop_value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $prop_value\n                        ];\n                    }\n                } elseif ($value) {\n                    // If it this is a standard meta data type\n                    if (in_array($key, $header_tag_http_equivs, true)) {\n                        $this->_metadata[$key] = [\n                            'http_equiv' => $key,\n                            'content' => $escape ? htmlspecialchars($value, ENT_COMPAT, 'UTF-8') : $value\n                        ];\n                    } elseif ($key === 'charset') {\n                        $this->_metadata[$key] = ['charset' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value];\n                    } else {\n                        // if it's a social metadata with separator, render as property\n                        $separator = strpos($key, ':');\n                        $hasSeparator = $separator && $separator < strlen($key) - 1;\n                        $entry = [\n                            'content' => $escape ? htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8') : $value\n                        ];\n\n                        if ($hasSeparator && !Utils::startsWith($key, 'twitter')) {\n                            $entry['property'] = $key;\n                        } else {\n                            $entry['name'] = $key;\n                        }\n\n                        $this->_metadata[$key] = $entry;\n                    }\n                }\n            }\n        }\n\n        return $this->_metadata;\n    }\n\n    /**\n     * Reset the metadata and pull from header again\n     */\n    public function resetMetadata(): void\n    {\n        $this->_metadata = null;\n    }\n\n    /**\n     * Gets and sets the option to show the etag header for the page.\n     *\n     * @param  bool|null $var show etag header\n     * @return bool      show etag header\n     */\n    public function eTag($var = null): bool\n    {\n        return $this->loadHeaderProperty(\n            'etag',\n            $var,\n            static function ($value) {\n                return (bool)($value ?? Grav::instance()['config']->get('system.pages.etag'));\n            }\n        );\n    }\n\n    /**\n     * Gets and sets the path to the .md file for this Page object.\n     *\n     * @param  string|null $var the file path\n     * @return string|null      the file path\n     */\n    public function filePath($var = null): ?string\n    {\n        if (null !== $var) {\n            // TODO:\n            throw new RuntimeException(__METHOD__ . '(string): Not Implemented');\n        }\n\n        $folder = $this->getStorageFolder();\n        if (!$folder) {\n            return null;\n        }\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . \"/{$folder}\";\n\n        return $folder . '/' . ($this->isPage() ? $this->name() : 'default.md');\n    }\n\n    /**\n     * Gets the relative path to the .md file\n     *\n     * @return string|null The relative file path\n     */\n    public function filePathClean(): ?string\n    {\n        $folder = $this->getStorageFolder();\n        if (!$folder) {\n            return null;\n        }\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $folder = $locator->isStream($folder) ? $locator->getResource($folder, false) : $folder;\n\n        return $folder .  '/' . ($this->isPage() ? $this->name() : 'default.md');\n    }\n\n    /**\n     * Gets and sets the order by which any sub-pages should be sorted.\n     *\n     * @param  string|null $var the order, either \"asc\" or \"desc\"\n     * @return string      the order, either \"asc\" or \"desc\"\n     */\n    public function orderDir($var = null): string\n    {\n        return $this->loadHeaderProperty(\n            'order_dir',\n            $var,\n            static function ($value) {\n                return strtolower(trim($value) ?: Grav::instance()['config']->get('system.pages.order.dir')) === 'desc' ? 'desc' : 'asc';\n            }\n        );\n    }\n\n    /**\n     * Gets and sets the order by which the sub-pages should be sorted.\n     *\n     * default - is the order based on the file system, ie 01.Home before 02.Advark\n     * title - is the order based on the title set in the pages\n     * date - is the order based on the date set in the pages\n     * folder - is the order based on the name of the folder with any numerics omitted\n     *\n     * @param  string|null $var supported options include \"default\", \"title\", \"date\", and \"folder\"\n     * @return string      supported options include \"default\", \"title\", \"date\", and \"folder\"\n     */\n    public function orderBy($var = null): string\n    {\n        return $this->loadHeaderProperty(\n            'order_by',\n            $var,\n            static function ($value) {\n                return trim($value) ?: Grav::instance()['config']->get('system.pages.order.by');\n            }\n        );\n    }\n\n    /**\n     * Gets the manual order set in the header.\n     *\n     * @param  string|null $var supported options include \"default\", \"title\", \"date\", and \"folder\"\n     * @return array\n     */\n    public function orderManual($var = null): array\n    {\n        return $this->loadHeaderProperty(\n            'order_manual',\n            $var,\n            static function ($value) {\n                return (array)$value;\n            }\n        );\n    }\n\n    /**\n     * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the\n     * sub_pages header property is set for this page object.\n     *\n     * @param  int|null $var the maximum number of sub-pages\n     * @return int      the maximum number of sub-pages\n     */\n    public function maxCount($var = null): int\n    {\n        return $this->loadHeaderProperty(\n            'max_count',\n            $var,\n            static function ($value) {\n                return (int)($value ?? Grav::instance()['config']->get('system.pages.list.count'));\n            }\n        );\n    }\n\n    /**\n     * Gets and sets the modular var that helps identify this page is a modular child\n     *\n     * @param  bool|null $var true if modular_twig\n     * @return bool      true if modular_twig\n     * @deprecated 1.7 Use ->isModule() or ->modularTwig() method instead.\n     */\n    public function modular($var = null): bool\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use ->isModule() or ->modularTwig() method instead', E_USER_DEPRECATED);\n\n        return $this->modularTwig($var);\n    }\n\n    /**\n     * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need\n     * twig processing handled differently from a regular page.\n     *\n     * @param  bool|null $var true if modular_twig\n     * @return bool      true if modular_twig\n     */\n    public function modularTwig($var = null): bool\n    {\n        if ($var !== null) {\n            $this->setProperty('modular_twig', (bool)$var);\n            if ($var) {\n                $this->visible(false);\n            }\n        }\n\n        return (bool)($this->getProperty('modular_twig') ?? strpos($this->slug(), '_') === 0);\n    }\n\n    /**\n     * Returns children of this page.\n     *\n     * @return PageCollectionInterface|FlexIndexInterface\n     */\n    public function children()\n    {\n        $meta = $this->getMetaData();\n        $keys = array_keys($meta['children'] ?? []);\n        $prefix = $this->getMasterKey();\n        if ($prefix) {\n            foreach ($keys as &$key) {\n                $key = $prefix . '/' . $key;\n            }\n            unset($key);\n        }\n\n        return $this->getFlexDirectory()->getIndex($keys, 'storage_key');\n    }\n\n    /**\n     * Check to see if this item is the first in an array of sub-pages.\n     *\n     * @return bool True if item is first.\n     */\n    public function isFirst(): bool\n    {\n        $parent = $this->parent();\n        $children = $parent ? $parent->children() : null;\n        if ($children instanceof FlexCollectionInterface) {\n            $children = $children->withKeyField();\n        }\n\n        return $children instanceof PageCollectionInterface ? $children->isFirst($this->getKey()) : true;\n    }\n\n    /**\n     * Check to see if this item is the last in an array of sub-pages.\n     *\n     * @return bool True if item is last\n     */\n    public function isLast(): bool\n    {\n        $parent = $this->parent();\n        $children = $parent ? $parent->children() : null;\n        if ($children instanceof FlexCollectionInterface) {\n            $children = $children->withKeyField();\n        }\n\n        return $children instanceof PageCollectionInterface ? $children->isLast($this->getKey()) : true;\n    }\n\n    /**\n     * Gets the previous sibling based on current position.\n     *\n     * @return PageInterface|false the previous Page item\n     */\n    public function prevSibling()\n    {\n        return $this->adjacentSibling(-1);\n    }\n\n    /**\n     * Gets the next sibling based on current position.\n     *\n     * @return PageInterface|false the next Page item\n     */\n    public function nextSibling()\n    {\n        return $this->adjacentSibling(1);\n    }\n\n    /**\n     * Returns the adjacent sibling based on a direction.\n     *\n     * @param  int $direction either -1 or +1\n     * @return PageInterface|false the sibling page\n     */\n    public function adjacentSibling($direction = 1)\n    {\n        $parent = $this->parent();\n        $children = $parent ? $parent->children() : null;\n        if ($children instanceof FlexCollectionInterface) {\n            $children = $children->withKeyField();\n        }\n\n        if ($children instanceof PageCollectionInterface) {\n            $child = $children->adjacentSibling($this->getKey(), $direction);\n            if ($child instanceof PageInterface) {\n                return $child;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Helper method to return an ancestor page.\n     *\n     * @param string|null $lookup Name of the parent folder\n     * @return PageInterface|null page you were looking for if it exists\n     */\n    public function ancestor($lookup = null)\n    {\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->ancestor($this->getProperty('parent_route'), $lookup);\n    }\n\n    /**\n     * Helper method to return an ancestor page to inherit from. The current\n     * page object is returned.\n     *\n     * @param string $field Name of the parent folder\n     * @return PageInterface|null\n     */\n    public function inherited($field)\n    {\n        [$inherited, $currentParams] = $this->getInheritedParams($field);\n\n        $this->modifyHeader($field, $currentParams);\n\n        return $inherited;\n    }\n\n    /**\n     * Helper method to return an ancestor field only to inherit from. The\n     * first occurrence of an ancestor field will be returned if at all.\n     *\n     * @param string $field Name of the parent folder\n     * @return array\n     */\n    public function inheritedField($field): array\n    {\n        [, $currentParams] = $this->getInheritedParams($field);\n\n        return $currentParams;\n    }\n\n    /**\n     * Method that contains shared logic for inherited() and inheritedField()\n     *\n     * @param string $field Name of the parent folder\n     * @return array\n     */\n    protected function getInheritedParams($field): array\n    {\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        $inherited = $pages->inherited($this->getProperty('parent_route'), $field);\n        $inheritedParams = $inherited ? (array)$inherited->value('header.' . $field) : [];\n        $currentParams = (array)$this->getFormValue('header.' . $field);\n        if ($inheritedParams && is_array($inheritedParams)) {\n            $currentParams = array_replace_recursive($inheritedParams, $currentParams);\n        }\n\n        return [$inherited, $currentParams];\n    }\n\n    /**\n     * Helper method to return a page.\n     *\n     * @param string $url the url of the page\n     * @param bool $all\n     * @return PageInterface|null page you were looking for if it exists\n     */\n    public function find($url, $all = false)\n    {\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->find($url, $all);\n    }\n\n    /**\n     * Get a collection of pages in the current context.\n     *\n     * @param string|array $params\n     * @param bool $pagination\n     * @return PageCollectionInterface|Collection\n     * @throws InvalidArgumentException\n     */\n    public function collection($params = 'content', $pagination = true)\n    {\n        if (is_string($params)) {\n            // Look into a page header field.\n            $params = (array)$this->getFormValue('header.' . $params);\n        } elseif (!is_array($params)) {\n            throw new InvalidArgumentException('Argument should be either header variable name or array of parameters');\n        }\n\n        if (!$pagination) {\n            $params['pagination'] = false;\n        }\n        $context = [\n            'pagination' => $pagination,\n            'self' => $this\n        ];\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->getCollection($params, $context);\n    }\n\n    /**\n     * @param string|array $value\n     * @param bool $only_published\n     * @return PageCollectionInterface|Collection\n     */\n    public function evaluate($value, $only_published = true)\n    {\n        $params = [\n            'items' => $value,\n            'published' => $only_published\n        ];\n        $context = [\n            'event' => false,\n            'pagination' => false,\n            'url_taxonomy_filters' => false,\n            'self' => $this\n        ];\n\n        /** @var Pages $pages */\n        $pages = Grav::instance()['pages'];\n\n        return $pages->getCollection($params, $context);\n    }\n\n    /**\n     * Returns whether or not the current folder exists\n     *\n     * @return bool\n     */\n    public function folderExists(): bool\n    {\n        return $this->exists() || is_dir($this->getStorageFolder() ?? '');\n    }\n\n    /**\n     * Gets the action.\n     *\n     * @return string|null The Action string.\n     */\n    public function getAction(): ?string\n    {\n        $meta = $this->getMetaData();\n        if (!empty($meta['copy'])) {\n            return 'copy';\n        }\n        if (isset($meta['storage_key']) && $this->getStorageKey() !== $meta['storage_key']) {\n            return 'move';\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Pages/Traits/PageRoutableTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Pages\\Traits;\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Interfaces\\PageCollectionInterface;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function is_string;\n\n/**\n * Implements PageRoutableInterface\n */\ntrait PageRoutableTrait\n{\n    /** @var bool */\n    protected $root = false;\n\n    /** @var string|null */\n    private $_route;\n    /** @var string|null */\n    private $_path;\n    /** @var PageInterface|null */\n    private $_parentCache;\n\n    /**\n     * Returns the page extension, got from the page `url_extension` config and falls back to the\n     * system config `system.pages.append_url_extension`.\n     *\n     * @return string      The extension of this page. For example `.html`\n     */\n    public function urlExtension(): string\n    {\n        return $this->loadHeaderProperty(\n            'url_extension',\n            null,\n            function ($value) {\n                if ($this->home()) {\n                    return '';\n                }\n\n                return $value ?? Grav::instance()['config']->get('system.pages.append_url_extension', '');\n            }\n        );\n    }\n\n    /**\n     * Gets and Sets whether or not this Page is routable, ie you can reach it via a URL.\n     * The page must be *routable* and *published*\n     *\n     * @param  bool|null $var true if the page is routable\n     * @return bool      true if the page is routable\n     */\n    public function routable($var = null): bool\n    {\n        $value = $this->loadHeaderProperty(\n            'routable',\n            $var,\n            static function ($value) {\n                return $value ?? true;\n            }\n        );\n\n        return $value && $this->published() && !$this->isModule() && !$this->root() && $this->getLanguages(true);\n    }\n\n    /**\n     * Gets the URL for a page - alias of url().\n     *\n     * @param bool $include_host\n     * @return string the permalink\n     */\n    public function link($include_host = false): string\n    {\n        return $this->url($include_host);\n    }\n\n    /**\n     * Gets the URL with host information, aka Permalink.\n     * @return string The permalink.\n     */\n    public function permalink(): string\n    {\n        return $this->url(true, false, true, true);\n    }\n\n    /**\n     * Returns the canonical URL for a page\n     *\n     * @param bool $include_lang\n     * @return string\n     */\n    public function canonical($include_lang = true): string\n    {\n        return $this->url(true, true, $include_lang);\n    }\n\n    /**\n     * Gets the url for the Page.\n     *\n     * @param bool $include_host Defaults false, but true would include http://yourhost.com\n     * @param bool $canonical true to return the canonical URL\n     * @param bool $include_base\n     * @param bool $raw_route\n     * @return string The url.\n     */\n    public function url($include_host = false, $canonical = false, $include_base = true, $raw_route = false): string\n    {\n        // Override any URL when external_url is set\n        $external = $this->getNestedProperty('header.external_url');\n        if ($external) {\n            return $external;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        // get base route (multi-site base and language)\n        $route = $include_base ? $pages->baseRoute() : '';\n\n        // add full route if configured to do so\n        if (!$include_host && $config->get('system.absolute_urls', false)) {\n            $include_host = true;\n        }\n\n        if ($canonical) {\n            $route .= $this->routeCanonical();\n        } elseif ($raw_route) {\n            $route .= $this->rawRoute();\n        } else {\n            $route .= $this->route();\n        }\n\n        /** @var Uri $uri */\n        $uri = $grav['uri'];\n        $url = $uri->rootUrl($include_host) . '/' . trim($route, '/') . $this->urlExtension();\n\n        return Uri::filterPath($url);\n    }\n\n    /**\n     * Gets the route for the page based on the route headers if available, else from\n     * the parents route and the current Page's slug.\n     *\n     * @param  string $var Set new default route.\n     * @return string|null  The route for the Page.\n     */\n    public function route($var = null): ?string\n    {\n        if (null !== $var) {\n            // TODO: not the best approach, but works...\n            $this->setNestedProperty('header.routes.default', $var);\n        }\n\n        // Return default route if given.\n        $default = $this->getNestedProperty('header.routes.default');\n        if (is_string($default)) {\n            return $default;\n        }\n\n        return $this->routeInternal();\n    }\n\n    /**\n     * @return string|null\n     */\n    protected function routeInternal(): ?string\n    {\n        $route = $this->_route;\n        if (null !== $route) {\n            return $route;\n        }\n\n        if ($this->root()) {\n            return null;\n        }\n\n        // Root and orphan nodes have no route.\n        $parent = $this->parent();\n        if (!$parent) {\n            return null;\n        }\n\n        if ($parent->home()) {\n            /** @var Config $config */\n            $config = Grav::instance()['config'];\n            $hide = (bool)$config->get('system.home.hide_in_urls', false);\n            $route = '/' . ($hide ? '' : $parent->slug());\n        } else {\n            $route = $parent->route();\n        }\n\n        if ($route !== '' && $route !== '/') {\n            $route .= '/';\n        }\n\n        if (!$this->home()) {\n            $route .= $this->slug();\n        }\n\n        $this->_route = $route;\n\n        return $route;\n    }\n\n    /**\n     * Helper method to clear the route out so it regenerates next time you use it\n     */\n    public function unsetRouteSlug(): void\n    {\n        // TODO:\n        throw new RuntimeException(__METHOD__ . '(): Not Implemented');\n    }\n\n    /**\n     * Gets and Sets the page raw route\n     *\n     * @param string|null $var\n     * @return string|null\n     */\n    public function rawRoute($var = null): ?string\n    {\n        if (null !== $var) {\n            // TODO:\n            throw new RuntimeException(__METHOD__ . '(string): Not Implemented');\n        }\n\n        if ($this->root()) {\n            return null;\n        }\n\n        return '/' . $this->getKey();\n    }\n\n    /**\n     * Gets the route aliases for the page based on page headers.\n     *\n     * @param  array|null $var list of route aliases\n     * @return array  The route aliases for the Page.\n     */\n    public function routeAliases($var = null): array\n    {\n        if (null !== $var) {\n            $this->setNestedProperty('header.routes.aliases', (array)$var);\n        }\n\n        $aliases = (array)$this->getNestedProperty('header.routes.aliases');\n        $default = $this->getNestedProperty('header.routes.default');\n        if ($default) {\n            $aliases[] = $default;\n        }\n\n        return $aliases;\n    }\n\n    /**\n     * Gets the canonical route for this page if its set. If provided it will use\n     * that value, else if it's `true` it will use the default route.\n     *\n     * @param string|null $var\n     * @return string|null\n     */\n    public function routeCanonical($var = null): ?string\n    {\n        if (null !== $var) {\n            $this->setNestedProperty('header.routes.canonical', (array)$var);\n        }\n\n        $canonical = $this->getNestedProperty('header.routes.canonical');\n\n        return is_string($canonical) ? $canonical : $this->route();\n    }\n\n    /**\n     * Gets the redirect set in the header.\n     *\n     * @param  string|null $var redirect url\n     * @return string|null\n     */\n    public function redirect($var = null): ?string\n    {\n        return $this->loadHeaderProperty(\n            'redirect',\n            $var,\n            static function ($value) {\n                return trim($value) ?: null;\n            }\n        );\n    }\n\n    /**\n     * Returns the clean path to the page file\n     *\n     * Needed in admin for Page Media.\n     */\n    public function relativePagePath(): ?string\n    {\n        $folder = $this->getMediaFolder();\n        if (!$folder) {\n            return null;\n        }\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $path = $locator->isStream($folder) ? $locator->findResource($folder, false) : $folder;\n\n        return is_string($path) ? $path : null;\n    }\n\n    /**\n     * Gets and sets the path to the folder where the .md for this Page object resides.\n     * This is equivalent to the filePath but without the filename.\n     *\n     * @param  string|null $var the path\n     * @return string|null      the path\n     */\n    public function path($var = null): ?string\n    {\n        if (null !== $var) {\n            // TODO:\n            throw new RuntimeException(__METHOD__ . '(string): Not Implemented');\n        }\n\n        $path = $this->_path;\n        if ($path) {\n            return $path;\n        }\n\n        if ($this->root()) {\n            $folder = $this->getFlexDirectory()->getStorageFolder();\n        } else {\n            $folder = $this->getStorageFolder();\n        }\n\n        if ($folder) {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            $folder = $locator->isStream($folder) ? $locator->getResource($folder) : GRAV_ROOT . \"/{$folder}\";\n        }\n\n        return $this->_path = is_string($folder) ? $folder : null;\n    }\n\n    /**\n     * Get/set the folder.\n     *\n     * @param string|null $var Optional path, including numeric prefix.\n     * @return string|null\n     */\n    public function folder($var = null): ?string\n    {\n        return $this->loadProperty(\n            'folder',\n            $var,\n            function ($value) {\n                if (null === $value) {\n                    $value = $this->getMasterKey() ?: $this->getKey();\n                }\n\n                return Utils::basename($value) ?: null;\n            }\n        );\n    }\n\n    /**\n     * Get/set the folder.\n     *\n     * @param string|null $var Optional path, including numeric prefix.\n     * @return string|null\n     */\n    public function parentStorageKey($var = null): ?string\n    {\n        return $this->loadProperty(\n            'parent_key',\n            $var,\n            function ($value) {\n                if (null === $value) {\n                    $filesystem = Filesystem::getInstance(false);\n                    $value = $this->getMasterKey() ?: $this->getKey();\n                    $value = ltrim($filesystem->dirname(\"/{$value}\"), '/') ?: '';\n                }\n\n                return $value;\n            }\n        );\n    }\n\n    /**\n     * Gets and Sets the parent object for this page\n     *\n     * @param  PageInterface|null $var the parent page object\n     * @return PageInterface|null the parent page object if it exists.\n     */\n    public function parent(PageInterface $var = null)\n    {\n        if (null !== $var) {\n            // TODO:\n            throw new RuntimeException(__METHOD__ . '(PageInterface): Not Implemented');\n        }\n\n        if ($this->_parentCache || $this->root()) {\n            return $this->_parentCache;\n        }\n\n        // Use filesystem as \\dirname() does not work in Windows because of '/foo' becomes '\\'.\n        $filesystem = Filesystem::getInstance(false);\n        $directory = $this->getFlexDirectory();\n        $parentKey = ltrim($filesystem->dirname(\"/{$this->getKey()}\"), '/');\n        if ('' !== $parentKey) {\n            $parent = $directory->getObject($parentKey);\n            $language = $this->getLanguage();\n            if ($language && $parent && method_exists($parent, 'getTranslation')) {\n                $parent = $parent->getTranslation($language) ?? $parent;\n            }\n\n            $this->_parentCache = $parent;\n        } else {\n            $index = $directory->getIndex();\n\n            $this->_parentCache = \\is_callable([$index, 'getRoot']) ? $index->getRoot() : null;\n        }\n\n        return $this->_parentCache;\n    }\n\n    /**\n     * Gets the top parent object for this page. Can return page itself.\n     *\n     * @return PageInterface The top parent page object.\n     */\n    public function topParent()\n    {\n        $topParent = $this;\n        while ($topParent) {\n            $parent = $topParent->parent();\n            if (!$parent || !$parent->parent()) {\n                break;\n            }\n            $topParent = $parent;\n        }\n\n        return $topParent;\n    }\n\n    /**\n     * Returns the item in the current position.\n     *\n     * @return int|null   the index of the current page.\n     */\n    public function currentPosition(): ?int\n    {\n        $parent = $this->parent();\n        $collection = $parent ? $parent->collection('content', false) : null;\n        if ($collection instanceof PageCollectionInterface && $path = $this->path()) {\n            return $collection->currentPosition($path);\n        }\n\n        return 1;\n    }\n\n    /**\n     * Returns whether or not this page is the currently active page requested via the URL.\n     *\n     * @return bool True if it is active\n     */\n    public function active(): bool\n    {\n        $grav = Grav::instance();\n        $uri_path = rtrim(urldecode($grav['uri']->path()), '/') ?: '/';\n        $routes = $grav['pages']->routes();\n\n        return isset($routes[$uri_path]) && $routes[$uri_path] === $this->path();\n    }\n\n    /**\n     * Returns whether or not this URI's URL contains the URL of the active page.\n     * Or in other words, is this page's URL in the current URL\n     *\n     * @return bool True if active child exists\n     */\n    public function activeChild(): bool\n    {\n        $grav = Grav::instance();\n        /** @var Uri $uri */\n        $uri = $grav['uri'];\n        /** @var Pages $pages */\n        $pages = $grav['pages'];\n        $uri_path = rtrim(urldecode($uri->path()), '/');\n        $routes = $pages->routes();\n\n        if (isset($routes[$uri_path])) {\n            $page = $pages->find($uri->route());\n            /** @var PageInterface|null $child_page */\n            $child_page = $page ? $page->parent() : null;\n            while ($child_page && !$child_page->root()) {\n                if ($this->path() === $child_page->path()) {\n                    return true;\n                }\n                $child_page = $child_page->parent();\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Returns whether or not this page is the currently configured home page.\n     *\n     * @return bool True if it is the homepage\n     */\n    public function home(): bool\n    {\n        $home = Grav::instance()['config']->get('system.home.alias');\n\n        return '/' . $this->getKey() === $home;\n    }\n\n    /**\n     * Returns whether or not this page is the root node of the pages tree.\n     *\n     * @param bool|null $var\n     * @return bool True if it is the root\n     */\n    public function root($var = null): bool\n    {\n        if (null !== $var) {\n            $this->root = (bool)$var;\n        }\n\n        return $this->root === true || $this->getKey() === '/';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Pages/Traits/PageTranslateTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Pages\\Traits;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Language\\Language;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse function is_bool;\n\n/**\n * Implements PageTranslateInterface\n */\ntrait PageTranslateTrait\n{\n    /** @var array|null */\n    private $_languages;\n\n    /** @var PageInterface[] */\n    private $_translations = [];\n\n    /**\n     * @return bool\n     */\n    public function translated(): bool\n    {\n        return (bool)$this->translatedLanguages(true);\n    }\n\n    /**\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return bool\n     */\n    public function hasTranslation(string $languageCode = null, bool $fallback = null): bool\n    {\n        $code = $this->findTranslation($languageCode, $fallback);\n\n        return null !== $code;\n    }\n\n    /**\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return FlexObjectInterface|PageInterface|null\n     */\n    public function getTranslation(string $languageCode = null, bool $fallback = null)\n    {\n        if ($this->root()) {\n            return $this;\n        }\n\n        $code = $this->findTranslation($languageCode, $fallback);\n        if (null === $code) {\n            $object = null;\n        } elseif ('' === $code) {\n            $object = $this->getLanguage() ? $this->getFlexDirectory()->getObject($this->getMasterKey(), 'storage_key') : $this;\n        } else {\n            $meta = $this->getMetaData();\n            $meta['template'] = $this->getLanguageTemplates()[$code] ?? $meta['template'];\n            $key = $this->getStorageKey() . '|' . $meta['template'] . '.' . $code;\n            $meta['storage_key'] = $key;\n            $meta['lang'] = $code;\n            $object = $this->getFlexDirectory()->loadObjects([$key => $meta])[$key] ?? null;\n        }\n\n        return $object;\n    }\n\n    /**\n     * @param bool $includeDefault If set to true, return separate entries for '' and 'en' (default) language.\n     * @return array\n     */\n    public function getAllLanguages(bool $includeDefault = false): array\n    {\n        $grav = Grav::instance();\n\n        /** @var Language $language */\n        $language = $grav['language'];\n        $languages = $language->getLanguages();\n        if (!$languages) {\n            return [];\n        }\n\n        $translated = $this->getLanguageTemplates();\n\n        if ($includeDefault) {\n            $languages[] = '';\n        } elseif (isset($translated[''])) {\n            $default = $language->getDefault();\n            if (is_bool($default)) {\n                $default = '';\n            }\n            $translated[$default] = $translated[''];\n            unset($translated['']);\n        }\n\n        $languages = array_fill_keys($languages, false);\n        $translated = array_fill_keys(array_keys($translated), true);\n\n        return array_replace($languages, $translated);\n    }\n\n    /**\n     * Returns all translated languages.\n     *\n     * @param bool $includeDefault If set to true, return separate entries for '' and 'en' (default) language.\n     * @return array\n     */\n    public function getLanguages(bool $includeDefault = false): array\n    {\n        $languages = $this->getLanguageTemplates();\n\n        if (!$includeDefault && isset($languages[''])) {\n            $grav = Grav::instance();\n\n            /** @var Language $language */\n            $language = $grav['language'];\n            $default = $language->getDefault();\n            if (is_bool($default)) {\n                $default = '';\n            }\n            $languages[$default] = $languages[''];\n            unset($languages['']);\n        }\n\n        return array_keys($languages);\n    }\n\n    /**\n     * @return string\n     */\n    public function getLanguage(): string\n    {\n        return $this->language() ?? '';\n    }\n\n    /**\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return string|null\n     */\n    public function findTranslation(string $languageCode = null, bool $fallback = null): ?string\n    {\n        $translated = $this->getLanguageTemplates();\n\n        // If there's no translations (including default), we have an empty folder.\n        if (!$translated) {\n            return '';\n        }\n\n        // FIXME: only published is not implemented...\n        $languages = $this->getFallbackLanguages($languageCode, $fallback);\n\n        $language = null;\n        foreach ($languages as $code) {\n            if (isset($translated[$code])) {\n                $language = $code;\n                break;\n            }\n        }\n\n        return $language;\n    }\n\n    /**\n     * Return an array with the routes of other translated languages\n     *\n     * @param bool $onlyPublished only return published translations\n     * @return array the page translated languages\n     */\n    public function translatedLanguages($onlyPublished = false): array\n    {\n        // FIXME: only published is not implemented...\n        $translated = $this->getLanguageTemplates();\n        if (!$translated) {\n            return $translated;\n        }\n\n        $grav = Grav::instance();\n\n        /** @var Language $language */\n        $language = $grav['language'];\n        $languages = $language->getLanguages();\n        $languages[] = '';\n\n        $translated = array_intersect_key($translated, array_flip($languages));\n        $list = array_fill_keys($languages, null);\n        foreach ($translated as $languageCode => $languageFile) {\n            $path = ($languageCode ? '/' : '') . $languageCode;\n            $list[$languageCode] = \"{$path}/{$this->getKey()}\";\n        }\n\n        return array_filter($list);\n    }\n\n    /**\n     * Return an array listing untranslated languages available\n     *\n     * @param bool $includeUnpublished also list unpublished translations\n     * @return array the page untranslated languages\n     */\n    public function untranslatedLanguages($includeUnpublished = false): array\n    {\n        $grav = Grav::instance();\n\n        /** @var Language $language */\n        $language = $grav['language'];\n\n        $languages = $language->getLanguages();\n        $translated = array_keys($this->translatedLanguages(!$includeUnpublished));\n\n        return array_values(array_diff($languages, $translated));\n    }\n\n    /**\n     * Get page language\n     *\n     * @param string|null $var\n     * @return string|null\n     */\n    public function language($var = null): ?string\n    {\n        return $this->loadHeaderProperty(\n            'lang',\n            $var,\n            function ($value) {\n                $value = $value ?? $this->getMetaData()['lang'] ?? '';\n\n                return trim($value) ?: null;\n            }\n        );\n    }\n\n    /**\n     * @return array\n     */\n    protected function getLanguageTemplates(): array\n    {\n        if (null === $this->_languages) {\n            $template = $this->getProperty('template');\n            $meta = $this->getMetaData();\n            $translations = $meta['markdown'] ?? [];\n            $list = [];\n            foreach ($translations as $code => $search) {\n                if (isset($search[$template])) {\n                    // Use main template if possible.\n                    $list[$code] = $template;\n                } elseif (!empty($search)) {\n                    // Fall back to first matching template.\n                    $list[$code] = key($search);\n                }\n            }\n\n            $this->_languages = $list;\n        }\n\n        return $this->_languages;\n    }\n\n    /**\n     * @param string|null $languageCode\n     * @param bool|null $fallback\n     * @return array\n     */\n    protected function getFallbackLanguages(string $languageCode = null, bool $fallback = null): array\n    {\n        $fallback = $fallback ?? true;\n        if (!$fallback && null !== $languageCode) {\n            return [$languageCode];\n        }\n\n        $grav = Grav::instance();\n\n        /** @var Language $language */\n        $language = $grav['language'];\n        $languageCode = $languageCode ?? ($language->getLanguage() ?: '');\n        if ($languageCode === '' && $fallback) {\n            return $language->getFallbackLanguages(null, true);\n        }\n\n        return $fallback ? $language->getFallbackLanguages($languageCode, true) : [$languageCode];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Storage;\n\nuse Grav\\Common\\File\\CompiledJsonFile;\nuse Grav\\Common\\File\\CompiledMarkdownFile;\nuse Grav\\Common\\File\\CompiledYamlFile;\nuse Grav\\Common\\Grav;\nuse Grav\\Framework\\File\\Formatter\\JsonFormatter;\nuse Grav\\Framework\\File\\Formatter\\MarkdownFormatter;\nuse Grav\\Framework\\File\\Formatter\\YamlFormatter;\nuse Grav\\Framework\\File\\Interfaces\\FileFormatterInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexStorageInterface;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function is_array;\n\n/**\n * Class AbstractFilesystemStorage\n * @package Grav\\Framework\\Flex\\Storage\n */\nabstract class AbstractFilesystemStorage implements FlexStorageInterface\n{\n    /** @var FileFormatterInterface */\n    protected $dataFormatter;\n    /** @var string */\n    protected $keyField = 'storage_key';\n    /** @var int */\n    protected $keyLen = 32;\n    /** @var bool */\n    protected $caseSensitive = true;\n\n    /**\n     * @return bool\n     */\n    public function isIndexed(): bool\n    {\n        return false;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::hasKeys()\n     */\n    public function hasKeys(array $keys): array\n    {\n        $list = [];\n        foreach ($keys as $key) {\n            $list[$key] = $this->hasKey((string)$key);\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritDoc}\n     * @see FlexStorageInterface::getKeyField()\n     */\n    public function getKeyField(): string\n    {\n        return $this->keyField;\n    }\n\n    /**\n     * @param array $keys\n     * @param bool $includeParams\n     * @return string\n     */\n    public function buildStorageKey(array $keys, bool $includeParams = true): string\n    {\n        $key = $keys['key'] ?? '';\n        $params = $includeParams ? $this->buildStorageKeyParams($keys) : '';\n\n        return $params ? \"{$key}|{$params}\" : $key;\n    }\n\n    /**\n     * @param array $keys\n     * @return string\n     */\n    public function buildStorageKeyParams(array $keys): string\n    {\n        return '';\n    }\n\n    /**\n     * @param array $row\n     * @return array\n     */\n    public function extractKeysFromRow(array $row): array\n    {\n        return [\n            'key' => $this->normalizeKey($row[$this->keyField] ?? '')\n        ];\n    }\n\n    /**\n     * @param string $key\n     * @return array\n     */\n    public function extractKeysFromStorageKey(string $key): array\n    {\n        return [\n            'key' => $key\n        ];\n    }\n\n    /**\n     * @param string|array $formatter\n     * @return void\n     */\n    protected function initDataFormatter($formatter): void\n    {\n        // Initialize formatter.\n        if (!is_array($formatter)) {\n            $formatter = ['class' => $formatter];\n        }\n        $formatterClassName = $formatter['class'] ?? JsonFormatter::class;\n        $formatterOptions = $formatter['options'] ?? [];\n\n        if (!is_a($formatterClassName, FileFormatterInterface::class, true)) {\n            throw new \\InvalidArgumentException('Bad Data Formatter');\n        }\n\n        $this->dataFormatter = new $formatterClassName($formatterOptions);\n    }\n\n    /**\n     * @param string $filename\n     * @return string|null\n     */\n    protected function detectDataFormatter(string $filename): ?string\n    {\n        if (preg_match('|(\\.[a-z0-9]*)$|ui', $filename, $matches)) {\n            switch ($matches[1]) {\n                case '.json':\n                    return JsonFormatter::class;\n                case '.yaml':\n                    return YamlFormatter::class;\n                case '.md':\n                    return MarkdownFormatter::class;\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * @param string $filename\n     * @return CompiledJsonFile|CompiledYamlFile|CompiledMarkdownFile\n     */\n    protected function getFile(string $filename)\n    {\n        $filename = $this->resolvePath($filename);\n\n        // TODO: start using the new file classes.\n        switch ($this->dataFormatter->getDefaultFileExtension()) {\n            case '.json':\n                $file = CompiledJsonFile::instance($filename);\n                break;\n            case '.yaml':\n                $file = CompiledYamlFile::instance($filename);\n                break;\n            case '.md':\n                $file = CompiledMarkdownFile::instance($filename);\n                break;\n            default:\n                throw new RuntimeException('Unknown extension type ' . $this->dataFormatter->getDefaultFileExtension());\n        }\n\n        return $file;\n    }\n\n    /**\n     * @param string $path\n     * @return string\n     */\n    protected function resolvePath(string $path): string\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n\n        if (!$locator->isStream($path)) {\n            return GRAV_ROOT . \"/{$path}\";\n        }\n\n        return $locator->getResource($path);\n    }\n\n    /**\n     * Generates a random, unique key for the row.\n     *\n     * @return string\n     */\n    protected function generateKey(): string\n    {\n        return substr(hash('sha256', random_bytes($this->keyLen)), 0, $this->keyLen);\n    }\n\n    /**\n     * @param string $key\n     * @return string\n     */\n    public function normalizeKey(string $key): string\n    {\n        if ($this->caseSensitive === true) {\n            return $key;\n        }\n\n        return mb_strtolower($key);\n    }\n\n    /**\n     * Checks if a key is valid.\n     *\n     * @param  string $key\n     * @return bool\n     */\n    protected function validateKey(string $key): bool\n    {\n        return $key && (bool) preg_match('/^[^\\\\/?*:;{}\\\\\\\\\\\\n]+$/u', $key);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Storage/FileStorage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Storage;\n\nuse FilesystemIterator;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexStorageInterface;\nuse RuntimeException;\nuse SplFileInfo;\n\n/**\n * Class FileStorage\n * @package Grav\\Framework\\Flex\\Storage\n */\nclass FileStorage extends FolderStorage\n{\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::__construct()\n     */\n    public function __construct(array $options)\n    {\n        $this->dataPattern = '{FOLDER}/{KEY}{EXT}';\n\n        if (!isset($options['formatter']) && isset($options['pattern'])) {\n            $options['formatter'] = $this->detectDataFormatter($options['pattern']);\n        }\n\n        parent::__construct($options);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::getMediaPath()\n     */\n    public function getMediaPath(string $key = null): ?string\n    {\n        $path = $this->getStoragePath();\n        if (!$path) {\n            return null;\n        }\n\n        return $key ? \"{$path}/{$key}\" : $path;\n    }\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     */\n    public function copyRow(string $src, string $dst): bool\n    {\n        if ($this->hasKey($dst)) {\n            throw new RuntimeException(\"Cannot copy object: key '{$dst}' is already taken\");\n        }\n\n        if (!$this->hasKey($src)) {\n            return false;\n        }\n\n        return true;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::renameRow()\n     */\n    public function renameRow(string $src, string $dst): bool\n    {\n        if (!$this->hasKey($src)) {\n            return false;\n        }\n\n        // Remove old file.\n        $path = $this->getPathFromKey($src);\n        $file = $this->getFile($path);\n        $file->delete();\n        $file->free();\n        unset($file);\n\n        return true;\n    }\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     */\n    protected function copyFolder(string $src, string $dst): bool\n    {\n        // Nothing to copy.\n        return true;\n    }\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     */\n    protected function moveFolder(string $src, string $dst): bool\n    {\n        // Nothing to move.\n        return true;\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     */\n    protected function canDeleteFolder(string $key): bool\n    {\n        return false;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    protected function getKeyFromPath(string $path): string\n    {\n        return Utils::basename($path, $this->dataFormatter->getDefaultFileExtension());\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    protected function buildIndex(): array\n    {\n        $this->clearCache();\n\n        $path = $this->getStoragePath();\n        if (!$path || !file_exists($path)) {\n            return [];\n        }\n\n        $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS;\n        $iterator = new FilesystemIterator($path, $flags);\n        $list = [];\n        /** @var SplFileInfo $info */\n        foreach ($iterator as $filename => $info) {\n            if (!$info->isFile() || !($key = $this->getKeyFromPath($filename)) || strpos($info->getFilename(), '.') === 0) {\n                continue;\n            }\n\n            $list[$key] = $this->getObjectMeta($key);\n        }\n\n        ksort($list, SORT_NATURAL | SORT_FLAG_CASE);\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Storage/FolderStorage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Storage;\n\nuse FilesystemIterator;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexStorageInterface;\nuse RocketTheme\\Toolbox\\File\\File;\nuse InvalidArgumentException;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse SplFileInfo;\nuse function array_key_exists;\nuse function basename;\nuse function count;\nuse function is_scalar;\nuse function is_string;\nuse function mb_strpos;\nuse function mb_substr;\n\n/**\n * Class FolderStorage\n * @package Grav\\Framework\\Flex\\Storage\n */\nclass FolderStorage extends AbstractFilesystemStorage\n{\n    /** @var string Folder where all the data is stored. */\n    protected $dataFolder;\n    /** @var string Pattern to access an object. */\n    protected $dataPattern = '{FOLDER}/{KEY}/{FILE}{EXT}';\n    /** @var string[] */\n    protected $variables = ['FOLDER' => '%1$s', 'KEY' => '%2$s', 'KEY:2' => '%3$s', 'FILE' => '%4$s', 'EXT' => '%5$s'];\n    /** @var string Filename for the object. */\n    protected $dataFile;\n    /** @var string File extension for the object. */\n    protected $dataExt;\n    /** @var bool */\n    protected $prefixed;\n    /** @var bool */\n    protected $indexed;\n    /** @var array */\n    protected $meta = [];\n\n    /**\n     * {@inheritdoc}\n     */\n    public function __construct(array $options)\n    {\n        if (!isset($options['folder'])) {\n            throw new InvalidArgumentException(\"Argument \\$options is missing 'folder'\");\n        }\n\n        $this->initDataFormatter($options['formatter'] ?? []);\n        $this->initOptions($options);\n    }\n\n    /**\n     * @return bool\n     */\n    public function isIndexed(): bool\n    {\n        return $this->indexed;\n    }\n\n    /**\n     * @return void\n     */\n    public function clearCache(): void\n    {\n        $this->meta = [];\n    }\n\n    /**\n     * @param string[] $keys\n     * @param bool $reload\n     * @return array\n     */\n    public function getMetaData(array $keys, bool $reload = false): array\n    {\n        $list = [];\n        foreach ($keys as $key) {\n            $list[$key] = $this->getObjectMeta((string)$key, $reload);\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::getExistingKeys()\n     */\n    public function getExistingKeys(): array\n    {\n        return $this->buildIndex();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::hasKey()\n     */\n    public function hasKey(string $key): bool\n    {\n        $meta = $this->getObjectMeta($key);\n\n        return array_key_exists('exists', $meta) ? $meta['exists'] : !empty($meta['storage_timestamp']);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::createRows()\n     */\n    public function createRows(array $rows): array\n    {\n        $list = [];\n        foreach ($rows as $key => $row) {\n            $list[$key] = $this->saveRow('@@', $row);\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::readRows()\n     */\n    public function readRows(array $rows, array &$fetched = null): array\n    {\n        $list = [];\n        foreach ($rows as $key => $row) {\n            if (null === $row || is_scalar($row)) {\n                // Only load rows which haven't been loaded before.\n                $key = (string)$key;\n                $list[$key] = $this->loadRow($key);\n\n                if (null !== $fetched) {\n                    $fetched[$key] = $list[$key];\n                }\n            } else {\n                // Keep the row if it has been loaded.\n                $list[$key] = $row;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::updateRows()\n     */\n    public function updateRows(array $rows): array\n    {\n        $list = [];\n        foreach ($rows as $key => $row) {\n            $key = (string)$key;\n            $list[$key] = $this->hasKey($key) ? $this->saveRow($key, $row) : null;\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::deleteRows()\n     */\n    public function deleteRows(array $rows): array\n    {\n        $list = [];\n        $baseMediaPath = $this->getMediaPath();\n        foreach ($rows as $key => $row) {\n            $key = (string)$key;\n            if (!$this->hasKey($key)) {\n                $list[$key] = null;\n            } else {\n                $path = $this->getPathFromKey($key);\n                $file = $this->getFile($path);\n                $list[$key] = $this->deleteFile($file);\n\n                if ($this->canDeleteFolder($key)) {\n                    $storagePath = $this->getStoragePath($key);\n                    $mediaPath = $this->getMediaPath($key);\n\n                    if ($storagePath) {\n                        $this->deleteFolder($storagePath, true);\n                    }\n                    if ($mediaPath && $mediaPath !== $storagePath && $mediaPath !== $baseMediaPath) {\n                        $this->deleteFolder($mediaPath, true);\n                    }\n                }\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::replaceRows()\n     */\n    public function replaceRows(array $rows): array\n    {\n        $list = [];\n        foreach ($rows as $key => $row) {\n            $key = (string)$key;\n            $list[$key] = $this->saveRow($key, $row);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     * @throws RuntimeException\n     */\n    public function copyRow(string $src, string $dst): bool\n    {\n        if ($this->hasKey($dst)) {\n            throw new RuntimeException(\"Cannot copy object: key '{$dst}' is already taken\");\n        }\n\n        if (!$this->hasKey($src)) {\n            return false;\n        }\n\n        $srcPath = $this->getStoragePath($src);\n        $dstPath = $this->getStoragePath($dst);\n        if (!$srcPath || !$dstPath) {\n            return false;\n        }\n\n        return $this->copyFolder($srcPath, $dstPath);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::renameRow()\n     * @throws RuntimeException\n     */\n    public function renameRow(string $src, string $dst): bool\n    {\n        if (!$this->hasKey($src)) {\n            return false;\n        }\n\n        $srcPath = $this->getStoragePath($src);\n        $dstPath = $this->getStoragePath($dst);\n        if (!$srcPath || !$dstPath) {\n            throw new RuntimeException(\"Destination path '{$dst}' is empty\");\n        }\n\n        if ($srcPath === $dstPath) {\n            return true;\n        }\n\n        if ($this->hasKey($dst)) {\n            throw new RuntimeException(\"Cannot rename object '{$src}': key '{$dst}' is already taken $srcPath $dstPath\");\n        }\n\n        return $this->moveFolder($srcPath, $dstPath);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::getStoragePath()\n     */\n    public function getStoragePath(string $key = null): ?string\n    {\n        if (null === $key || $key === '') {\n            $path = $this->dataFolder;\n        } else {\n            $parts = $this->parseKey($key, false);\n            $options = [\n                $this->dataFolder,      // {FOLDER}\n                $parts['key'],          // {KEY}\n                $parts['key:2'],        // {KEY:2}\n                '***',                  // {FILE}\n                '***'                   // {EXT}\n            ];\n\n            $path = rtrim(explode('***', sprintf($this->dataPattern, ...$options))[0], '/');\n        }\n\n        return $path;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::getMediaPath()\n     */\n    public function getMediaPath(string $key = null): ?string\n    {\n        return $this->getStoragePath($key);\n    }\n\n    /**\n     * Get filesystem path from the key.\n     *\n     * @param string $key\n     * @return string\n     */\n    public function getPathFromKey(string $key): string\n    {\n        $parts = $this->parseKey($key);\n        $options = [\n            $this->dataFolder,      // {FOLDER}\n            $parts['key'],          // {KEY}\n            $parts['key:2'],        // {KEY:2}\n            $parts['file'],         // {FILE}\n            $this->dataExt          // {EXT}\n        ];\n\n        return sprintf($this->dataPattern, ...$options);\n    }\n\n    /**\n     * @param string $key\n     * @param bool $variations\n     * @return array\n     */\n    public function parseKey(string $key, bool $variations = true): array\n    {\n        $keys = [\n            'key' => $key,\n            'key:2' => mb_substr($key, 0, 2),\n        ];\n        if ($variations) {\n            $keys['file'] = $this->dataFile;\n        }\n\n        return $keys;\n    }\n\n    /**\n     * Get key from the filesystem path.\n     *\n     * @param  string $path\n     * @return string\n     */\n    protected function getKeyFromPath(string $path): string\n    {\n        return Utils::basename($path);\n    }\n\n    /**\n     * Prepares the row for saving and returns the storage key for the record.\n     *\n     * @param array $row\n     * @return void\n     */\n    protected function prepareRow(array &$row): void\n    {\n        if (array_key_exists($this->keyField, $row)) {\n            $key = $row[$this->keyField];\n            if ($key === $this->normalizeKey($key)) {\n                unset($row[$this->keyField]);\n            }\n        }\n    }\n\n    /**\n     * @param string $key\n     * @return array\n     */\n    protected function loadRow(string $key): ?array\n    {\n        $path = $this->getPathFromKey($key);\n        $file = $this->getFile($path);\n        try {\n            $data = (array)$file->content();\n            if (isset($data[0])) {\n                throw new RuntimeException('Broken object file');\n            }\n\n            // Add key field to the object.\n            $keyField = $this->keyField;\n            if ($keyField !== 'storage_key' && !isset($data[$keyField])) {\n                $data[$keyField] = $key;\n            }\n        } catch (RuntimeException $e) {\n            $data = ['__ERROR' => $e->getMessage()];\n        } finally {\n            $file->free();\n            unset($file);\n        }\n\n        $data['__META'] = $this->getObjectMeta($key);\n\n        return $data;\n    }\n\n    /**\n     * @param string $key\n     * @param array $row\n     * @return array\n     */\n    protected function saveRow(string $key, array $row): array\n    {\n        try {\n            if (isset($row[$this->keyField])) {\n                $key = $row[$this->keyField];\n            }\n            if (strpos($key, '@@') !== false) {\n                $key = $this->getNewKey();\n            }\n\n            $key = $this->normalizeKey($key);\n\n            // Check if the row already exists and if the key has been changed.\n            $oldKey = $row['__META']['storage_key'] ?? null;\n            if (is_string($oldKey) && $oldKey !== $key) {\n                $isCopy = $row['__META']['copy'] ?? false;\n                if ($isCopy) {\n                    $this->copyRow($oldKey, $key);\n                } else {\n                    $this->renameRow($oldKey, $key);\n                }\n            }\n\n            $this->prepareRow($row);\n            unset($row['__META'], $row['__ERROR']);\n\n            $path = $this->getPathFromKey($key);\n            $file = $this->getFile($path);\n\n            $file->save($row);\n\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf('Flex saveFile(%s): %s', $path ?? $key, $e->getMessage()));\n        } finally {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            $locator->clearCache();\n\n            if (isset($file)) {\n                $file->free();\n                unset($file);\n            }\n        }\n\n        $row['__META'] = $this->getObjectMeta($key, true);\n\n        return $row;\n    }\n\n    /**\n     * @param File $file\n     * @return array|string\n     */\n    protected function deleteFile(File $file)\n    {\n        $filename = $file->filename();\n        try {\n            $data = $file->content();\n            if ($file->exists()) {\n                $file->delete();\n            }\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf('Flex deleteFile(%s): %s', $filename, $e->getMessage()));\n        } finally {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            $locator->clearCache();\n\n            $file->free();\n        }\n\n        return $data;\n    }\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     */\n    protected function copyFolder(string $src, string $dst): bool\n    {\n        try {\n            Folder::copy($this->resolvePath($src), $this->resolvePath($dst));\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf('Flex copyFolder(%s, %s): %s', $src, $dst, $e->getMessage()));\n        } finally {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            $locator->clearCache();\n        }\n\n        return true;\n    }\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     */\n    protected function moveFolder(string $src, string $dst): bool\n    {\n        try {\n            Folder::move($this->resolvePath($src), $this->resolvePath($dst));\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf('Flex moveFolder(%s, %s): %s', $src, $dst, $e->getMessage()));\n        } finally {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            $locator->clearCache();\n        }\n\n        return true;\n    }\n\n    /**\n     * @param string $path\n     * @param bool $include_target\n     * @return bool\n     */\n    protected function deleteFolder(string $path, bool $include_target = false): bool\n    {\n        try {\n            return Folder::delete($this->resolvePath($path), $include_target);\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf('Flex deleteFolder(%s): %s', $path, $e->getMessage()));\n        } finally {\n            /** @var UniformResourceLocator $locator */\n            $locator = Grav::instance()['locator'];\n            $locator->clearCache();\n        }\n    }\n\n    /**\n     * @param string $key\n     * @return bool\n     */\n    protected function canDeleteFolder(string $key): bool\n    {\n        return true;\n    }\n\n    /**\n     * Returns list of all stored keys in [key => timestamp] pairs.\n     *\n     * @return array\n     */\n    protected function buildIndex(): array\n    {\n        $this->clearCache();\n\n        $path = $this->getStoragePath();\n        if (!$path || !file_exists($path)) {\n            return [];\n        }\n\n        if ($this->prefixed) {\n            $list = $this->buildPrefixedIndexFromFilesystem($path);\n        } else {\n            $list = $this->buildIndexFromFilesystem($path);\n        }\n\n        ksort($list, SORT_NATURAL | SORT_FLAG_CASE);\n\n        return $list;\n    }\n\n    /**\n     * @param string $key\n     * @param bool $reload\n     * @return array\n     */\n    protected function getObjectMeta(string $key, bool $reload = false): array\n    {\n        if (!$reload && isset($this->meta[$key])) {\n            return $this->meta[$key];\n        }\n\n        if ($key && strpos($key, '@@') === false) {\n            $filename = $this->getPathFromKey($key);\n            $modified = is_file($filename) ? filemtime($filename) : 0;\n        } else {\n            $modified = 0;\n        }\n\n        $meta = [\n            'storage_key' => $key,\n            'storage_timestamp' => $modified\n        ];\n\n        $this->meta[$key] = $meta;\n\n        return $meta;\n    }\n\n    /**\n     * @param string $path\n     * @return array\n     */\n    protected function buildIndexFromFilesystem($path)\n    {\n        $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS;\n\n        $iterator = new FilesystemIterator($path, $flags);\n        $list = [];\n        /** @var SplFileInfo $info */\n        foreach ($iterator as $filename => $info) {\n            if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) {\n                continue;\n            }\n\n            $key = $this->getKeyFromPath($filename);\n            $meta = $this->getObjectMeta($key);\n            if ($meta['storage_timestamp']) {\n                $list[$key] = $meta;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $path\n     * @return array\n     */\n    protected function buildPrefixedIndexFromFilesystem($path)\n    {\n        $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS;\n\n        $iterator = new FilesystemIterator($path, $flags);\n        $list = [[]];\n        /** @var SplFileInfo $info */\n        foreach ($iterator as $filename => $info) {\n            if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) {\n                continue;\n            }\n\n            $list[] = $this->buildIndexFromFilesystem($filename);\n        }\n\n        return array_merge(...$list);\n    }\n\n    /**\n     * @return string\n     */\n    protected function getNewKey(): string\n    {\n        // Make sure that the file doesn't exist.\n        do {\n            $key = $this->generateKey();\n        } while (file_exists($this->getPathFromKey($key)));\n\n        return $key;\n    }\n\n    /**\n     * @param array $options\n     * @return void\n     */\n    protected function initOptions(array $options): void\n    {\n        $extension = $this->dataFormatter->getDefaultFileExtension();\n\n        /** @var string $pattern */\n        $pattern = !empty($options['pattern']) ? $options['pattern'] : $this->dataPattern;\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n        $folder = $options['folder'];\n        if ($locator->isStream($folder)) {\n            $folder = $locator->getResource($folder, false);\n        }\n\n        $this->dataFolder = $folder;\n        $this->dataFile = $options['file'] ?? 'item';\n        $this->dataExt = $extension;\n        if (mb_strpos($pattern, '{FILE}') === false && mb_strpos($pattern, '{EXT}') === false) {\n            if (isset($options['file'])) {\n                $pattern .= '/{FILE}{EXT}';\n            } else {\n                $filesystem = Filesystem::getInstance(true);\n                $this->dataFile = Utils::basename($pattern, $extension);\n                $pattern = $filesystem->dirname($pattern) . '/{FILE}{EXT}';\n            }\n        }\n        $this->prefixed = (bool)($options['prefixed'] ?? strpos($pattern, '/{KEY:2}/'));\n        $this->indexed = (bool)($options['indexed'] ?? false);\n        $this->keyField = $options['key'] ?? 'storage_key';\n        $this->keyLen = (int)($options['key_len'] ?? 32);\n        $this->caseSensitive = (bool)($options['case_sensitive'] ?? true);\n\n        $pattern = Utils::simpleTemplate($pattern, $this->variables);\n        if (!$pattern) {\n            throw new RuntimeException('Bad storage folder pattern');\n        }\n\n        $this->dataPattern = $pattern;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Storage/SimpleStorage.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Storage;\n\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse InvalidArgumentException;\nuse LogicException;\nuse RuntimeException;\nuse function is_scalar;\nuse function is_string;\n\n/**\n * Class SimpleStorage\n * @package Grav\\Framework\\Flex\\Storage\n */\nclass SimpleStorage extends AbstractFilesystemStorage\n{\n    /** @var string */\n    protected $dataFolder;\n    /** @var string */\n    protected $dataPattern;\n    /** @var string|null */\n    protected $prefix;\n    /** @var array|null */\n    protected $data;\n    /** @var int */\n    protected $modified = 0;\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::__construct()\n     */\n    public function __construct(array $options)\n    {\n        if (!isset($options['folder'])) {\n            throw new InvalidArgumentException(\"Argument \\$options is missing 'folder'\");\n        }\n\n        $formatter = $options['formatter'] ?? $this->detectDataFormatter($options['folder']);\n        $this->initDataFormatter($formatter);\n\n        $filesystem = Filesystem::getInstance(true);\n\n        $extension = $this->dataFormatter->getDefaultFileExtension();\n        $pattern = Utils::basename($options['folder']);\n\n        $this->dataPattern = Utils::basename($pattern, $extension) . $extension;\n        $this->dataFolder = $filesystem->dirname($options['folder']);\n        $this->keyField = $options['key'] ?? 'storage_key';\n        $this->keyLen = (int)($options['key_len'] ?? 32);\n        $this->prefix = $options['prefix'] ?? null;\n\n        // Make sure that the data folder exists.\n        if (!file_exists($this->dataFolder)) {\n            try {\n                Folder::create($this->dataFolder);\n            } catch (RuntimeException $e) {\n                throw new RuntimeException(sprintf('Flex: %s', $e->getMessage()));\n            }\n        }\n    }\n\n    /**\n     * @return void\n     */\n    public function clearCache(): void\n    {\n        $this->data = null;\n        $this->modified = 0;\n    }\n\n    /**\n     * @param string[] $keys\n     * @param bool $reload\n     * @return array\n     */\n    public function getMetaData(array $keys, bool $reload = false): array\n    {\n        if (null === $this->data || $reload) {\n            $this->buildIndex();\n        }\n\n        $list = [];\n        foreach ($keys as $key) {\n            $list[$key] = $this->getObjectMeta((string)$key);\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::getExistingKeys()\n     */\n    public function getExistingKeys(): array\n    {\n        return $this->buildIndex();\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::hasKey()\n     */\n    public function hasKey(string $key): bool\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        return $key && strpos($key, '@@') === false && isset($this->data[$key]);\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::createRows()\n     */\n    public function createRows(array $rows): array\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        $list = [];\n        foreach ($rows as $key => $row) {\n            $list[$key] = $this->saveRow('@@', $rows);\n        }\n\n        if ($list) {\n            $this->save();\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::readRows()\n     */\n    public function readRows(array $rows, array &$fetched = null): array\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        $list = [];\n        foreach ($rows as $key => $row) {\n            if (null === $row || is_scalar($row)) {\n                // Only load rows which haven't been loaded before.\n                $key = (string)$key;\n                $list[$key] = $this->hasKey($key) ? $this->loadRow($key) : null;\n                if (null !== $fetched) {\n                    $fetched[$key] = $list[$key];\n                }\n            } else {\n                // Keep the row if it has been loaded.\n                $list[$key] = $row;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::updateRows()\n     */\n    public function updateRows(array $rows): array\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        $save = false;\n        $list = [];\n        foreach ($rows as $key => $row) {\n            $key = (string)$key;\n            if ($this->hasKey($key)) {\n                $list[$key] = $this->saveRow($key, $row);\n                $save = true;\n            } else {\n                $list[$key] = null;\n            }\n        }\n\n        if ($save) {\n            $this->save();\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::deleteRows()\n     */\n    public function deleteRows(array $rows): array\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        $list = [];\n        foreach ($rows as $key => $row) {\n            $key = (string)$key;\n            if ($this->hasKey($key)) {\n                unset($this->data[$key]);\n                $list[$key] = $row;\n            }\n        }\n\n        if ($list) {\n            $this->save();\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::replaceRows()\n     */\n    public function replaceRows(array $rows): array\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        $list = [];\n        foreach ($rows as $key => $row) {\n            $list[$key] = $this->saveRow((string)$key, $row);\n        }\n\n        if ($list) {\n            $this->save();\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $src\n     * @param string $dst\n     * @return bool\n     */\n    public function copyRow(string $src, string $dst): bool\n    {\n        if ($this->hasKey($dst)) {\n            throw new RuntimeException(\"Cannot copy object: key '{$dst}' is already taken\");\n        }\n\n        if (!$this->hasKey($src)) {\n            return false;\n        }\n\n        $this->data[$dst] = $this->data[$src];\n\n        return true;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::renameRow()\n     */\n    public function renameRow(string $src, string $dst): bool\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        if ($this->hasKey($dst)) {\n            throw new RuntimeException(\"Cannot rename object: key '{$dst}' is already taken\");\n        }\n\n        if (!$this->hasKey($src)) {\n            return false;\n        }\n\n        // Change single key in the array without changing the order or value.\n        $keys = array_keys($this->data);\n        $keys[array_search($src, $keys, true)] = $dst;\n\n        $data = array_combine($keys, $this->data);\n        if (false === $data) {\n            throw new LogicException('Bad data');\n        }\n\n        $this->data = $data;\n\n        return true;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::getStoragePath()\n     */\n    public function getStoragePath(string $key = null): ?string\n    {\n        return $this->dataFolder . '/' . $this->dataPattern;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FlexStorageInterface::getMediaPath()\n     */\n    public function getMediaPath(string $key = null): ?string\n    {\n        return null;\n    }\n\n    /**\n     * Prepares the row for saving and returns the storage key for the record.\n     *\n     * @param array $row\n     */\n    protected function prepareRow(array &$row): void\n    {\n        unset($row[$this->keyField]);\n    }\n\n    /**\n     * @param string $key\n     * @return array\n     */\n    protected function loadRow(string $key): ?array\n    {\n        $data = $this->data[$key] ?? [];\n        if ($this->keyField !== 'storage_key') {\n            $data[$this->keyField] = $key;\n        }\n        $data['__META'] = $this->getObjectMeta($key);\n\n        return $data;\n    }\n\n    /**\n     * @param string $key\n     * @param array $row\n     * @return array\n     */\n    protected function saveRow(string $key, array $row): array\n    {\n        try {\n            if (isset($row[$this->keyField])) {\n                $key = $row[$this->keyField];\n            }\n            if (strpos($key, '@@') !== false) {\n                $key = $this->getNewKey();\n            }\n\n            // Check if the row already exists and if the key has been changed.\n            $oldKey = $row['__META']['storage_key'] ?? null;\n            if (is_string($oldKey) && $oldKey !== $key) {\n                $isCopy = $row['__META']['copy'] ?? false;\n                if ($isCopy) {\n                    $this->copyRow($oldKey, $key);\n                } else {\n                    $this->renameRow($oldKey, $key);\n                }\n            }\n\n            $this->prepareRow($row);\n            unset($row['__META'], $row['__ERROR']);\n\n            $this->data[$key] = $row;\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $key, $e->getMessage()));\n        }\n\n        $row['__META'] = $this->getObjectMeta($key, true);\n\n        return $row;\n    }\n\n    /**\n     * @param string $key\n     * @param bool $variations\n     * @return array\n     */\n    public function parseKey(string $key, bool $variations = true): array\n    {\n        return [\n            'key' => $key,\n        ];\n    }\n\n    protected function save(): void\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        try {\n            $path = $this->getStoragePath();\n            if (!$path) {\n                throw new RuntimeException('Storage path is not defined');\n            }\n            $file = $this->getFile($path);\n            if ($this->prefix) {\n                $data = new Data((array)$file->content());\n                $content = $data->set($this->prefix, $this->data)->toArray();\n            } else {\n                $content = $this->data;\n            }\n            $file->save($content);\n            $this->modified = (int)$file->modified(); // cast false to 0\n        } catch (RuntimeException $e) {\n            throw new RuntimeException(sprintf('Flex save(): %s', $e->getMessage()));\n        } finally {\n            if (isset($file)) {\n                $file->free();\n                unset($file);\n            }\n        }\n    }\n\n    /**\n     * Get key from the filesystem path.\n     *\n     * @param  string $path\n     * @return string\n     */\n    protected function getKeyFromPath(string $path): string\n    {\n        return Utils::basename($path);\n    }\n\n    /**\n     * Returns list of all stored keys in [key => timestamp] pairs.\n     *\n     * @return array\n     */\n    protected function buildIndex(): array\n    {\n        $path = $this->getStoragePath();\n        if (!$path) {\n            $this->data = [];\n\n            return [];\n        }\n\n        $file = $this->getFile($path);\n        $this->modified = (int)$file->modified(); // cast false to 0\n\n        $content = (array) $file->content();\n        if ($this->prefix) {\n            $data = new Data($content);\n            $content = $data->get($this->prefix, []);\n        }\n\n        $file->free();\n        unset($file);\n\n        $this->data = $content;\n\n        $list = [];\n        foreach ($this->data as $key => $info) {\n            $list[$key] = $this->getObjectMeta((string)$key);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $key\n     * @param bool $reload\n     * @return array\n     */\n    protected function getObjectMeta(string $key, bool $reload = false): array\n    {\n        $modified = isset($this->data[$key]) ? $this->modified : 0;\n\n        return [\n            'storage_key' => $key,\n            'key' => $key,\n            'storage_timestamp' => $modified\n        ];\n    }\n\n    /**\n     * @return string\n     */\n    protected function getNewKey(): string\n    {\n        if (null === $this->data) {\n            $this->buildIndex();\n        }\n\n        // Make sure that the key doesn't exist.\n        do {\n            $key = $this->generateKey();\n        } while (isset($this->data[$key]));\n\n        return $key;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Traits;\n\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\n\n/**\n * Implements basic ACL\n */\ntrait FlexAuthorizeTrait\n{\n    /**\n     * Check if user is authorized for the action.\n     *\n     * Note: There are two deny values: denied (false), not set (null). This allows chaining multiple rules together\n     * when the previous rules were not matched.\n     *\n     * To override the default behavior, please use isAuthorizedOverride().\n     *\n     * @param string $action\n     * @param string|null $scope\n     * @param UserInterface|null $user\n     * @return bool|null\n     * @final\n     */\n    public function isAuthorized(string $action, string $scope = null, UserInterface $user = null): ?bool\n    {\n        $action = $this->getAuthorizeAction($action);\n        $scope = $scope ?? $this->getAuthorizeScope();\n\n        $isMe = null === $user;\n        if ($isMe) {\n            $user = $this->getActiveUser();\n        }\n\n        if (null === $user) {\n            return false;\n        }\n\n        // Finally authorize against given action.\n        return $this->isAuthorizedOverride($user, $action, $scope, $isMe);\n    }\n\n    /**\n     * Please override this method\n     *\n     * @param UserInterface $user\n     * @param string $action\n     * @param string $scope\n     * @param bool $isMe\n     * @return bool|null\n     */\n    protected function isAuthorizedOverride(UserInterface $user, string $action, string $scope, bool $isMe): ?bool\n    {\n        return $this->isAuthorizedAction($user, $action, $scope, $isMe);\n    }\n\n    /**\n     * Check if user is authorized for the action.\n     *\n     * @param UserInterface $user\n     * @param string $action\n     * @param string $scope\n     * @param bool $isMe\n     * @return bool|null\n     */\n    protected function isAuthorizedAction(UserInterface $user, string $action, string $scope, bool $isMe): ?bool\n    {\n        // Check if the action has been denied in the flex type configuration.\n        $directory = $this instanceof FlexDirectory ? $this : $this->getFlexDirectory();\n        $config = $directory->getConfig();\n        $allowed = $config->get(\"{$scope}.actions.{$action}\") ?? $config->get(\"actions.{$action}\") ?? true;\n        if (false === $allowed) {\n            return false;\n        }\n\n        // TODO: Not needed anymore with flex users, remove in 2.0.\n        $auth = $user instanceof FlexObjectInterface ? null : $user->authorize('admin.super');\n        if (true === $auth) {\n            return true;\n        }\n\n        // Finally authorize the action.\n        return $user->authorize($this->getAuthorizeRule($scope, $action), !$isMe ? 'test' : null);\n    }\n\n    /**\n     * @param UserInterface $user\n     * @return bool|null\n     * @deprecated 1.7 Not needed for Flex Users.\n     */\n    protected function isAuthorizedSuperAdmin(UserInterface $user): ?bool\n    {\n        // Action authorization includes super user authorization if using Flex Users.\n        if ($user instanceof FlexObjectInterface) {\n            return null;\n        }\n\n        return $user->authorize('admin.super');\n    }\n\n    /**\n     * @param string $scope\n     * @param string $action\n     * @return string\n     */\n    protected function getAuthorizeRule(string $scope, string $action): string\n    {\n        if ($this instanceof FlexDirectory) {\n            return $this->getAuthorizeRule($scope, $action);\n        }\n\n        return $this->getFlexDirectory()->getAuthorizeRule($scope, $action);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php",
    "content": "<?php\n\nnamespace Grav\\Framework\\Flex\\Traits;\n\n/**\n * @package    Grav\\Framework\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Media\\Interfaces\\MediaCollectionInterface;\nuse Grav\\Common\\Media\\Interfaces\\MediaUploadInterface;\nuse Grav\\Common\\Media\\Traits\\MediaTrait;\nuse Grav\\Common\\Page\\Media;\nuse Grav\\Common\\Page\\Medium\\Medium;\nuse Grav\\Common\\Page\\Medium\\MediumFactory;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Cache\\CacheInterface;\nuse Grav\\Framework\\Filesystem\\Filesystem;\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse Grav\\Framework\\Form\\FormFlashFile;\nuse Grav\\Framework\\Media\\Interfaces\\MediaObjectInterface;\nuse Grav\\Framework\\Media\\MediaObject;\nuse Grav\\Framework\\Media\\UploadedMediaObject;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function array_key_exists;\nuse function in_array;\nuse function is_array;\nuse function is_callable;\nuse function is_int;\nuse function is_string;\nuse function strpos;\n\n/**\n * Implements Grav Page content and header manipulation methods.\n */\ntrait FlexMediaTrait\n{\n    use MediaTrait {\n        MediaTrait::getMedia as protected getExistingMedia;\n    }\n\n    /** @var array */\n    protected $_uploads = [];\n\n    /**\n     * @return string|null\n     */\n    public function getStorageFolder()\n    {\n        return $this->exists() ? $this->getFlexDirectory()->getStorageFolder($this->getStorageKey()) : null;\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getMediaFolder()\n    {\n        return $this->exists() ? $this->getFlexDirectory()->getMediaFolder($this->getStorageKey()) : null;\n    }\n\n    /**\n     * @return MediaCollectionInterface\n     */\n    public function getMedia()\n    {\n        $media = $this->media;\n        if (null === $media) {\n            $media = $this->getExistingMedia();\n\n            // Include uploaded media to the object media.\n            $this->addUpdatedMedia($media);\n        }\n\n        return $media;\n    }\n\n    /**\n     * @param string $field\n     * @return MediaCollectionInterface|null\n     */\n    public function getMediaField(string $field): ?MediaCollectionInterface\n    {\n        // Field specific media.\n        $settings = $this->getFieldSettings($field);\n        if (!empty($settings['media_field'])) {\n            $var = 'destination';\n        } elseif (!empty($settings['media_picker_field'])) {\n            $var = 'folder';\n        }\n\n        if (empty($var)) {\n            // Not a media field.\n            $media = null;\n        } elseif ($settings['self']) {\n            // Uses main media.\n            $media = $this->getMedia();\n        } else {\n            // Uses custom media.\n            $media = new Media($settings[$var]);\n            $this->addUpdatedMedia($media);\n        }\n\n        return $media;\n    }\n\n    /**\n     * @param string $field\n     * @return array|null\n     */\n    public function getFieldSettings(string $field): ?array\n    {\n        if ($field === '') {\n            return null;\n        }\n\n        // Load settings for the field.\n        $schema = $this->getBlueprint()->schema();\n        $settings = (array)$schema->getProperty($field);\n        if (!is_array($settings)) {\n            return null;\n        }\n\n        $type = $settings['type'] ?? '';\n\n        // Media field.\n        if (!empty($settings['media_field']) || array_key_exists('destination', $settings) || in_array($type, ['avatar', 'file', 'pagemedia'], true)) {\n            $settings['media_field'] = true;\n            $var = 'destination';\n        }\n\n        // Media picker field.\n        if (!empty($settings['media_picker_field']) || in_array($type, ['filepicker', 'pagemediaselect'], true)) {\n            $settings['media_picker_field'] = true;\n            $var = 'folder';\n        }\n\n        // Set media folder for media fields.\n        if (isset($var)) {\n            $folder = $settings[$var] ?? '';\n            if (in_array(rtrim($folder, '/'), ['', '@self', 'self@', '@self@'], true)) {\n                $settings[$var] = $this->getMediaFolder();\n                $settings['self'] = true;\n            } else {\n                $settings[$var] = Utils::getPathFromToken($folder, $this);\n                $settings['self'] = false;\n            }\n        }\n\n        return $settings;\n    }\n\n    /**\n     * @param string $field\n     * @return array\n     * @internal\n     */\n    protected function getMediaFieldSettings(string $field): array\n    {\n        $settings = $this->getFieldSettings($field) ?? [];\n\n        return $settings + ['accept' => '*', 'limit' => 1000, 'self' => true];\n    }\n\n    /**\n     * @return array\n     */\n    protected function getMediaFields(): array\n    {\n        // Load settings for the field.\n        $schema = $this->getBlueprint()->schema();\n\n        $list = [];\n        foreach ($schema->getState()['items'] as $field => $settings) {\n            if (isset($settings['type']) && (in_array($settings['type'], ['avatar', 'file', 'pagemedia']) || !empty($settings['destination']))) {\n                $list[] = $field;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param array|mixed $value\n     * @param array $settings\n     * @return array|mixed\n     */\n    protected function parseFileProperty($value, array $settings = [])\n    {\n        if (!is_array($value)) {\n            return $value;\n        }\n\n        $media = $this->getMedia();\n        $originalMedia = is_callable([$this, 'getOriginalMedia']) ? $this->getOriginalMedia() : null;\n\n        $list = [];\n        foreach ($value as $filename => $info) {\n            if (!is_array($info)) {\n                $list[$filename] = $info;\n                continue;\n            }\n\n            if (is_int($filename)) {\n                $filename = $info['path'] ?? $info['name'];\n            }\n\n            /** @var Medium|null $imageFile */\n            $imageFile = $media[$filename];\n\n            /** @var Medium|null $originalFile */\n            $originalFile = $originalMedia ? $originalMedia[$filename] : null;\n\n            $url = $imageFile ? $imageFile->url() : null;\n            $originalUrl = $originalFile ? $originalFile->url() : null;\n            $list[$filename] = [\n                'name' => $info['name'] ?? null,\n                'type' => $info['type'] ?? null,\n                'size' => $info['size'] ?? null,\n                'path' => $filename,\n                'thumb_url' => $url,\n                'image_url' => $originalUrl ?? $url\n            ];\n            if ($originalFile) {\n                $list[$filename]['cropData'] = (object)($originalFile->metadata()['upload']['crop'] ?? []);\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param UploadedFileInterface $uploadedFile\n     * @param string|null $filename\n     * @param string|null $field\n     * @return void\n     * @internal\n     */\n    public function checkUploadedMediaFile(UploadedFileInterface $uploadedFile, string $filename = null, string $field = null)\n    {\n        $media = $this->getMedia();\n        if (!$media instanceof MediaUploadInterface) {\n            throw new RuntimeException(\"Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads.\");\n        }\n\n        $media->checkUploadedFile($uploadedFile, $filename, $this->getMediaFieldSettings($field ?? ''));\n    }\n\n    /**\n     * @param UploadedFileInterface $uploadedFile\n     * @param string|null $filename\n     * @param string|null $field\n     * @return void\n     * @internal\n     */\n    public function uploadMediaFile(UploadedFileInterface $uploadedFile, string $filename = null, string $field = null): void\n    {\n        $settings = $this->getMediaFieldSettings($field ?? '');\n\n        $media = $field ? $this->getMediaField($field) : $this->getMedia();\n        if (!$media instanceof MediaUploadInterface) {\n            throw new RuntimeException(\"Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads.\");\n        }\n\n        $filename = $media->checkUploadedFile($uploadedFile, $filename, $settings);\n        $media->copyUploadedFile($uploadedFile, $filename, $settings);\n        $this->clearMediaCache();\n    }\n\n    /**\n     * @param string $filename\n     * @return void\n     * @internal\n     */\n    public function deleteMediaFile(string $filename): void\n    {\n        $media = $this->getMedia();\n        if (!$media instanceof MediaUploadInterface) {\n            throw new RuntimeException(\"Media for {$this->getFlexDirectory()->getFlexType()} doesn't support file uploads.\");\n        }\n\n        $media->deleteFile($filename);\n        $this->clearMediaCache();\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function __debugInfo()\n    {\n        return parent::__debugInfo() + [\n                'uploads:private' => $this->getUpdatedMedia()\n            ];\n    }\n\n    /**\n     * @param string|null $field\n     * @param string $filename\n     * @param MediaObjectInterface|null $image\n     * @return MediaObject|UploadedMediaObject\n     */\n    protected function buildMediaObject(?string $field, string $filename, MediaObjectInterface $image = null)\n    {\n        if (!$image) {\n            $media = $field ? $this->getMediaField($field) : null;\n            if ($media) {\n                $image = $media[$filename];\n            }\n        }\n\n        return new MediaObject($field, $filename, $image, $this);\n    }\n\n    /**\n     * @param string|null $field\n     * @return array\n     */\n    protected function buildMediaList(?string $field): array\n    {\n        $names = $field ? (array)$this->getNestedProperty($field) : [];\n        $media = $field ? $this->getMediaField($field) : null;\n        if (null === $media) {\n            $media = $this->getMedia();\n        }\n\n        $list = [];\n        foreach ($names as $key => $val) {\n            $name = is_string($val) ? $val : $key;\n            $medium = $media[$name];\n            if ($medium) {\n                if ($medium->uploaded_file) {\n                    $upload = $medium->uploaded_file;\n                    $id = $upload instanceof FormFlashFile ? $upload->getId() : \"{$field}-{$name}\";\n\n                    $list[] = new UploadedMediaObject($id, $field, $name, $upload);\n                } else {\n                    $list[] = $this->buildMediaObject($field, $name, $medium);\n                }\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param array $files\n     * @return void\n     */\n    protected function setUpdatedMedia(array $files): void\n    {\n        $media = $this->getMedia();\n        if (!$media instanceof MediaUploadInterface) {\n            return;\n        }\n\n        $filesystem = Filesystem::getInstance(false);\n\n        $list = [];\n        foreach ($files as $field => $group) {\n            $field = (string)$field;\n            // Ignore files without a field and resized images.\n            if ($field === '' || strpos($field, '/')) {\n                continue;\n            }\n\n            // Load settings for the field.\n            $settings = $this->getMediaFieldSettings($field);\n            foreach ($group as $filename => $file) {\n                if ($file) {\n                    // File upload.\n                    $filename = $file->getClientFilename();\n\n                    /** @var FormFlashFile $file */\n                    $data = $file->jsonSerialize();\n                    unset($data['tmp_name'], $data['path']);\n                } else {\n                    // File delete.\n                    $data = null;\n                }\n\n                if ($file) {\n                    // Check file upload against media limits (except for max size).\n                    $filename = $media->checkUploadedFile($file, $filename, ['filesize' => 0] + $settings);\n                }\n\n                $self = $settings['self'];\n                if ($this->_loadMedia && $self) {\n                    $filepath = $filename;\n                } else {\n                    $filepath = \"{$settings['destination']}/{$filename}\";\n                }\n\n                // Calculate path without the retina scaling factor.\n                $realpath = $filesystem->pathname($filepath) . str_replace(['@3x', '@2x'], '', Utils::basename($filepath));\n\n                $list[$filename] = [$file, $settings];\n\n                $path = str_replace('.', \"\\n\", $field);\n                if (null !== $data) {\n                    $data['name'] = $filename;\n                    $data['path'] = $filepath;\n\n                    $this->setNestedProperty(\"{$path}\\n{$realpath}\", $data, \"\\n\");\n                } else {\n                    $this->unsetNestedProperty(\"{$path}\\n{$realpath}\", \"\\n\");\n                }\n            }\n        }\n\n        $this->clearMediaCache();\n\n        $this->_uploads = $list;\n    }\n\n    /**\n     * @param MediaCollectionInterface $media\n     */\n    protected function addUpdatedMedia(MediaCollectionInterface $media): void\n    {\n        $updated = false;\n        foreach ($this->getUpdatedMedia() as $filename => $upload) {\n            if (is_array($upload)) {\n                /** @var array{UploadedFileInterface,array} $upload */\n                $settings = $upload[1];\n                if (isset($settings['destination']) && $settings['destination'] === $media->getPath()) {\n                    $upload = $upload[0];\n                } else {\n                    $upload = false;\n                }\n            }\n            if (false !== $upload) {\n                $medium = $upload ? MediumFactory::fromUploadedFile($upload) : null;\n                $updated = true;\n                if ($medium) {\n                    $medium->uploaded = true;\n                    $medium->uploaded_file = $upload;\n                    $media->add($filename, $medium);\n                } elseif (is_callable([$media, 'hide'])) {\n                    $media->hide($filename);\n                }\n            }\n        }\n\n        if ($updated) {\n            $media->setTimestamps();\n        }\n    }\n\n    /**\n     * @return array<string,UploadedFileInterface|array|null>\n     */\n    protected function getUpdatedMedia(): array\n    {\n        return $this->_uploads;\n    }\n\n    /**\n     * @return void\n     */\n    protected function saveUpdatedMedia(): void\n    {\n        $media = $this->getMedia();\n        if (!$media instanceof MediaUploadInterface) {\n            return;\n        }\n\n        // Upload/delete altered files.\n        /**\n         * @var string $filename\n         * @var UploadedFileInterface|array|null $file\n         */\n        foreach ($this->getUpdatedMedia() as $filename => $file) {\n            if (is_array($file)) {\n                [$file, $settings] = $file;\n            } else {\n                $settings = null;\n            }\n            if ($file instanceof UploadedFileInterface) {\n                $media->copyUploadedFile($file, $filename, $settings);\n            } else {\n                $media->deleteFile($filename, $settings);\n            }\n        }\n\n        $this->setUpdatedMedia([]);\n        $this->clearMediaCache();\n    }\n\n    /**\n     * @return void\n     */\n    protected function freeMedia(): void\n    {\n        $this->unsetObjectProperty('media');\n    }\n\n    /**\n     * @param string $uri\n     * @return Medium|null\n     */\n    protected function createMedium($uri)\n    {\n        $grav = Grav::instance();\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $grav['locator'];\n\n        $file = $uri && $locator->isStream($uri) ? $locator->findResource($uri) : $uri;\n\n        return is_string($file) && file_exists($file) ? MediumFactory::fromFile($file) : null;\n    }\n\n    /**\n     * @return CacheInterface\n     */\n    protected function getMediaCache()\n    {\n        return $this->getCache('object');\n    }\n\n    /**\n     * @return MediaCollectionInterface\n     */\n    protected function offsetLoad_media()\n    {\n        return $this->getMedia();\n    }\n\n    /**\n     * @return null\n     */\n    protected function offsetSerialize_media()\n    {\n        return null;\n    }\n\n    /**\n     * @return FlexDirectory\n     */\n    abstract public function getFlexDirectory(): FlexDirectory;\n\n    /**\n     * @return string\n     */\n    abstract public function getStorageKey(): string;\n\n    /**\n     * @param string $filename\n     * @return void\n     * @deprecated 1.7 Use Media class that implements MediaUploadInterface instead.\n     */\n    public function checkMediaFilename(string $filename)\n    {\n        user_error(__METHOD__ . '() is deprecated since Grav 1.7, use Media class that implements MediaUploadInterface instead', E_USER_DEPRECATED);\n\n        // Check the file extension.\n        $extension = strtolower(Utils::pathinfo($filename, PATHINFO_EXTENSION));\n\n        $grav = Grav::instance();\n\n        /** @var Config $config */\n        $config = $grav['config'];\n\n        // If not a supported type, return\n        if (!$extension || !$config->get(\"media.types.{$extension}\")) {\n            $language = $grav['language'];\n            throw new RuntimeException($language->translate('PLUGIN_ADMIN.UNSUPPORTED_FILE_TYPE') . ': ' . $extension, 400);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Traits/FlexRelatedDirectoryTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Common\\Flex\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Flex\\Traits;\n\nuse Grav\\Framework\\Flex\\FlexDirectory;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexCollectionInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse RuntimeException;\nuse function in_array;\n\n/**\n * Trait GravTrait\n * @package Grav\\Common\\Flex\\Traits\n */\ntrait FlexRelatedDirectoryTrait\n{\n    /**\n     * @param string $type\n     * @param string $property\n     * @return FlexCollectionInterface<FlexObjectInterface>\n     */\n    protected function getCollectionByProperty($type, $property)\n    {\n        $directory = $this->getRelatedDirectory($type);\n        $collection = $directory->getCollection();\n        $list = $this->getNestedProperty($property) ?: [];\n\n        /** @var FlexCollectionInterface<FlexObjectInterface> $collection */\n        $collection = $collection->filter(static function ($object) use ($list) {\n            return in_array($object->getKey(), $list, true);\n        });\n\n        return $collection;\n    }\n\n    /**\n     * @param string $type\n     * @return FlexDirectory\n     * @throws RuntimeException\n     */\n    protected function getRelatedDirectory($type): FlexDirectory\n    {\n        $directory = $this->getFlexContainer()->getDirectory($type);\n        if (!$directory) {\n            throw new RuntimeException(ucfirst($type). ' directory does not exist!');\n        }\n\n        return $directory;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Flex/Traits/FlexRelationshipsTrait.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Flex\\Traits;\n\nuse Grav\\Framework\\Contracts\\Relationships\\RelationshipInterface;\nuse Grav\\Framework\\Contracts\\Relationships\\RelationshipsInterface;\nuse Grav\\Framework\\Flex\\FlexIdentifier;\nuse Grav\\Framework\\Relationships\\Relationships;\n\n/**\n * Trait FlexRelationshipsTrait\n */\ntrait FlexRelationshipsTrait\n{\n    /** @var RelationshipsInterface|null */\n    private $_relationships = null;\n\n    /**\n     * @return Relationships\n     */\n    public function getRelationships(): Relationships\n    {\n        if (!isset($this->_relationships)) {\n            $blueprint = $this->getBlueprint();\n            $options = $blueprint->get('config/relationships', []);\n            $parent = FlexIdentifier::createFromObject($this);\n\n            $this->_relationships = new Relationships($parent, $options);\n        }\n\n        return $this->_relationships;\n    }\n\n    /**\n     * @param string $name\n     * @return RelationshipInterface|null\n     */\n    public function getRelationship(string $name): ?RelationshipInterface\n    {\n        return $this->getRelationships()[$name];\n    }\n\n    protected function resetRelationships(): void\n    {\n        $this->_relationships = null;\n    }\n\n    /**\n     * @param iterable $collection\n     * @return array\n     */\n    protected function buildFlexIdentifierList(iterable $collection): array\n    {\n        $list = [];\n        foreach ($collection as $object) {\n            $list[] = FlexIdentifier::createFromObject($object);\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Form/FormFlash.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Form\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Form;\n\nuse Exception;\nuse Grav\\Common\\Filesystem\\Folder;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Form\\Interfaces\\FormFlashInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse RocketTheme\\Toolbox\\File\\YamlFile;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\nuse RuntimeException;\nuse function func_get_args;\nuse function is_array;\n\n/**\n * Class FormFlash\n * @package Grav\\Framework\\Form\n */\nclass FormFlash implements FormFlashInterface\n{\n    /** @var bool */\n    protected $exists;\n    /** @var string */\n    protected $id;\n    /** @var string */\n    protected $sessionId;\n    /** @var string */\n    protected $uniqueId;\n    /** @var string */\n    protected $formName;\n    /** @var string */\n    protected $url;\n    /** @var array|null */\n    protected $user;\n    /** @var int */\n    protected $createdTimestamp;\n    /** @var int */\n    protected $updatedTimestamp;\n    /** @var array|null */\n    protected $data;\n    /** @var array */\n    protected $files;\n    /** @var array */\n    protected $uploadedFiles;\n    /** @var string[] */\n    protected $uploadObjects;\n    /** @var string */\n    protected $folder;\n\n    /**\n     * @inheritDoc\n     */\n    public function __construct($config)\n    {\n        // Backwards compatibility with Grav 1.6 plugins.\n        if (!is_array($config)) {\n            user_error(__CLASS__ . '::' . __FUNCTION__ . '($sessionId, $uniqueId, $formName) is deprecated since Grav 1.6.11, use $config parameter instead', E_USER_DEPRECATED);\n\n            $args = func_get_args();\n            $config = [\n                'session_id' => $args[0],\n                'unique_id' => $args[1] ?? null,\n                'form_name' => $args[2] ?? null,\n            ];\n            $config = array_filter($config, static function ($val) {\n                return $val !== null;\n            });\n        }\n\n        $this->id = $config['id'] ?? '';\n        $this->sessionId = $config['session_id'] ?? '';\n        $this->uniqueId = $config['unique_id'] ?? '';\n\n        $this->setUser($config['user'] ?? null);\n\n        $folder = $config['folder'] ?? ($this->sessionId ? 'tmp://forms/' . $this->sessionId : '');\n\n        /** @var UniformResourceLocator $locator */\n        $locator = Grav::instance()['locator'];\n\n        $this->folder = $folder && $locator->isStream($folder) ? $locator->findResource($folder, true, true) : $folder;\n\n        $this->init($this->loadStoredForm(), $config);\n    }\n\n    /**\n     * @param array|null $data\n     * @param array $config\n     */\n    protected function init(?array $data, array $config): void\n    {\n        if (null === $data) {\n            $this->exists = false;\n            $this->formName = $config['form_name'] ?? '';\n            $this->url = '';\n            $this->createdTimestamp = $this->updatedTimestamp = time();\n            $this->files = [];\n        } else {\n            $this->exists = true;\n            $this->formName = $data['form'] ?? $config['form_name'] ?? '';\n            $this->url = $data['url'] ?? '';\n            $this->user = $data['user'] ?? null;\n            $this->updatedTimestamp = $data['timestamps']['updated'] ?? time();\n            $this->createdTimestamp = $data['timestamps']['created'] ?? $this->updatedTimestamp;\n            $this->data = $data['data'] ?? null;\n            $this->files = $data['files'] ?? [];\n        }\n    }\n\n    /**\n     * Load raw flex flash data from the filesystem.\n     *\n     * @return array|null\n     */\n    protected function loadStoredForm(): ?array\n    {\n        $file = $this->getTmpIndex();\n        $exists = $file && $file->exists();\n\n        $data = null;\n        if ($exists) {\n            try {\n                $data = (array)$file->content();\n            } catch (Exception $e) {\n            }\n        }\n\n        return $data;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getId(): string\n    {\n        return $this->id && $this->uniqueId ? $this->id . '/' . $this->uniqueId : '';\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getSessionId(): string\n    {\n        return $this->sessionId;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getUniqueId(): string\n    {\n        return $this->uniqueId;\n    }\n\n    /**\n     * @return string\n     * @deprecated 1.6.11 Use '->getUniqueId()' method instead.\n     */\n    public function getUniqieId(): string\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6.11, use ->getUniqueId() method instead', E_USER_DEPRECATED);\n\n        return $this->getUniqueId();\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getFormName(): string\n    {\n        return $this->formName;\n    }\n\n\n    /**\n     * @inheritDoc\n     */\n    public function getUrl(): string\n    {\n        return $this->url;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getUsername(): string\n    {\n        return $this->user['username'] ?? '';\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getUserEmail(): string\n    {\n        return $this->user['email'] ?? '';\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getCreatedTimestamp(): int\n    {\n        return $this->createdTimestamp;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getUpdatedTimestamp(): int\n    {\n        return $this->updatedTimestamp;\n    }\n\n\n    /**\n     * @inheritDoc\n     */\n    public function getData(): ?array\n    {\n        return $this->data;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function setData(?array $data): void\n    {\n        $this->data = $data;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function exists(): bool\n    {\n        return $this->exists;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function save(bool $force = false)\n    {\n        if (!($this->folder && $this->uniqueId)) {\n            return $this;\n        }\n\n        if ($force || $this->data || $this->files) {\n            // Only save if there is data or files to be saved.\n            $file = $this->getTmpIndex();\n            if ($file) {\n                $file->save($this->jsonSerialize());\n                $this->exists = true;\n            }\n        } elseif ($this->exists) {\n            // Delete empty form flash if it exists (it carries no information).\n            return $this->delete();\n        }\n\n        return $this;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function delete()\n    {\n        if ($this->folder && $this->uniqueId) {\n            $this->removeTmpDir();\n            $this->files = [];\n            $this->exists = false;\n        }\n\n        return $this;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getFilesByField(string $field): array\n    {\n        if (!isset($this->uploadObjects[$field])) {\n            $objects = [];\n            foreach ($this->files[$field] ?? [] as $name => $upload) {\n                $objects[$name] = $upload ? new FormFlashFile($field, $upload, $this) : null;\n            }\n            $this->uploadedFiles[$field] = $objects;\n        }\n\n        return $this->uploadedFiles[$field];\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getFilesByFields($includeOriginal = false): array\n    {\n        $list = [];\n        foreach ($this->files as $field => $values) {\n            if (!$includeOriginal && strpos($field, '/')) {\n                continue;\n            }\n            $list[$field] = $this->getFilesByField($field);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function addUploadedFile(UploadedFileInterface $upload, string $field = null, array $crop = null): string\n    {\n        $tmp_dir = $this->getTmpDir();\n        $tmp_name = Utils::generateRandomString(12);\n        $name = $upload->getClientFilename();\n        if (!$name) {\n            throw new RuntimeException('Uploaded file has no filename');\n        }\n\n        // Prepare upload data for later save\n        $data = [\n            'name' => $name,\n            'type' => $upload->getClientMediaType(),\n            'size' => $upload->getSize(),\n            'tmp_name' => $tmp_name\n        ];\n\n        Folder::create($tmp_dir);\n        $upload->moveTo(\"{$tmp_dir}/{$tmp_name}\");\n\n        $this->addFileInternal($field, $name, $data, $crop);\n\n        return $name;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function addFile(string $filename, string $field, array $crop = null): bool\n    {\n        if (!file_exists($filename)) {\n            throw new RuntimeException(\"File not found: {$filename}\");\n        }\n\n        // Prepare upload data for later save\n        $data = [\n            'name' => Utils::basename($filename),\n            'type' => Utils::getMimeByLocalFile($filename),\n            'size' => filesize($filename),\n        ];\n\n        $this->addFileInternal($field, $data['name'], $data, $crop);\n\n        return true;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function removeFile(string $name, string $field = null): bool\n    {\n        if (!$name) {\n            return false;\n        }\n\n        $field = $field ?: 'undefined';\n\n        $upload = $this->files[$field][$name] ?? null;\n        if (null !== $upload) {\n            $this->removeTmpFile($upload['tmp_name'] ?? '');\n        }\n        $upload = $this->files[$field . '/original'][$name] ?? null;\n        if (null !== $upload) {\n            $this->removeTmpFile($upload['tmp_name'] ?? '');\n        }\n\n        // Mark file as deleted.\n        $this->files[$field][$name] = null;\n        $this->files[$field . '/original'][$name] = null;\n\n        unset(\n            $this->uploadedFiles[$field][$name],\n            $this->uploadedFiles[$field . '/original'][$name]\n        );\n\n        return true;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function clearFiles()\n    {\n        foreach ($this->files as $files) {\n            foreach ($files as $upload) {\n                $this->removeTmpFile($upload['tmp_name'] ?? '');\n            }\n        }\n\n        $this->files = [];\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function jsonSerialize(): array\n    {\n        return [\n            'form' => $this->formName,\n            'id' => $this->getId(),\n            'unique_id' => $this->uniqueId,\n            'url' => $this->url,\n            'user' => $this->user,\n            'timestamps' => [\n                'created' => $this->createdTimestamp,\n                'updated' => time(),\n            ],\n            'data' => $this->data,\n            'files' => $this->files\n        ];\n    }\n\n    /**\n     * @param string $url\n     * @return $this\n     */\n    public function setUrl(string $url): self\n    {\n        $this->url = $url;\n\n        return $this;\n    }\n\n    /**\n     * @param UserInterface|null $user\n     * @return $this\n     */\n    public function setUser(UserInterface $user = null)\n    {\n        if ($user && $user->username) {\n            $this->user = [\n                'username' => $user->username,\n                'email' => $user->email ?? ''\n            ];\n        } else {\n            $this->user = null;\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string|null $username\n     * @return $this\n     */\n    public function setUserName(string $username = null): self\n    {\n        $this->user['username'] = $username;\n\n        return $this;\n    }\n\n    /**\n     * @param string|null $email\n     * @return $this\n     */\n    public function setUserEmail(string $email = null): self\n    {\n        $this->user['email'] = $email;\n\n        return $this;\n    }\n\n    /**\n     * @return string\n     */\n    public function getTmpDir(): string\n    {\n        return $this->folder && $this->uniqueId ? \"{$this->folder}/{$this->uniqueId}\" : '';\n    }\n\n    /**\n     * @return ?YamlFile\n     */\n    protected function getTmpIndex(): ?YamlFile\n    {\n        $tmpDir = $this->getTmpDir();\n\n        // Do not use CompiledYamlFile as the file can change multiple times per second.\n        return $tmpDir ? YamlFile::instance($tmpDir . '/index.yaml') : null;\n    }\n\n    /**\n     * @param string $name\n     */\n    protected function removeTmpFile(string $name): void\n    {\n        $tmpDir = $this->getTmpDir();\n        $filename =  $tmpDir ? $tmpDir . '/' . $name : '';\n        if ($name && $filename && is_file($filename)) {\n            unlink($filename);\n        }\n    }\n\n    /**\n     * @return void\n     */\n    protected function removeTmpDir(): void\n    {\n        // Make sure that index file cache gets always cleared.\n        $file = $this->getTmpIndex();\n        if ($file) {\n            $file->free();\n        }\n\n        $tmpDir = $this->getTmpDir();\n        if ($tmpDir && file_exists($tmpDir)) {\n            Folder::delete($tmpDir);\n        }\n    }\n\n    /**\n     * @param string|null $field\n     * @param string $name\n     * @param array $data\n     * @param array|null $crop\n     * @return void\n     */\n    protected function addFileInternal(?string $field, string $name, array $data, array $crop = null): void\n    {\n        if (!($this->folder && $this->uniqueId)) {\n            throw new RuntimeException('Cannot upload files: form flash folder not defined');\n        }\n\n        $field = $field ?: 'undefined';\n        if (!isset($this->files[$field])) {\n            $this->files[$field] = [];\n        }\n\n        $oldUpload = $this->files[$field][$name] ?? null;\n\n        if ($crop) {\n            // Deal with crop upload\n            if ($oldUpload) {\n                $originalUpload = $this->files[$field . '/original'][$name] ?? null;\n                if ($originalUpload) {\n                    // If there is original file already present, remove the modified file\n                    $this->files[$field . '/original'][$name]['crop'] = $crop;\n                    $this->removeTmpFile($oldUpload['tmp_name'] ?? '');\n                } else {\n                    // Otherwise make the previous file as original\n                    $oldUpload['crop'] = $crop;\n                    $this->files[$field . '/original'][$name] = $oldUpload;\n                }\n            } else {\n                $this->files[$field . '/original'][$name] = [\n                    'name' => $name,\n                    'type' => $data['type'],\n                    'crop' => $crop\n                ];\n            }\n        } else {\n            // Deal with replacing upload\n            $originalUpload = $this->files[$field . '/original'][$name] ?? null;\n            $this->files[$field . '/original'][$name] = null;\n\n            $this->removeTmpFile($oldUpload['tmp_name'] ?? '');\n            $this->removeTmpFile($originalUpload['tmp_name'] ?? '');\n        }\n\n        // Prepare data to be saved later\n        $this->files[$field][$name] = $data;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Form/FormFlashFile.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Form\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Form;\n\nuse Grav\\Common\\Security;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Psr7\\Stream;\nuse InvalidArgumentException;\nuse JsonSerializable;\nuse Psr\\Http\\Message\\StreamInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse RuntimeException;\nuse function copy;\nuse function fopen;\nuse function is_string;\nuse function sprintf;\n\n/**\n * Class FormFlashFile\n * @package Grav\\Framework\\Form\n */\nclass FormFlashFile implements UploadedFileInterface, JsonSerializable\n{\n    /** @var string */\n    private $id;\n    /** @var string */\n    private $field;\n    /** @var bool */\n    private $moved = false;\n    /** @var array */\n    private $upload;\n    /** @var FormFlash */\n    private $flash;\n\n    /**\n     * FormFlashFile constructor.\n     * @param string $field\n     * @param array $upload\n     * @param FormFlash $flash\n     */\n    public function __construct(string $field, array $upload, FormFlash $flash)\n    {\n        $this->id = $flash->getId() ?: $flash->getUniqueId();\n        $this->field = $field;\n        $this->upload = $upload;\n        $this->flash = $flash;\n\n        $tmpFile = $this->getTmpFile();\n        if (!$tmpFile && $this->isOk()) {\n            $this->upload['error'] = \\UPLOAD_ERR_NO_FILE;\n        }\n\n        if (!isset($this->upload['size'])) {\n            $this->upload['size'] = $tmpFile && $this->isOk() ? filesize($tmpFile) : 0;\n        }\n    }\n\n    /**\n     * @return StreamInterface\n     */\n    public function getStream()\n    {\n        $this->validateActive();\n\n        $tmpFile = $this->getTmpFile();\n        if (null === $tmpFile) {\n            throw new RuntimeException('No temporary file');\n        }\n\n        $resource = fopen($tmpFile, 'rb');\n        if (false === $resource) {\n            throw new RuntimeException('No temporary file');\n        }\n\n        return Stream::create($resource);\n    }\n\n    /**\n     * @param string $targetPath\n     * @return void\n     */\n    public function moveTo($targetPath)\n    {\n        $this->validateActive();\n\n        if (!is_string($targetPath) || empty($targetPath)) {\n            throw new InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string');\n        }\n        $tmpFile = $this->getTmpFile();\n        if (null === $tmpFile) {\n            throw new RuntimeException('No temporary file');\n        }\n\n        $this->moved = copy($tmpFile, $targetPath);\n\n        if (false === $this->moved) {\n            throw new RuntimeException(sprintf('Uploaded file could not be moved to %s', $targetPath));\n        }\n\n        $filename = $this->getClientFilename();\n        if ($filename) {\n            $this->flash->removeFile($filename, $this->field);\n        }\n    }\n\n    public function getId(): string\n    {\n        return $this->id;\n    }\n\n    /**\n     * @return string\n     */\n    public function getField(): string\n    {\n        return $this->field;\n    }\n\n    /**\n     * @return int\n     */\n    public function getSize()\n    {\n        return $this->upload['size'];\n    }\n\n    /**\n     * @return int\n     */\n    public function getError()\n    {\n        return $this->upload['error'] ?? \\UPLOAD_ERR_OK;\n    }\n\n    /**\n     * @return string\n     */\n    public function getClientFilename()\n    {\n        return $this->upload['name'] ?? 'unknown';\n    }\n\n    /**\n     * @return string\n     */\n    public function getClientMediaType()\n    {\n        return $this->upload['type'] ?? 'application/octet-stream';\n    }\n\n    /**\n     * @return bool\n     */\n    public function isMoved(): bool\n    {\n        return $this->moved;\n    }\n\n    /**\n     * @return array\n     */\n    public function getMetaData(): array\n    {\n        if (isset($this->upload['crop'])) {\n            return ['crop' => $this->upload['crop']];\n        }\n\n        return [];\n    }\n\n    /**\n     * @return string\n     */\n    public function getDestination()\n    {\n        return $this->upload['path'] ?? '';\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->upload;\n    }\n\n    /**\n     * @return void\n     */\n    public function checkXss(): void\n    {\n        $tmpFile = $this->getTmpFile();\n        $mime = $this->getClientMediaType();\n        if (Utils::contains($mime, 'svg', false)) {\n            $response = Security::detectXssFromSvgFile($tmpFile);\n            if ($response) {\n                throw new RuntimeException(sprintf('SVG file XSS check failed on %s', $response));\n            }\n        }\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getTmpFile(): ?string\n    {\n        $tmpName = $this->upload['tmp_name'] ?? null;\n\n        if (!$tmpName) {\n            return null;\n        }\n\n        $tmpFile = $this->flash->getTmpDir() . '/' . $tmpName;\n\n        return file_exists($tmpFile) ? $tmpFile : null;\n    }\n\n    /**\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function __debugInfo()\n    {\n        return [\n            'id:private' => $this->id,\n            'field:private' => $this->field,\n            'moved:private' => $this->moved,\n            'upload:private' => $this->upload,\n        ];\n    }\n\n    /**\n     * @return void\n     * @throws RuntimeException if is moved or not ok\n     */\n    private function validateActive(): void\n    {\n        if (!$this->isOk()) {\n            throw new RuntimeException('Cannot retrieve stream due to upload error');\n        }\n\n        if ($this->moved) {\n            throw new RuntimeException('Cannot retrieve stream after it has already been moved');\n        }\n\n        if (!$this->getTmpFile()) {\n            throw new RuntimeException('Cannot retrieve stream as the file is missing');\n        }\n    }\n\n    /**\n     * @return bool return true if there is no upload error\n     */\n    private function isOk(): bool\n    {\n        return \\UPLOAD_ERR_OK === $this->getError();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Form/Interfaces/FormFactoryInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Form\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Form\\Interfaces;\n\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Page\\Page;\n\n/**\n * Interface FormFactoryInterface\n * @package Grav\\Framework\\Form\\Interfaces\n */\ninterface FormFactoryInterface\n{\n    /**\n     * @param Page $page\n     * @param string $name\n     * @param array $form\n     * @return FormInterface|null\n     * @deprecated 1.6 Use FormFactory::createFormByPage() instead.\n     */\n    public function createPageForm(Page $page, string $name, array $form): ?FormInterface;\n\n    /**\n     * Create form using the header of the page.\n     *\n     * @param PageInterface $page\n     * @param string $name\n     * @param array $form\n     * @return FormInterface|null\n     *\n    public function createFormForPage(PageInterface $page, string $name, array $form): ?FormInterface;\n    */\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Form\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Form\\Interfaces;\n\nuse Psr\\Http\\Message\\UploadedFileInterface;\n\n/**\n * Interface FormFlashInterface\n * @package Grav\\Framework\\Form\\Interfaces\n */\ninterface FormFlashInterface extends \\JsonSerializable\n{\n    /**\n     * @param array $config     Available configuration keys: session_id, unique_id, form_name\n     */\n    public function __construct($config);\n\n    /**\n     * Get unique form flash id if set.\n     *\n     * @return string\n     */\n    public function getId(): string;\n\n    /**\n     * Get session Id associated to this form instance.\n     *\n     * @return string\n     */\n    public function getSessionId(): string;\n\n    /**\n     * Get unique identifier associated to this form instance.\n     *\n     * @return string\n     */\n    public function getUniqueId(): string;\n\n    /**\n     * Get form name associated to this form instance.\n     *\n     * @return string\n     */\n    public function getFormName(): string;\n\n    /**\n     * Get URL associated to this form instance.\n     *\n     * @return string\n     */\n    public function getUrl(): string;\n\n    /**\n     * Get username from the user who was associated to this form instance.\n     *\n     * @return string\n     */\n    public function getUsername(): string;\n\n    /**\n     * Get email from the user who was associated to this form instance.\n     *\n     * @return string\n     */\n    public function getUserEmail(): string;\n\n\n    /**\n     * Get creation timestamp for this form flash.\n     *\n     * @return int\n     */\n    public function getCreatedTimestamp(): int;\n\n    /**\n     * Get last updated timestamp for this form flash.\n     *\n     * @return int\n     */\n    public function getUpdatedTimestamp(): int;\n\n    /**\n     * Get raw form data.\n     *\n     * @return array|null\n     */\n    public function getData(): ?array;\n\n    /**\n     * Set raw form data.\n     *\n     * @param array|null $data\n     * @return void\n     */\n    public function setData(?array $data): void;\n\n    /**\n     * Check if this form flash exists.\n     *\n     * @return bool\n     */\n    public function exists(): bool;\n\n    /**\n     * Save this form flash.\n     *\n     * @return $this\n     */\n    public function save();\n\n    /**\n     * Delete this form flash.\n     *\n     * @return $this\n     */\n    public function delete();\n\n    /**\n     * Get all files associated to a form field.\n     *\n     * @param string $field\n     * @return array\n     */\n    public function getFilesByField(string $field): array;\n\n    /**\n     * Get all files grouped by the associated form fields.\n     *\n     * @param bool $includeOriginal\n     * @return array\n     */\n    public function getFilesByFields($includeOriginal = false): array;\n\n    /**\n     * Add uploaded file to the form flash.\n     *\n     * @param UploadedFileInterface $upload\n     * @param string|null $field\n     * @param array|null $crop\n     * @return string Return name of the file\n     */\n    public function addUploadedFile(UploadedFileInterface $upload, string $field = null, array $crop = null): string;\n\n    /**\n     * Add existing file to the form flash.\n     *\n     * @param string $filename\n     * @param string $field\n     * @param array|null $crop\n     * @return bool\n     */\n    public function addFile(string $filename, string $field, array $crop = null): bool;\n\n    /**\n     * Remove any file from form flash.\n     *\n     * @param string $name\n     * @param string|null $field\n     * @return bool\n     */\n    public function removeFile(string $name, string $field = null): bool;\n\n    /**\n     * Clear form flash from all uploaded files.\n     *\n     * @return void\n     */\n    public function clearFiles();\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Form/Interfaces/FormInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Form\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Form\\Interfaces;\n\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Framework\\Interfaces\\RenderInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\n\n/**\n * Interface FormInterface\n * @package Grav\\Framework\\Form\n */\ninterface FormInterface extends RenderInterface, \\Serializable\n{\n    /**\n     * Get HTML id=\"...\" attribute.\n     *\n     * @return string\n     */\n    public function getId(): string;\n\n    /**\n     * Sets HTML id=\"\" attribute.\n     *\n     * @param string $id\n     */\n    public function setId(string $id): void;\n\n    /**\n     * Get unique id for the current form instance. By default regenerated on every page reload.\n     *\n     * This id is used to load the saved form state, if available.\n     *\n     * @return string\n     */\n    public function getUniqueId(): string;\n\n    /**\n     * Sets unique form id.\n     *\n     * @param string $uniqueId\n     */\n    public function setUniqueId(string $uniqueId): void;\n\n    /**\n     * @return string\n     */\n    public function getName(): string;\n\n    /**\n     * Get form name.\n     *\n     * @return string\n     */\n    public function getFormName(): string;\n\n    /**\n     * Get nonce name.\n     *\n     * @return string\n     */\n    public function getNonceName(): string;\n\n    /**\n     * Get nonce action.\n     *\n     * @return string\n     */\n    public function getNonceAction(): string;\n\n    /**\n     * Get the nonce value for a form\n     *\n     * @return string\n     */\n    public function getNonce(): string;\n\n    /**\n     * Get task for the form if set in blueprints.\n     *\n     * @return string\n     */\n    public function getTask(): string;\n\n    /**\n     * Get form action (URL). If action is empty, it points to the current page.\n     *\n     * @return string\n     */\n    public function getAction(): string;\n\n    /**\n     * Get current data passed to the form.\n     *\n     * @return Data|object\n     */\n    public function getData();\n\n    /**\n     * Get files which were passed to the form.\n     *\n     * @return array|UploadedFileInterface[]\n     */\n    public function getFiles(): array;\n\n    /**\n     * Get a value from the form.\n     *\n     * Note: Used in form fields.\n     *\n     * @param string $name\n     * @return mixed\n     */\n    public function getValue(string $name);\n\n    /**\n     * Get form flash object.\n     *\n     * @return FormFlashInterface\n     */\n    public function getFlash();\n\n    /**\n     * @param ServerRequestInterface $request\n     * @return $this\n     */\n    public function handleRequest(ServerRequestInterface $request): FormInterface;\n\n    /**\n     * @param array $data\n     * @param UploadedFileInterface[]|null $files\n     * @return $this\n     */\n    public function submit(array $data, array $files = null): FormInterface;\n\n    /**\n     * @return bool\n     */\n    public function isValid(): bool;\n\n    /**\n     * @return string\n     */\n    public function getError(): ?string;\n\n    /**\n     * @return array\n     */\n    public function getErrors(): array;\n\n    /**\n     * @return bool\n     */\n    public function isSubmitted(): bool;\n\n    /**\n     * Reset form.\n     *\n     * @return void\n     */\n    public function reset(): void;\n\n    /**\n     * Get form fields as an array.\n     *\n     * Note: Used in form fields.\n     *\n     * @return array\n     */\n    public function getFields(): array;\n\n    /**\n     * Get blueprint used in the form.\n     *\n     * @return Blueprint\n     */\n    public function getBlueprint(): Blueprint;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Form/Traits/FormTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Form\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Form\\Traits;\n\nuse ArrayAccess;\nuse Exception;\nuse FilesystemIterator;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Data\\Data;\nuse Grav\\Common\\Data\\ValidationException;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Form\\FormFlash;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Twig\\Twig;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Common\\Utils;\nuse Grav\\Framework\\Compat\\Serializable;\nuse Grav\\Framework\\ContentBlock\\HtmlBlock;\nuse Grav\\Framework\\Form\\FormFlashFile;\nuse Grav\\Framework\\Form\\Interfaces\\FormFlashInterface;\nuse Grav\\Framework\\Form\\Interfaces\\FormInterface;\nuse Grav\\Framework\\Session\\SessionInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse RuntimeException;\nuse SplFileInfo;\nuse Twig\\Error\\LoaderError;\nuse Twig\\Error\\SyntaxError;\nuse Twig\\Template;\nuse Twig\\TemplateWrapper;\nuse function in_array;\nuse function is_array;\nuse function is_object;\n\n/**\n * Trait FormTrait\n * @package Grav\\Framework\\Form\n */\ntrait FormTrait\n{\n    use Serializable;\n\n    /** @var string */\n    public $status = 'success';\n    /** @var string|null */\n    public $message;\n    /** @var string[] */\n    public $messages = [];\n\n    /** @var string */\n    private $name;\n    /** @var string */\n    private $id;\n    /** @var bool */\n    private $enabled = true;\n    /** @var string */\n    private $uniqueid;\n    /** @var string */\n    private $sessionid;\n    /** @var bool */\n    private $submitted;\n    /** @var ArrayAccess<string,mixed>|Data|null */\n    private $data;\n    /** @var UploadedFileInterface[] */\n    private $files = [];\n    /** @var FormFlashInterface|null */\n    private $flash;\n    /** @var string */\n    private $flashFolder;\n    /** @var Blueprint */\n    private $blueprint;\n\n    /**\n     * @return string\n     */\n    public function getId(): string\n    {\n        return $this->id;\n    }\n\n    /**\n     * @param string $id\n     */\n    public function setId(string $id): void\n    {\n        $this->id = $id;\n    }\n\n    /**\n     * @return void\n     */\n    public function disable(): void\n    {\n        $this->enabled = false;\n    }\n\n    /**\n     * @return void\n     */\n    public function enable(): void\n    {\n        $this->enabled = true;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isEnabled(): bool\n    {\n        return $this->enabled;\n    }\n\n    /**\n     * @return string\n     */\n    public function getUniqueId(): string\n    {\n        return $this->uniqueid;\n    }\n\n    /**\n     * @param string $uniqueId\n     * @return void\n     */\n    public function setUniqueId(string $uniqueId): void\n    {\n        $this->uniqueid = $uniqueId;\n    }\n\n    /**\n     * @return string\n     */\n    public function getName(): string\n    {\n        return $this->name;\n    }\n\n    /**\n     * @return string\n     */\n    public function getFormName(): string\n    {\n        return $this->name;\n    }\n\n    /**\n     * @return string\n     */\n    public function getNonceName(): string\n    {\n        return 'form-nonce';\n    }\n\n    /**\n     * @return string\n     */\n    public function getNonceAction(): string\n    {\n        return 'form';\n    }\n\n    /**\n     * @return string\n     */\n    public function getNonce(): string\n    {\n        return Utils::getNonce($this->getNonceAction());\n    }\n\n    /**\n     * @return string\n     */\n    public function getAction(): string\n    {\n        return '';\n    }\n\n    /**\n     * @return string\n     */\n    public function getTask(): string\n    {\n        return $this->getBlueprint()->get('form/task') ?? '';\n    }\n\n    /**\n     * @param string|null $name\n     * @return mixed\n     */\n    public function getData(string $name = null)\n    {\n        return null !== $name ? $this->data[$name] : $this->data;\n    }\n\n    /**\n     * @return array|UploadedFileInterface[]\n     */\n    public function getFiles(): array\n    {\n        return $this->files;\n    }\n\n    /**\n     * @param string $name\n     * @return mixed|null\n     */\n    public function getValue(string $name)\n    {\n        return $this->data[$name] ?? null;\n    }\n\n    /**\n     * @param string $name\n     * @return mixed|null\n     */\n    public function getDefaultValue(string $name)\n    {\n        $path = explode('.', $name);\n        $offset = array_shift($path);\n\n        $current = $this->getDefaultValues();\n\n        if (!isset($current[$offset])) {\n            return null;\n        }\n\n        $current = $current[$offset];\n\n        while ($path) {\n            $offset = array_shift($path);\n\n            if ((is_array($current) || $current instanceof ArrayAccess) && isset($current[$offset])) {\n                $current = $current[$offset];\n            } elseif (is_object($current) && isset($current->{$offset})) {\n                $current = $current->{$offset};\n            } else {\n                return null;\n            }\n        };\n\n        return $current;\n    }\n\n    /**\n     * @return array\n     */\n    public function getDefaultValues(): array\n    {\n        return $this->getBlueprint()->getDefaults();\n    }\n\n    /**\n     * @param ServerRequestInterface $request\n     * @return FormInterface|$this\n     */\n    public function handleRequest(ServerRequestInterface $request): FormInterface\n    {\n        // Set current form to be active.\n        $grav = Grav::instance();\n        $forms = $grav['forms'] ?? null;\n        if ($forms) {\n            $forms->setActiveForm($this);\n\n            /** @var Twig $twig */\n            $twig = $grav['twig'];\n            $twig->twig_vars['form'] = $this;\n        }\n\n        try {\n            [$data, $files] = $this->parseRequest($request);\n\n            $this->submit($data, $files);\n        } catch (Exception $e) {\n            /** @var Debugger $debugger */\n            $debugger = $grav['debugger'];\n            $debugger->addException($e);\n\n            $this->setError($e->getMessage());\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param ServerRequestInterface $request\n     * @return FormInterface|$this\n     */\n    public function setRequest(ServerRequestInterface $request): FormInterface\n    {\n        [$data, $files] = $this->parseRequest($request);\n\n        $this->data = new Data($data, $this->getBlueprint());\n        $this->files = $files;\n\n        return $this;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isValid(): bool\n    {\n        return $this->status === 'success';\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getError(): ?string\n    {\n        return !$this->isValid() ? $this->message : null;\n    }\n\n    /**\n     * @return array\n     */\n    public function getErrors(): array\n    {\n        return !$this->isValid() ? $this->messages : [];\n    }\n\n    /**\n     * @return bool\n     */\n    public function isSubmitted(): bool\n    {\n        return $this->submitted;\n    }\n\n    /**\n     * @return bool\n     */\n    public function validate(): bool\n    {\n        if (!$this->isValid()) {\n            return false;\n        }\n\n        try {\n            $this->validateData($this->data);\n            $this->validateUploads($this->getFiles());\n        } catch (ValidationException $e) {\n            $this->setErrors($e->getMessages());\n        } catch (Exception $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            $this->setError($e->getMessage());\n        }\n\n        $this->filterData($this->data);\n\n        return $this->isValid();\n    }\n\n    /**\n     * @param array $data\n     * @param UploadedFileInterface[]|null $files\n     * @return FormInterface|$this\n     */\n    public function submit(array $data, array $files = null): FormInterface\n    {\n        try {\n            if ($this->isSubmitted()) {\n                throw new RuntimeException('Form has already been submitted');\n            }\n\n            $this->data = new Data($data, $this->getBlueprint());\n            $this->files = $files ?? [];\n\n            if (!$this->validate()) {\n                return $this;\n            }\n\n            $this->doSubmit($this->data->toArray(), $this->files);\n\n            $this->submitted = true;\n        } catch (Exception $e) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addException($e);\n\n            $this->setError($e->getMessage());\n        }\n\n        return $this;\n    }\n\n    /**\n     * @return void\n     */\n    public function reset(): void\n    {\n        // Make sure that the flash object gets deleted.\n        $this->getFlash()->delete();\n\n        $this->data = null;\n        $this->files = [];\n        $this->status = 'success';\n        $this->message = null;\n        $this->messages = [];\n        $this->submitted = false;\n        $this->flash = null;\n    }\n\n    /**\n     * @return array\n     */\n    public function getFields(): array\n    {\n        return $this->getBlueprint()->fields();\n    }\n\n    /**\n     * @return array\n     */\n    public function getButtons(): array\n    {\n        return $this->getBlueprint()->get('form/buttons') ?? [];\n    }\n\n    /**\n     * @return array\n     */\n    public function getTasks(): array\n    {\n        return $this->getBlueprint()->get('form/tasks') ?? [];\n    }\n\n    /**\n     * @return Blueprint\n     */\n    abstract public function getBlueprint(): Blueprint;\n\n    /**\n     * Get form flash object.\n     *\n     * @return FormFlashInterface\n     */\n    public function getFlash()\n    {\n        if (null === $this->flash) {\n            $grav = Grav::instance();\n            $config = [\n                'session_id' => $this->getSessionId(),\n                'unique_id' => $this->getUniqueId(),\n                'form_name' => $this->getName(),\n                'folder' => $this->getFlashFolder(),\n                'id' => $this->getFlashId()\n            ];\n\n            $this->flash = new FormFlash($config);\n            $this->flash->setUrl($grav['uri']->url)->setUser($grav['user'] ?? null);\n        }\n\n        return $this->flash;\n    }\n\n    /**\n     * Get all available form flash objects for this form.\n     *\n     * @return FormFlashInterface[]\n     */\n    public function getAllFlashes(): array\n    {\n        $folder = $this->getFlashFolder();\n        if (!$folder || !is_dir($folder)) {\n            return [];\n        }\n\n        $name = $this->getName();\n\n        $list = [];\n        /** @var SplFileInfo $file */\n        foreach (new FilesystemIterator($folder) as $file) {\n            $uniqueId = $file->getFilename();\n            $config = [\n                'session_id' => $this->getSessionId(),\n                'unique_id' => $uniqueId,\n                'form_name' => $name,\n                'folder' => $this->getFlashFolder(),\n                'id' => $this->getFlashId()\n            ];\n            $flash = new FormFlash($config);\n            if ($flash->exists() && $flash->getFormName() === $name) {\n                $list[] = $flash;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * {@inheritdoc}\n     * @see FormInterface::render()\n     */\n    public function render(string $layout = null, array $context = [])\n    {\n        if (null === $layout) {\n            $layout = 'default';\n        }\n\n        $grav = Grav::instance();\n\n        $block = HtmlBlock::create();\n        $block->disableCache();\n\n        $output = $this->getTemplate($layout)->render(\n            ['grav' => $grav, 'config' => $grav['config'], 'block' => $block, 'form' => $this, 'layout' => $layout] + $context\n        );\n\n        $block->setContent($output);\n\n        return $block;\n    }\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        return $this->doSerialize();\n    }\n\n    /**\n     * @return array\n     */\n    final public function __serialize(): array\n    {\n        return $this->doSerialize();\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    final public function __unserialize(array $data): void\n    {\n        $this->doUnserialize($data);\n    }\n\n    protected function getSessionId(): string\n    {\n        if (null === $this->sessionid) {\n            /** @var Grav $grav */\n            $grav = Grav::instance();\n\n            /** @var SessionInterface|null $session */\n            $session = $grav['session'] ?? null;\n\n            $this->sessionid = $session ? ($session->getId() ?? '') : '';\n        }\n\n        return $this->sessionid;\n    }\n\n    /**\n     * @param string $sessionId\n     * @return void\n     */\n    protected function setSessionId(string $sessionId): void\n    {\n        $this->sessionid = $sessionId;\n    }\n\n    /**\n     * @return void\n     */\n    protected function unsetFlash(): void\n    {\n        $this->flash = null;\n    }\n\n    /**\n     * @return string|null\n     */\n    protected function getFlashFolder(): ?string\n    {\n        $grav = Grav::instance();\n\n        /** @var UserInterface|null $user */\n        $user = $grav['user'] ?? null;\n        if (null !== $user && $user->exists()) {\n            $username = $user->username;\n            $mediaFolder = $user->getMediaFolder();\n        } else {\n            $username = null;\n            $mediaFolder = null;\n        }\n        $session = $grav['session'] ?? null;\n        $sessionId = $session ? $session->getId() : null;\n\n        // Fill template token keys/value pairs.\n        $dataMap = [\n            '[FORM_NAME]' => $this->getName(),\n            '[SESSIONID]' => $sessionId ?? '!!',\n            '[USERNAME]' => $username ?? '!!',\n            '[USERNAME_OR_SESSIONID]' => $username ?? $sessionId ?? '!!',\n            '[ACCOUNT]' => $mediaFolder ?? '!!'\n        ];\n\n        $flashLookupFolder = $this->getFlashLookupFolder();\n\n        $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashLookupFolder);\n\n        // Make sure we only return valid paths.\n        return strpos($path, '!!') === false ? rtrim($path, '/') : null;\n    }\n\n    /**\n     * @return string|null\n     */\n    protected function getFlashId(): ?string\n    {\n        // Fill template token keys/value pairs.\n        $dataMap = [\n            '[FORM_NAME]' => $this->getName(),\n            '[SESSIONID]' => 'session',\n            '[USERNAME]' => '!!',\n            '[USERNAME_OR_SESSIONID]' => '!!',\n            '[ACCOUNT]' => 'account'\n        ];\n\n        $flashLookupFolder = $this->getFlashLookupFolder();\n\n        $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashLookupFolder);\n\n        // Make sure we only return valid paths.\n        return strpos($path, '!!') === false ? rtrim($path, '/') : null;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getFlashLookupFolder(): string\n    {\n        if (null === $this->flashFolder) {\n            $this->flashFolder = $this->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]';\n        }\n\n        return $this->flashFolder;\n    }\n\n    /**\n     * @param string $folder\n     * @return void\n     */\n    protected function setFlashLookupFolder(string $folder): void\n    {\n        $this->flashFolder = $folder;\n    }\n\n    /**\n     * Set a single error.\n     *\n     * @param string $error\n     * @return void\n     */\n    protected function setError(string $error): void\n    {\n        $this->status = 'error';\n        $this->message = $error;\n    }\n\n    /**\n     * Set all errors.\n     *\n     * @param array $errors\n     * @return void\n     */\n    protected function setErrors(array $errors): void\n    {\n        $this->status = 'error';\n        $this->messages = $errors;\n    }\n\n    /**\n     * @param string $layout\n     * @return Template|TemplateWrapper\n     * @throws LoaderError\n     * @throws SyntaxError\n     */\n    protected function getTemplate($layout)\n    {\n        $grav = Grav::instance();\n\n        /** @var Twig $twig */\n        $twig = $grav['twig'];\n\n        return $twig->twig()->resolveTemplate(\n            [\n                \"forms/{$layout}/form.html.twig\",\n                'forms/default/form.html.twig'\n            ]\n        );\n    }\n\n    /**\n     * Parse PSR-7 ServerRequest into data and files.\n     *\n     * @param ServerRequestInterface $request\n     * @return array\n     */\n    protected function parseRequest(ServerRequestInterface $request): array\n    {\n        $method = $request->getMethod();\n        if (!in_array($method, ['PUT', 'POST', 'PATCH'])) {\n            throw new RuntimeException(sprintf('FlexForm: Bad HTTP method %s', $method));\n        }\n\n        $body = (array)$request->getParsedBody();\n        $data = isset($body['data']) ? $this->decodeData($body['data']) : null;\n\n        $flash = $this->getFlash();\n        /*\n        if (null !== $data) {\n            $flash->setData($data);\n            $flash->save();\n        }\n        */\n\n        $blueprint = $this->getBlueprint();\n        $includeOriginal = (bool)($blueprint->form()['images']['original'] ?? null);\n        $files = $flash->getFilesByFields($includeOriginal);\n\n        $data = $blueprint->processForm($data ?? [], $body['toggleable_data'] ?? []);\n\n        return [\n            $data,\n            $files\n        ];\n    }\n\n    /**\n     * Validate data and throw validation exceptions if validation fails.\n     *\n     * @param ArrayAccess|Data|null $data\n     * @return void\n     * @throws ValidationException\n     * @phpstan-param ArrayAccess<string,mixed>|Data|null $data\n     * @throws Exception\n     */\n    protected function validateData($data = null): void\n    {\n        if ($data instanceof Data) {\n            $data->validate();\n        }\n    }\n\n    /**\n     * Filter validated data.\n     *\n     * @param ArrayAccess|Data|null $data\n     * @return void\n     * @phpstan-param ArrayAccess<string,mixed>|Data|null $data\n     */\n    protected function filterData($data = null): void\n    {\n        if ($data instanceof Data) {\n            $data->filter();\n        }\n    }\n\n    /**\n     * Validate all uploaded files.\n     *\n     * @param array $files\n     * @return void\n     */\n    protected function validateUploads(array $files): void\n    {\n        foreach ($files as $file) {\n            if (null === $file) {\n                continue;\n            }\n            if ($file instanceof UploadedFileInterface) {\n                $this->validateUpload($file);\n            } else {\n                $this->validateUploads($file);\n            }\n        }\n    }\n\n    /**\n     * Validate uploaded file.\n     *\n     * @param UploadedFileInterface $file\n     * @return void\n     */\n    protected function validateUpload(UploadedFileInterface $file): void\n    {\n        // Handle bad filenames.\n        $filename = $file->getClientFilename();\n        if ($filename && !Utils::checkFilename($filename)) {\n            $grav = Grav::instance();\n            throw new RuntimeException(\n                sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true), $filename, 'Bad filename')\n            );\n        }\n\n        if ($file instanceof FormFlashFile) {\n            $file->checkXss();\n        }\n    }\n\n    /**\n     * Decode POST data\n     *\n     * @param array $data\n     * @return array\n     */\n    protected function decodeData($data): array\n    {\n        if (!is_array($data)) {\n            return [];\n        }\n\n        // Decode JSON encoded fields and merge them to data.\n        if (isset($data['_json'])) {\n            $data = array_replace_recursive($data, $this->jsonDecode($data['_json']));\n\n            unset($data['_json']);\n        }\n\n        return $data;\n    }\n\n    /**\n     * Recursively JSON decode POST data.\n     *\n     * @param  array $data\n     * @return array\n     */\n    protected function jsonDecode(array $data): array\n    {\n        foreach ($data as $key => &$value) {\n            if (is_array($value)) {\n                $value = $this->jsonDecode($value);\n            } elseif (trim($value) === '') {\n                unset($data[$key]);\n            } else {\n                $value = json_decode($value, true);\n                if ($value === null && json_last_error() !== JSON_ERROR_NONE) {\n                    unset($data[$key]);\n                    $this->setError(\"Badly encoded JSON data (for {$key}) was sent to the form\");\n                }\n            }\n        }\n\n        return $data;\n    }\n\n    /**\n     * @return array\n     */\n    protected function doSerialize(): array\n    {\n        $data = $this->data instanceof Data ? $this->data->toArray() : null;\n\n        return [\n            'name' => $this->name,\n            'id' => $this->id,\n            'uniqueid' => $this->uniqueid,\n            'submitted' => $this->submitted,\n            'status' => $this->status,\n            'message' => $this->message,\n            'messages' => $this->messages,\n            'data' => $data,\n            'files' => $this->files,\n        ];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    protected function doUnserialize(array $data): void\n    {\n        $this->name = $data['name'];\n        $this->id = $data['id'];\n        $this->uniqueid = $data['uniqueid'];\n        $this->submitted = $data['submitted'] ?? false;\n        $this->status = $data['status'] ?? 'success';\n        $this->message = $data['message'] ?? null;\n        $this->messages = $data['messages'] ?? [];\n        $this->data = isset($data['data']) ? new Data($data['data'], $this->getBlueprint()) : null;\n        $this->files = $data['files'] ?? [];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Interfaces/RenderInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Interfaces\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Interfaces;\n\nuse Grav\\Framework\\ContentBlock\\ContentBlockInterface;\nuse Grav\\Framework\\ContentBlock\\HtmlBlock;\n\n/**\n * Defines common interface to render any object.\n *\n * @used-by \\Grav\\Framework\\Flex\\FlexObject\n * @since 1.6\n */\ninterface RenderInterface\n{\n    /**\n     * Renders the object.\n     *\n     * @example $block = $object->render('custom', ['variable' => 'value']);\n     * @example {% render object layout 'custom' with { variable: 'value' } %}\n     *\n     * @param string|null $layout  Layout to be used.\n     * @param array       $context Extra context given to the renderer.\n     *\n     * @return ContentBlockInterface|HtmlBlock Returns `HtmlBlock` containing the rendered output.\n     * @api\n     */\n    public function render(string $layout = null, array $context = []);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Logger/Processors/UserProcessor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Logger\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Logger\\Processors;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Monolog\\Processor\\ProcessorInterface;\n\n/**\n * Adds username and email to log messages.\n */\nclass UserProcessor implements ProcessorInterface\n{\n    /**\n     * {@inheritDoc}\n     */\n    public function __invoke(array $record): array\n    {\n        /** @var UserInterface|null $user */\n        $user = Grav::instance()['user'] ?? null;\n        if ($user && $user->exists()) {\n            $record['extra']['user'] = ['username' => $user->username, 'email' => $user->email];\n        }\n\n        return $record;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Media/Interfaces/MediaCollectionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Media\\Interfaces;\n\nuse ArrayAccess;\nuse Countable;\nuse Iterator;\n\n/**\n * Class implements media collection interface.\n * @extends ArrayAccess<string,MediaObjectInterface>\n * @extends Iterator<string,MediaObjectInterface>\n */\ninterface MediaCollectionInterface extends ArrayAccess, Countable, Iterator\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Media/Interfaces/MediaInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Media\\Interfaces;\n\n/**\n * Class implements media interface.\n */\ninterface MediaInterface\n{\n    /**\n     * Gets the associated media collection.\n     *\n     * @return MediaCollectionInterface  Collection of associated media.\n     */\n    public function getMedia();\n\n    /**\n     * Get filesystem path to the associated media.\n     *\n     * @return string|null  Media path or null if the object doesn't have media folder.\n     */\n    public function getMediaFolder();\n\n    /**\n     * Get display order for the associated media.\n     *\n     * @return array Empty array means default ordering.\n     */\n    public function getMediaOrder();\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Media/Interfaces/MediaManipulationInterface.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Media\\Interfaces;\n\nuse Grav\\Common\\Media\\Interfaces\\MediaInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\n\n/**\n * Interface MediaManipulationInterface\n * @package Grav\\Framework\\Media\\Interfaces\n * @deprecated 1.7 Not used currently\n */\ninterface MediaManipulationInterface extends MediaInterface\n{\n    /**\n     * @param UploadedFileInterface $uploadedFile\n     */\n    public function uploadMediaFile(UploadedFileInterface $uploadedFile): void;\n\n    /**\n     * @param string $filename\n     */\n    public function deleteMediaFile(string $filename): void;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Media/Interfaces/MediaObjectInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Media\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Media\\Interfaces;\n\nuse Psr\\Http\\Message\\UploadedFileInterface;\n\n/**\n * Class implements media object interface.\n *\n * @property UploadedFileInterface|null $uploaded_file\n */\ninterface MediaObjectInterface\n{\n    /**\n     * Returns an array containing the file metadata\n     *\n     * @return array\n     */\n    public function getMeta();\n\n    /**\n     * Return URL to file.\n     *\n     * @param bool $reset\n     * @return string\n     */\n    public function url($reset = true);\n\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @example $value = $this->get('this.is.my.nested.variable');\n     *\n     * @param string $name Dot separated path to the requested value.\n     * @param mixed $default Default value (or null).\n     * @param string|null $separator Separator, defaults to '.'\n     * @return mixed Value.\n     */\n    public function get($name, $default = null, $separator = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Media/MediaIdentifier.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Media;\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Framework\\Contracts\\Media\\MediaObjectInterface;\nuse Grav\\Framework\\Flex\\Flex;\nuse Grav\\Framework\\Flex\\FlexFormFlash;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Object\\Identifiers\\Identifier;\n\n/**\n * Interface IdentifierInterface\n *\n * @template T of MediaObjectInterface\n * @extends Identifier<T>\n */\nclass MediaIdentifier extends Identifier\n{\n    /** @var MediaObjectInterface|null */\n    private $object = null;\n\n    /**\n     * @param MediaObjectInterface $object\n     * @return MediaIdentifier<T>\n     */\n    public static function createFromObject(MediaObjectInterface $object): MediaIdentifier\n    {\n        $instance = new static($object->getId());\n        $instance->setObject($object);\n\n        return $instance;\n    }\n\n    /**\n     * @param string $id\n     */\n    public function __construct(string $id)\n    {\n        parent::__construct($id, 'media');\n    }\n\n    /**\n     * @return T\n     */\n    public function getObject(): ?MediaObjectInterface\n    {\n        if (!isset($this->object)) {\n            $type = $this->getType();\n            $id = $this->getId();\n\n            $parts = explode('/', $id);\n            if ($type === 'media' && str_starts_with($id, 'uploads/')) {\n                array_shift($parts);\n                [, $folder, $uniqueId, $field, $filename] = $this->findFlash($parts);\n\n                $flash = $this->getFlash($folder, $uniqueId);\n                if ($flash->exists()) {\n\n                    $uploadedFile = $flash->getFilesByField($field)[$filename] ?? null;\n\n                    $this->object = UploadedMediaObject::createFromFlash($flash, $field, $filename, $uploadedFile);\n                }\n            } else {\n                $type = array_shift($parts);\n                $key = array_shift($parts);\n                $field = array_shift($parts);\n                $filename = implode('/', $parts);\n\n                $flexObject = $this->getFlexObject($type, $key);\n                if ($flexObject && method_exists($flexObject, 'getMediaField') && method_exists($flexObject, 'getMedia')) {\n                    $media = $field !== 'media' ? $flexObject->getMediaField($field) : $flexObject->getMedia();\n                    $image = null;\n                    if ($media) {\n                        $image = $media[$filename];\n                    }\n\n                    $this->object = new MediaObject($field, $filename, $image, $flexObject);\n                }\n            }\n\n            if (!isset($this->object)) {\n                throw new \\RuntimeException(sprintf('Object not found for identifier {type: \"%s\", id: \"%s\"}', $type, $id));\n            }\n        }\n\n        return $this->object;\n    }\n\n    /**\n     * @param T $object\n     */\n    public function setObject(MediaObjectInterface $object): void\n    {\n        $type = $this->getType();\n        $objectType = $object->getType();\n\n        if ($type !== $objectType) {\n            throw new \\RuntimeException(sprintf('Object has to be type %s, %s given', $type, $objectType));\n        }\n\n        $this->object = $object;\n    }\n\n    protected function findFlash(array $parts): ?array\n    {\n        $type = array_shift($parts);\n        if ($type === 'account') {\n            /** @var UserInterface|null $user */\n            $user = Grav::instance()['user'] ?? null;\n            $folder = $user->getMediaFolder();\n        } else {\n            $folder = 'tmp://';\n        }\n\n        if (!$folder) {\n            return null;\n        }\n\n        do {\n            $part = array_shift($parts);\n            $folder .= \"/{$part}\";\n        } while (!str_starts_with($part, 'flex-'));\n\n        $uniqueId = array_shift($parts);\n        $field = array_shift($parts);\n        $filename = implode('/', $parts);\n\n        return [$type, $folder, $uniqueId, $field, $filename];\n    }\n\n    protected function getFlash(string $folder, string $uniqueId): FlexFormFlash\n    {\n        $config = [\n            'unique_id' => $uniqueId,\n            'folder' => $folder\n        ];\n\n        return new FlexFormFlash($config);\n    }\n\n    protected function getFlexObject(string $type, string $key): ?FlexObjectInterface\n    {\n        /** @var Flex $flex */\n        $flex = Grav::instance()['flex'];\n\n        return $flex->getObject($key, $type);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Media/MediaObject.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Media;\n\nuse Grav\\Common\\Page\\Medium\\ImageMedium;\nuse Grav\\Framework\\Contracts\\Media\\MediaObjectInterface;\nuse Grav\\Framework\\Flex\\Interfaces\\FlexObjectInterface;\nuse Grav\\Framework\\Media\\Interfaces\\MediaObjectInterface as GravMediaObjectInterface;\nuse Grav\\Framework\\Psr7\\Response;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Throwable;\n\n/**\n * Class MediaObject\n */\nclass MediaObject implements MediaObjectInterface\n{\n    /** @var string */\n    static public $placeholderImage = 'image://media/thumb.png';\n\n    /** @var FlexObjectInterface */\n    public $object;\n    /** @var GravMediaObjectInterface|null */\n    public $media;\n\n    /** @var string|null */\n    private $field;\n    /** @var string */\n    private $filename;\n\n    /**\n     * MediaObject constructor.\n     * @param string|null $field\n     * @param string $filename\n     * @param GravMediaObjectInterface|null $media\n     * @param FlexObjectInterface $object\n     */\n    public function __construct(?string $field, string $filename, ?GravMediaObjectInterface $media, FlexObjectInterface $object)\n    {\n        $this->field = $field;\n        $this->filename = $filename;\n        $this->media = $media;\n        $this->object = $object;\n    }\n\n    /**\n     * @return string\n     */\n    public function getType(): string\n    {\n        return 'media';\n    }\n\n    /**\n     * @return string\n     */\n    public function getId(): string\n    {\n        $field = $this->field;\n        $object = $this->object;\n        $path = $field ? \"/{$field}/\" : '/media/';\n\n        return $object->getType() . '/' . $object->getKey() . $path . basename($this->filename);\n    }\n\n    /**\n     * @return bool\n     */\n    public function exists(): bool\n    {\n        return $this->media !== null;\n    }\n\n    /**\n     * @return array\n     */\n    public function getMeta(): array\n    {\n        if (!isset($this->media)) {\n            return [];\n        }\n\n        return $this->media->getMeta();\n    }\n\n    /**\n     * @param string $field\n     * @return mixed|null\n     */\n    public function get(string $field)\n    {\n        if (!isset($this->media)) {\n            return null;\n        }\n\n        return $this->media->get($field);\n    }\n\n    /**\n     * @return string\n     */\n    public function getUrl(): string\n    {\n        if (!isset($this->media)) {\n            return '';\n        }\n\n        return $this->media->url();\n    }\n\n    /**\n     * Create media response.\n     *\n     * @param array $actions\n     * @return Response\n     */\n    public function createResponse(array $actions): ResponseInterface\n    {\n        if (!isset($this->media)) {\n            return $this->create404Response($actions);\n        }\n\n        $media = $this->media;\n\n        if ($actions) {\n            $media = $this->processMediaActions($media, $actions);\n        }\n\n        // FIXME: This only works for images\n        if (!$media instanceof ImageMedium) {\n            throw new \\RuntimeException('Not Implemented', 500);\n        }\n\n        $filename = $media->path(false);\n        $time = filemtime($filename);\n        $size = filesize($filename);\n        $body = fopen($filename, 'rb');\n        $headers = [\n            'Content-Type' => $media->get('mime'),\n            'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT',\n            'ETag' => sprintf('%x-%x', $size, $time)\n        ];\n\n        return new Response(200, $headers, $body);\n    }\n\n    /**\n     * Process media actions\n     *\n     * @param GravMediaObjectInterface $medium\n     * @param array $actions\n     * @return GravMediaObjectInterface\n     */\n    protected function processMediaActions(GravMediaObjectInterface $medium, array $actions): GravMediaObjectInterface\n    {\n        // loop through actions for the image and call them\n        foreach ($actions as $method => $params) {\n            $matches = [];\n\n            if (preg_match('/\\[(.*)]/', $params, $matches)) {\n                $args = [explode(',', $matches[1])];\n            } else {\n                $args = explode(',', $params);\n            }\n\n            try {\n                $medium->{$method}(...$args);\n            } catch (Throwable $e) {\n                // Ignore all errors for now and just skip the action.\n            }\n        }\n\n        return $medium;\n    }\n\n    /**\n     * @param array $actions\n     * @return Response\n     */\n    protected function create404Response(array $actions): Response\n    {\n        // Display placeholder image.\n        $filename = static::$placeholderImage;\n\n        $time = filemtime($filename);\n        $size = filesize($filename);\n        $body = fopen($filename, 'rb');\n        $headers = [\n            'Content-Type' => 'image/svg',\n            'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT',\n            'ETag' => sprintf('%x-%x', $size, $time)\n        ];\n\n        return new Response(404, $headers, $body);\n    }\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        return [\n            'type' => $this->getType(),\n            'id' => $this->getId()\n        ];\n    }\n\n    /**\n     * @return string[]\n     */\n    public function __debugInfo(): array\n    {\n        return $this->jsonSerialize();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Media/UploadedMediaObject.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Media;\n\nuse Grav\\Framework\\Contracts\\Media\\MediaObjectInterface;\nuse Grav\\Framework\\Flex\\FlexFormFlash;\nuse Grav\\Framework\\Form\\Interfaces\\FormFlashInterface;\nuse Grav\\Framework\\Psr7\\Response;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\n\n/**\n * Class UploadedMediaObject\n */\nclass UploadedMediaObject implements MediaObjectInterface\n{\n    /** @var string */\n    static public $placeholderImage = 'image://media/thumb.png';\n\n    /** @var FormFlashInterface */\n    public $object;\n\n    /** @var string */\n    private $id;\n    /** @var string|null */\n    private $field;\n    /** @var string */\n    private $filename;\n    /** @var array */\n    private $meta;\n    /** @var UploadedFileInterface|null */\n    private $uploadedFile;\n\n    /**\n     * @param FlexFormFlash $flash\n     * @param string|null $field\n     * @param string $filename\n     * @param UploadedFileInterface|null $uploadedFile\n     * @return static\n     */\n    public static function createFromFlash(FlexFormFlash $flash, ?string $field, string $filename, ?UploadedFileInterface $uploadedFile = null)\n    {\n        $id = $flash->getId();\n\n        return new static($id, $field, $filename, $uploadedFile);\n    }\n\n    /**\n     * @param string $id\n     * @param string|null $field\n     * @param string $filename\n     * @param UploadedFileInterface|null $uploadedFile\n     */\n    public function __construct(string $id, ?string $field, string $filename, ?UploadedFileInterface $uploadedFile = null)\n    {\n        $this->id = $id;\n        $this->field = $field;\n        $this->filename = $filename;\n        $this->uploadedFile = $uploadedFile;\n        if ($uploadedFile) {\n            $this->meta = [\n                'filename' => $uploadedFile->getClientFilename(),\n                'mime' => $uploadedFile->getClientMediaType(),\n                'size' => $uploadedFile->getSize()\n            ];\n        } else {\n            $this->meta = [];\n        }\n    }\n\n    /**\n     * @return string\n     */\n    public function getType(): string\n    {\n        return 'media';\n    }\n\n    /**\n     * @return string\n     */\n    public function getId(): string\n    {\n        $id = $this->id;\n        $field = $this->field;\n        $path = $field ? \"/{$field}/\" : '';\n\n        return 'uploads/' . $id . $path . basename($this->filename);\n    }\n\n    /**\n     * @return bool\n     */\n    public function exists(): bool\n    {\n        //return $this->uploadedFile !== null;\n        return false;\n    }\n\n    /**\n     * @return array\n     */\n    public function getMeta(): array\n    {\n        return $this->meta;\n    }\n\n    /**\n     * @param string $field\n     * @return mixed|null\n     */\n    public function get(string $field)\n    {\n        return $this->meta[$field] ?? null;\n    }\n\n    /**\n     * @return string\n     */\n    public function getUrl(): string\n    {\n        return '';\n    }\n\n    /**\n     * @return UploadedFileInterface|null\n     */\n    public function getUploadedFile(): ?UploadedFileInterface\n    {\n        return $this->uploadedFile;\n    }\n\n    /**\n     * @param array $actions\n     * @return Response\n     */\n    public function createResponse(array $actions): ResponseInterface\n    {\n        // Display placeholder image.\n        $filename = static::$placeholderImage;\n\n        $time = filemtime($filename);\n        $size = filesize($filename);\n        $body = fopen($filename, 'rb');\n        $headers = [\n            'Content-Type' => 'image/svg',\n            'Last-Modified' => gmdate('D, d M Y H:i:s', $time) . ' GMT',\n            'ETag' => sprintf('%x-%x', $size, $time)\n        ];\n\n        return new Response(404, $headers, $body);\n    }\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        return [\n            'type' => $this->getType(),\n            'id' => $this->getId()\n        ];\n    }\n\n    /**\n     * @return string[]\n     */\n    public function __debugInfo(): array\n    {\n        return $this->jsonSerialize();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Mime/MimeTypes.php",
    "content": "<?php declare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Mime\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Mime;\n\nuse function in_array;\n\n/**\n * Class to handle mime-types.\n */\nclass MimeTypes\n{\n    /** @var array */\n    protected $extensions;\n    /** @var array */\n    protected $mimes;\n\n    /**\n     * Create a new mime types instance with the given mappings.\n     *\n     * @param array $mimes An associative array containing ['ext' => ['mime/type', 'mime/type2']]\n     */\n    public static function createFromMimes(array $mimes): self\n    {\n        $extensions = [];\n        foreach ($mimes as $ext => $list) {\n            foreach ($list as $mime) {\n                $list = $extensions[$mime] ?? [];\n                if (!in_array($ext, $list, true)) {\n                    $list[] = $ext;\n                    $extensions[$mime] = $list;\n                }\n            }\n        }\n\n        return new static($extensions, $mimes);\n    }\n\n    /**\n     * @param string $extension\n     * @return string|null\n     */\n    public function getMimeType(string $extension): ?string\n    {\n        $extension = $this->cleanInput($extension);\n\n        return $this->mimes[$extension][0] ?? null;\n    }\n\n    /**\n     * @param string $mime\n     * @return string|null\n     */\n    public function getExtension(string $mime): ?string\n    {\n        $mime = $this->cleanInput($mime);\n\n        return $this->extensions[$mime][0] ?? null;\n    }\n\n    /**\n     * @param string $extension\n     * @return array\n     */\n    public function getMimeTypes(string $extension): array\n    {\n        $extension = $this->cleanInput($extension);\n\n        return $this->mimes[$extension] ?? [];\n    }\n\n    /**\n     * @param string $mime\n     * @return array\n     */\n    public function getExtensions(string $mime): array\n    {\n        $mime = $this->cleanInput($mime);\n\n        return $this->extensions[$mime] ?? [];\n    }\n\n    /**\n     * @param string $input\n     * @return string\n     */\n    protected function cleanInput(string $input): string\n    {\n        return strtolower(trim($input));\n    }\n\n    /**\n     * @param array $extensions\n     * @param array $mimes\n     */\n    protected function __construct(array $extensions, array $mimes)\n    {\n        $this->extensions = $extensions;\n        $this->mimes = $mimes;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Access;\n\n/**\n * ArrayAccess Object Trait\n * @package Grav\\Framework\\Object\n */\ntrait ArrayAccessTrait\n{\n    /**\n     * Whether or not an offset exists.\n     *\n     * @param mixed $offset  An offset to check for.\n     * @return bool          Returns TRUE on success or FALSE on failure.\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset)\n    {\n        return $this->hasProperty($offset);\n    }\n\n    /**\n     * Returns the value at specified offset.\n     *\n     * @param mixed $offset  The offset to retrieve.\n     * @return mixed         Can return all value types.\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        return $this->getProperty($offset);\n    }\n\n    /**\n     * Assigns a value to the specified offset.\n     *\n     * @param mixed $offset  The offset to assign the value to.\n     * @param mixed $value   The value to set.\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetSet($offset, $value)\n    {\n        $this->setProperty($offset, $value);\n    }\n\n    /**\n     * Unsets an offset.\n     *\n     * @param mixed $offset  The offset to unset.\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetUnset($offset)\n    {\n        $this->unsetProperty($offset);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Access;\n\n/**\n * Nested ArrayAccess Object Trait\n * @package Grav\\Framework\\Object\n */\ntrait NestedArrayAccessTrait\n{\n    /**\n     * Whether or not an offset exists.\n     *\n     * @param mixed $offset  An offset to check for.\n     * @return bool          Returns TRUE on success or FALSE on failure.\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset)\n    {\n        return $this->hasNestedProperty($offset);\n    }\n\n    /**\n     * Returns the value at specified offset.\n     *\n     * @param mixed $offset  The offset to retrieve.\n     * @return mixed         Can return all value types.\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        return $this->getNestedProperty($offset);\n    }\n\n    /**\n     * Assigns a value to the specified offset.\n     *\n     * @param mixed $offset  The offset to assign the value to.\n     * @param mixed $value   The value to set.\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetSet($offset, $value)\n    {\n        $this->setNestedProperty($offset, $value);\n    }\n\n    /**\n     * Unsets an offset.\n     *\n     * @param mixed $offset  The offset to unset.\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function offsetUnset($offset)\n    {\n        $this->unsetNestedProperty($offset);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Access;\n\nuse Grav\\Framework\\Object\\Interfaces\\NestedObjectInterface;\n\n/**\n * Nested Properties Collection Trait\n * @package Grav\\Framework\\Object\\Properties\n */\ntrait NestedPropertyCollectionTrait\n{\n    /**\n     * @param string $property      Object property to be matched.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return array                Key/Value pairs of the properties.\n     */\n    public function hasNestedProperty($property, $separator = null)\n    {\n        $list = [];\n\n        /** @var NestedObjectInterface $element */\n        foreach ($this->getIterator() as $id => $element) {\n            $list[$id] = $element->hasNestedProperty($property, $separator);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed $default        Default value if not set.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return array                Key/Value pairs of the properties.\n     */\n    public function getNestedProperty($property, $default = null, $separator = null)\n    {\n        $list = [];\n\n        /** @var NestedObjectInterface $element */\n        foreach ($this->getIterator() as $id => $element) {\n            $list[$id] = $element->getNestedProperty($property, $default, $separator);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return $this\n     */\n    public function setNestedProperty($property, $value, $separator = null)\n    {\n        /** @var NestedObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $element->setNestedProperty($property, $value, $separator);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return $this\n     */\n    public function unsetNestedProperty($property, $separator = null)\n    {\n        /** @var NestedObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $element->unsetNestedProperty($property, $separator);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param string $default       Default value.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return $this\n     */\n    public function defNestedProperty($property, $default, $separator = null)\n    {\n        /** @var NestedObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $element->defNestedProperty($property, $default, $separator);\n        }\n\n        return $this;\n    }\n\n    /**\n     * Group items in the collection by a field.\n     *\n     * @param string $property      Object property to be used to make groups.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return array\n     */\n    public function group($property, $separator = null)\n    {\n        $list = [];\n\n        /** @var NestedObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $list[(string) $element->getNestedProperty($property, null, $separator)][] = $element;\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Access;\n\nuse Grav\\Framework\\Object\\Interfaces\\ObjectInterface;\nuse RuntimeException;\nuse stdClass;\nuse function is_array;\nuse function is_object;\n\n/**\n * Nested Property Object Trait\n * @package Grav\\Framework\\Object\\Traits\n */\ntrait NestedPropertyTrait\n{\n    /**\n     * @param string $property      Object property name.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return bool                 True if property has been defined (can be null).\n     */\n    public function hasNestedProperty($property, $separator = null)\n    {\n        $test = new stdClass;\n\n        return $this->getNestedProperty($property, $test, $separator) !== $test;\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed|null $default    Default value if property has not been set.\n     * @param string|null $separator Separator, defaults to '.'\n     * @return mixed                Property value.\n     */\n    public function getNestedProperty($property, $default = null, $separator = null)\n    {\n        $separator = $separator ?: '.';\n        $path = explode($separator, (string) $property);\n        $offset = array_shift($path);\n\n        if (!$this->hasProperty($offset)) {\n            return $default;\n        }\n\n        $current = $this->getProperty($offset);\n\n        while ($path) {\n            // Get property of nested Object.\n            if ($current instanceof ObjectInterface) {\n                if (method_exists($current, 'getNestedProperty')) {\n                    return $current->getNestedProperty(implode($separator, $path), $default, $separator);\n                }\n                return $current->getProperty(implode($separator, $path), $default);\n            }\n\n            $offset = array_shift($path);\n\n            if ((is_array($current) || is_a($current, 'ArrayAccess')) && isset($current[$offset])) {\n                $current = $current[$offset];\n            } elseif (is_object($current) && isset($current->{$offset})) {\n                $current = $current->{$offset};\n            } else {\n                return $default;\n            }\n        };\n\n        return $current;\n    }\n\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function setNestedProperty($property, $value, $separator = null)\n    {\n        $separator = $separator ?: '.';\n        $path = explode($separator, $property);\n        $offset = array_shift($path);\n\n        if (!$path) {\n            $this->setProperty($offset, $value);\n\n            return $this;\n        }\n\n        $current = &$this->doGetProperty($offset, null, true);\n\n        while ($path) {\n            $offset = array_shift($path);\n\n            // Handle arrays and scalars.\n            if ($current === null) {\n                $current = [$offset => []];\n            } elseif (is_array($current)) {\n                if (!isset($current[$offset])) {\n                    $current[$offset] = [];\n                }\n            } else {\n                throw new RuntimeException(\"Cannot set nested property {$property} on non-array value\");\n            }\n\n            $current = &$current[$offset];\n        };\n\n        $current = $value;\n\n        return $this;\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function unsetNestedProperty($property, $separator = null)\n    {\n        $separator = $separator ?: '.';\n        $path = explode($separator, $property);\n        $offset = array_shift($path);\n\n        if (!$path) {\n            $this->unsetProperty($offset);\n\n            return $this;\n        }\n\n        $last = array_pop($path);\n        $current = &$this->doGetProperty($offset, null, true);\n\n        while ($path) {\n            $offset = array_shift($path);\n\n            // Handle arrays and scalars.\n            if ($current === null) {\n                return $this;\n            }\n            if (is_array($current)) {\n                if (!isset($current[$offset])) {\n                    return $this;\n                }\n            } else {\n                throw new RuntimeException(\"Cannot unset nested property {$property} on non-array value\");\n            }\n\n            $current = &$current[$offset];\n        };\n\n        unset($current[$last]);\n\n        return $this;\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $default       Default value.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function defNestedProperty($property, $default, $separator = null)\n    {\n        if (!$this->hasNestedProperty($property, $separator)) {\n            $this->setNestedProperty($property, $default, $separator);\n        }\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Access;\n\n/**\n * Overloaded Property Object Trait\n * @package Grav\\Framework\\Object\\Access\n */\ntrait OverloadedPropertyTrait\n{\n    /**\n     * Checks whether or not an offset exists.\n     *\n     * @param mixed $offset  An offset to check for.\n     * @return bool          Returns TRUE on success or FALSE on failure.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __isset($offset)\n    {\n        return $this->hasProperty($offset);\n    }\n\n    /**\n     * Returns the value at specified offset.\n     *\n     * @param mixed $offset  The offset to retrieve.\n     * @return mixed         Can return all value types.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __get($offset)\n    {\n        return $this->getProperty($offset);\n    }\n\n    /**\n     * Assigns a value to the specified offset.\n     *\n     * @param mixed $offset  The offset to assign the value to.\n     * @param mixed $value   The value to set.\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __set($offset, $value)\n    {\n        $this->setProperty($offset, $value);\n    }\n\n    /**\n     * Magic method to unset the attribute\n     *\n     * @param mixed $offset The name value to unset\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __unset($offset)\n    {\n        $this->unsetProperty($offset);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/ArrayObject.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object;\n\nuse ArrayAccess;\nuse Grav\\Framework\\Object\\Access\\NestedArrayAccessTrait;\nuse Grav\\Framework\\Object\\Access\\NestedPropertyTrait;\nuse Grav\\Framework\\Object\\Access\\OverloadedPropertyTrait;\nuse Grav\\Framework\\Object\\Base\\ObjectTrait;\nuse Grav\\Framework\\Object\\Interfaces\\NestedObjectInterface;\nuse Grav\\Framework\\Object\\Property\\ArrayPropertyTrait;\n\n/**\n * Array Objects keep the data in private array property.\n * @implements ArrayAccess<string,mixed>\n */\nclass ArrayObject implements NestedObjectInterface, ArrayAccess\n{\n    use ObjectTrait;\n    use ArrayPropertyTrait;\n    use NestedPropertyTrait;\n    use OverloadedPropertyTrait;\n    use NestedArrayAccessTrait;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Base/ObjectCollectionTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Base;\n\nuse Grav\\Framework\\Compat\\Serializable;\nuse Grav\\Framework\\Object\\Interfaces\\ObjectInterface;\nuse InvalidArgumentException;\nuse function call_user_func_array;\nuse function get_class;\nuse function is_callable;\nuse function is_object;\n\n/**\n * ObjectCollection Trait\n * @package Grav\\Framework\\Object\n *\n * @template TKey as array-key\n * @template T as ObjectInterface\n */\ntrait ObjectCollectionTrait\n{\n    use Serializable;\n\n    /** @var string */\n    protected static $type;\n\n    /** @var string */\n    private $_key;\n\n    /**\n     * @return string\n     */\n    protected function getTypePrefix()\n    {\n        return '';\n    }\n\n    /**\n     * @param bool $prefix\n     * @return string\n     */\n    public function getType($prefix = true)\n    {\n        $type = $prefix ? $this->getTypePrefix() : '';\n\n        if (static::$type) {\n            return $type . static::$type;\n        }\n\n        $class = get_class($this);\n\n        return $type . strtolower(substr($class, strrpos($class, '\\\\') + 1));\n    }\n\n    /**\n     * @return string\n     */\n    public function getKey()\n    {\n        return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this);\n    }\n\n    /**\n     * @return bool\n     */\n    public function hasKey()\n    {\n        return !empty($this->_key);\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @return bool[]               True if property has been defined (can be null).\n     */\n    public function hasProperty($property)\n    {\n        return $this->doHasProperty($property);\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed $default        Default value if property has not been set.\n     * @return mixed[]              Property values.\n     */\n    public function getProperty($property, $default = null)\n    {\n        return $this->doGetProperty($property, $default);\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @return $this\n     */\n    public function setProperty($property, $value)\n    {\n        $this->doSetProperty($property, $value);\n\n        return $this;\n    }\n\n    /**\n     * @param string  $property     Object property to be unset.\n     * @return $this\n     */\n    public function unsetProperty($property)\n    {\n        $this->doUnsetProperty($property);\n\n        return $this;\n    }\n\n    /**\n     * @param string  $property     Object property to be defined.\n     * @param mixed   $default      Default value.\n     * @return $this\n     */\n    public function defProperty($property, $default)\n    {\n        if (!$this->hasProperty($property)) {\n            $this->setProperty($property, $default);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @return array\n     */\n    final public function __serialize(): array\n    {\n        return $this->doSerialize();\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    final public function __unserialize(array $data): void\n    {\n        if (method_exists($this, 'initObjectProperties')) {\n            $this->initObjectProperties();\n        }\n\n        $this->doUnserialize($data);\n    }\n\n\n    /**\n     * @return array\n     */\n    protected function doSerialize()\n    {\n        return [\n            'key' => $this->getKey(),\n            'type' => $this->getType(),\n            'elements' => $this->getElements()\n        ];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    protected function doUnserialize(array $data)\n    {\n        if (!isset($data['key'], $data['type'], $data['elements']) || $data['type'] !== $this->getType()) {\n            throw new InvalidArgumentException(\"Cannot unserialize '{$this->getType()}': Bad data\");\n        }\n\n        $this->setKey($data['key']);\n        $this->setElements($data['elements']);\n    }\n\n    /**\n     * Implements JsonSerializable interface.\n     *\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->doSerialize();\n    }\n\n    /**\n     * Returns a string representation of this object.\n     *\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return $this->getKey();\n    }\n\n    /**\n     * @param string $key\n     * @return $this\n     */\n    public function setKey($key)\n    {\n        $this->_key = (string) $key;\n\n        return $this;\n    }\n\n    /**\n     * Create a copy from this collection by cloning all objects in the collection.\n     *\n     * @return static<TKey,T>\n     */\n    public function copy()\n    {\n        $list = [];\n        foreach ($this->getIterator() as $key => $value) {\n            /** @phpstan-ignore-next-line */\n            $list[$key] = is_object($value) ? clone $value : $value;\n        }\n\n        /** @phpstan-var static<TKey,T> */\n        return $this->createFrom($list);\n    }\n\n    /**\n     * @return string[]\n     */\n    public function getObjectKeys()\n    {\n        return $this->call('getKey');\n    }\n\n    /**\n     * @param string $property      Object property to be matched.\n     * @return bool[]               Key/Value pairs of the properties.\n     */\n    public function doHasProperty($property)\n    {\n        $list = [];\n\n        /** @var ObjectInterface $element */\n        foreach ($this->getIterator() as $id => $element) {\n            $list[$id] = (bool)$element->hasProperty($property);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed $default        Default value if not set.\n     * @param bool $doCreate        Not being used.\n     * @return mixed[]              Key/Value pairs of the properties.\n     */\n    public function &doGetProperty($property, $default = null, $doCreate = false)\n    {\n        $list = [];\n\n        /** @var ObjectInterface $element */\n        foreach ($this->getIterator() as $id => $element) {\n            $list[$id] = $element->getProperty($property, $default);\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $property  Object property to be updated.\n     * @param mixed  $value     New value.\n     * @return $this\n     */\n    public function doSetProperty($property, $value)\n    {\n        /** @var ObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $element->setProperty($property, $value);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $property  Object property to be updated.\n     * @return $this\n     */\n    public function doUnsetProperty($property)\n    {\n        /** @var ObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $element->unsetProperty($property);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $property  Object property to be updated.\n     * @param mixed  $default   Default value.\n     * @return $this\n     */\n    public function doDefProperty($property, $default)\n    {\n        /** @var ObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $element->defProperty($property, $default);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @param string $method        Method name.\n     * @param array  $arguments     List of arguments passed to the function.\n     * @return mixed[]              Return values.\n     */\n    public function call($method, array $arguments = [])\n    {\n        $list = [];\n\n        /**\n         * @var string|int $id\n         * @var ObjectInterface $element\n         */\n        foreach ($this->getIterator() as $id => $element) {\n            $callable = [$element, $method];\n            $list[$id] = is_callable($callable) ? call_user_func_array($callable, $arguments) : null;\n        }\n\n        return $list;\n    }\n\n    /**\n     * Group items in the collection by a field and return them as associated array.\n     *\n     * @param string $property\n     * @return array\n     * @phpstan-return array<TKey,T>\n     */\n    public function group($property)\n    {\n        $list = [];\n\n        /** @var ObjectInterface $element */\n        foreach ($this->getIterator() as $element) {\n            $list[(string) $element->getProperty($property)][] = $element;\n        }\n\n        return $list;\n    }\n\n    /**\n     * Group items in the collection by a field and return them as associated array of collections.\n     *\n     * @param string $property\n     * @return static[]\n     * @phpstan-return array<static<TKey,T>>\n     */\n    public function collectionGroup($property)\n    {\n        $collections = [];\n        foreach ($this->group($property) as $id => $elements) {\n            /** @phpstan-var static<TKey,T> $collection */\n            $collection = $this->createFrom($elements);\n\n            $collections[$id] = $collection;\n        }\n\n        return $collections;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Base/ObjectTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Base;\n\nuse Grav\\Framework\\Compat\\Serializable;\nuse InvalidArgumentException;\nuse function get_class;\n\n/**\n * Object trait.\n *\n * @package Grav\\Framework\\Object\n */\ntrait ObjectTrait\n{\n    use Serializable;\n\n    /** @var string */\n    protected static $type;\n\n    /** @var string */\n    private $_key;\n\n    /**\n     * @return string\n     */\n    protected function getTypePrefix()\n    {\n        return '';\n    }\n\n    /**\n     * @param bool $prefix\n     * @return string\n     */\n    public function getType($prefix = true)\n    {\n        $type = $prefix ? $this->getTypePrefix() : '';\n\n        if (static::$type) {\n            return $type . static::$type;\n        }\n\n        $class = get_class($this);\n        return $type . strtolower(substr($class, strrpos($class, '\\\\') + 1));\n    }\n\n    /**\n     * @return string\n     */\n    public function getKey()\n    {\n        return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this);\n    }\n\n    /**\n     * @return bool\n     */\n    public function hasKey()\n    {\n        return !empty($this->_key);\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @return bool                 True if property has been defined (can be null).\n     */\n    public function hasProperty($property)\n    {\n        return $this->doHasProperty($property);\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed $default        Default value if property has not been set.\n     * @return mixed                Property value.\n     */\n    public function getProperty($property, $default = null)\n    {\n        return $this->doGetProperty($property, $default);\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @return $this\n     */\n    public function setProperty($property, $value)\n    {\n        $this->doSetProperty($property, $value);\n\n        return $this;\n    }\n\n    /**\n     * @param string  $property     Object property to be unset.\n     * @return $this\n     */\n    public function unsetProperty($property)\n    {\n        $this->doUnsetProperty($property);\n\n        return $this;\n    }\n\n    /**\n     * @param string  $property     Object property to be defined.\n     * @param mixed   $default      Default value.\n     * @return $this\n     */\n    public function defProperty($property, $default)\n    {\n        if (!$this->hasProperty($property)) {\n            $this->setProperty($property, $default);\n        }\n\n        return $this;\n    }\n\n    /**\n     * @return array\n     */\n    final public function __serialize(): array\n    {\n        return $this->doSerialize();\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    final public function __unserialize(array $data): void\n    {\n        if (method_exists($this, 'initObjectProperties')) {\n            $this->initObjectProperties();\n        }\n\n        $this->doUnserialize($data);\n    }\n\n    /**\n     * @return array\n     */\n    protected function doSerialize()\n    {\n        return ['key' => $this->getKey(), 'type' => $this->getType(), 'elements' => $this->getElements()];\n    }\n\n    /**\n     * @param array $serialized\n     * @return void\n     */\n    protected function doUnserialize(array $serialized)\n    {\n        if (!isset($serialized['key'], $serialized['type'], $serialized['elements']) || $serialized['type'] !== $this->getType()) {\n            throw new InvalidArgumentException(\"Cannot unserialize '{$this->getType()}': Bad data\");\n        }\n\n        $this->setKey($serialized['key']);\n        $this->setElements($serialized['elements']);\n    }\n\n    /**\n     * Implements JsonSerializable interface.\n     *\n     * @return array\n     */\n    #[\\ReturnTypeWillChange]\n    public function jsonSerialize()\n    {\n        return $this->doSerialize();\n    }\n\n    /**\n     * Returns a string representation of this object.\n     *\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return $this->getKey();\n    }\n\n    /**\n     * @param string $key\n     * @return $this\n     */\n    protected function setKey($key)\n    {\n        $this->_key = (string) $key;\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Collection/ObjectExpressionVisitor.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Collection;\n\nuse ArrayAccess;\nuse Closure;\nuse Doctrine\\Common\\Collections\\Expr\\ClosureExpressionVisitor;\nuse Doctrine\\Common\\Collections\\Expr\\Comparison;\nuse RuntimeException;\nuse function in_array;\nuse function is_array;\nuse function is_callable;\nuse function is_string;\nuse function strlen;\n\n/**\n * Class ObjectExpressionVisitor\n * @package Grav\\Framework\\Object\\Collection\n */\nclass ObjectExpressionVisitor extends ClosureExpressionVisitor\n{\n    /**\n     * Accesses the field of a given object.\n     *\n     * @param object $object\n     * @param string $field\n     * @return mixed\n     */\n    public static function getObjectFieldValue($object, $field)\n    {\n        $op = $value = null;\n\n        $pos = strpos($field, '(');\n        if (false !== $pos) {\n            [$op, $field] = explode('(', $field, 2);\n            $field = rtrim($field, ')');\n        }\n\n        if ($object instanceof ArrayAccess && isset($object[$field])) {\n            $value = $object[$field];\n        } else {\n            $accessors = array('', 'get', 'is');\n\n            foreach ($accessors as $accessor) {\n                $accessor .= $field;\n\n                if (!is_callable([$object, $accessor])) {\n                    continue;\n                }\n\n                $value = $object->{$accessor}();\n                break;\n            }\n        }\n\n        if ($op) {\n            $function = 'filter' . ucfirst(strtolower($op));\n            if (method_exists(static::class, $function)) {\n                $value = static::$function($value);\n            }\n        }\n\n        return $value;\n    }\n\n    /**\n     * @param string $str\n     * @return string\n     */\n    public static function filterLower($str)\n    {\n        return mb_strtolower($str);\n    }\n\n    /**\n     * @param string $str\n     * @return string\n     */\n    public static function filterUpper($str)\n    {\n        return mb_strtoupper($str);\n    }\n\n    /**\n     * @param string $str\n     * @return int\n     */\n    public static function filterLength($str)\n    {\n        return mb_strlen($str);\n    }\n\n    /**\n     * @param string $str\n     * @return string\n     */\n    public static function filterLtrim($str)\n    {\n        return ltrim($str);\n    }\n\n    /**\n     * @param string $str\n     * @return string\n     */\n    public static function filterRtrim($str)\n    {\n        return rtrim($str);\n    }\n\n    /**\n     * @param string $str\n     * @return string\n     */\n    public static function filterTrim($str)\n    {\n        return trim($str);\n    }\n\n    /**\n     * Helper for sorting arrays of objects based on multiple fields + orientations.\n     *\n     * Comparison between two strings is natural and case insensitive.\n     *\n     * @param string   $name\n     * @param int      $orientation\n     * @param Closure|null $next\n     *\n     * @return Closure\n     */\n    public static function sortByField($name, $orientation = 1, Closure $next = null)\n    {\n        if (!$next) {\n            $next = function ($a, $b) {\n                return 0;\n            };\n        }\n\n        return function ($a, $b) use ($name, $next, $orientation) {\n            $aValue = static::getObjectFieldValue($a, $name);\n            $bValue = static::getObjectFieldValue($b, $name);\n\n            if ($aValue === $bValue) {\n                return $next($a, $b);\n            }\n\n            // For strings we use natural case insensitive sorting.\n            if (is_string($aValue) && is_string($bValue)) {\n                return strnatcasecmp($aValue, $bValue) * $orientation;\n            }\n\n            return (($aValue > $bValue) ? 1 : -1) * $orientation;\n        };\n    }\n\n    /**\n     * {@inheritDoc}\n     */\n    public function walkComparison(Comparison $comparison)\n    {\n        $field = $comparison->getField();\n        $value = $comparison->getValue()->getValue(); // shortcut for walkValue()\n\n        switch ($comparison->getOperator()) {\n            case Comparison::EQ:\n                return function ($object) use ($field, $value) {\n                    return static::getObjectFieldValue($object, $field) === $value;\n                };\n\n            case Comparison::NEQ:\n                return function ($object) use ($field, $value) {\n                    return static::getObjectFieldValue($object, $field) !== $value;\n                };\n\n            case Comparison::LT:\n                return function ($object) use ($field, $value) {\n                    return static::getObjectFieldValue($object, $field) < $value;\n                };\n\n            case Comparison::LTE:\n                return function ($object) use ($field, $value) {\n                    return static::getObjectFieldValue($object, $field) <= $value;\n                };\n\n            case Comparison::GT:\n                return function ($object) use ($field, $value) {\n                    return static::getObjectFieldValue($object, $field) > $value;\n                };\n\n            case Comparison::GTE:\n                return function ($object) use ($field, $value) {\n                    return static::getObjectFieldValue($object, $field) >= $value;\n                };\n\n            case Comparison::IN:\n                return function ($object) use ($field, $value) {\n                    return in_array(static::getObjectFieldValue($object, $field), $value, true);\n                };\n\n            case Comparison::NIN:\n                return function ($object) use ($field, $value) {\n                    return !in_array(static::getObjectFieldValue($object, $field), $value, true);\n                };\n\n            case Comparison::CONTAINS:\n                return function ($object) use ($field, $value) {\n                    return false !== strpos(static::getObjectFieldValue($object, $field), $value);\n                };\n\n            case Comparison::MEMBER_OF:\n                return function ($object) use ($field, $value) {\n                    $fieldValues = static::getObjectFieldValue($object, $field);\n                    if (!is_array($fieldValues)) {\n                        $fieldValues = iterator_to_array($fieldValues);\n                    }\n                    return in_array($value, $fieldValues, true);\n                };\n\n            case Comparison::STARTS_WITH:\n                return function ($object) use ($field, $value) {\n                    return 0 === strpos(static::getObjectFieldValue($object, $field), $value);\n                };\n\n            case Comparison::ENDS_WITH:\n                return function ($object) use ($field, $value) {\n                    return $value === substr(static::getObjectFieldValue($object, $field), -strlen($value));\n                };\n\n            default:\n                throw new RuntimeException('Unknown comparison operator: ' . $comparison->getOperator());\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Identifiers/Identifier.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Object\\Identifiers;\n\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\n\n/**\n * Interface IdentifierInterface\n *\n * @template T of object\n */\nclass Identifier implements IdentifierInterface\n{\n    /** @var string */\n    private $id;\n    /** @var string */\n    private $type;\n\n    /**\n     * IdentifierInterface constructor.\n     * @param string $id\n     * @param string $type\n     */\n    public function __construct(string $id, string $type)\n    {\n        $this->id = $id;\n        $this->type = $type;\n    }\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getId(): string\n    {\n        return $this->id;\n    }\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getType(): string\n    {\n        return $this->type;\n    }\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        return [\n            'type' => $this->type,\n            'id' => $this->id\n        ];\n    }\n\n    /**\n     * @return array\n     */\n    public function __debugInfo(): array\n    {\n        return $this->jsonSerialize();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Interfaces/NestedObjectCollectionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Interfaces;\n\nuse RuntimeException;\n\n/**\n * Common Interface for both Objects and Collections\n * @package Grav\\Framework\\Object\n *\n * @template TKey of array-key\n * @template T\n * @extends ObjectCollectionInterface<TKey,T>\n */\ninterface NestedObjectCollectionInterface extends ObjectCollectionInterface\n{\n    /**\n     * @param  string       $property   Object property name.\n     * @param  string|null  $separator  Separator, defaults to '.'\n     * @return bool[]                   List of [key => bool] pairs.\n     */\n    public function hasNestedProperty($property, $separator = null);\n\n    /**\n     * @param  string       $property   Object property to be fetched.\n     * @param  mixed|null   $default    Default value if property has not been set.\n     * @param  string|null  $separator  Separator, defaults to '.'\n     * @return mixed[]                  List of [key => value] pairs.\n     */\n    public function getNestedProperty($property, $default = null, $separator = null);\n\n    /**\n     * @param  string       $property    Object property to be updated.\n     * @param  mixed        $value       New value.\n     * @param  string|null  $separator   Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function setNestedProperty($property, $value, $separator = null);\n\n    /**\n     * @param  string $property         Object property to be defined.\n     * @param  mixed  $default          Default value.\n     * @param  string|null $separator   Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function defNestedProperty($property, $default, $separator = null);\n\n    /**\n     * @param  string       $property   Object property to be unset.\n     * @param  string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function unsetNestedProperty($property, $separator = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Interfaces;\n\nuse RuntimeException;\n\n/**\n * Common Interface for both Objects and Collections\n * @package Grav\\Framework\\Object\n */\ninterface NestedObjectInterface extends ObjectInterface\n{\n    /**\n     * @param  string       $property   Object property name.\n     * @param  string|null  $separator  Separator, defaults to '.'\n     * @return bool|bool[]              True if property has been defined (can be null).\n     */\n    public function hasNestedProperty($property, $separator = null);\n\n    /**\n     * @param  string       $property   Object property to be fetched.\n     * @param  mixed|null   $default    Default value if property has not been set.\n     * @param  string|null  $separator  Separator, defaults to '.'\n     * @return mixed|mixed[]            Property value.\n     */\n    public function getNestedProperty($property, $default = null, $separator = null);\n\n    /**\n     * @param  string       $property    Object property to be updated.\n     * @param  mixed        $value       New value.\n     * @param  string|null  $separator   Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function setNestedProperty($property, $value, $separator = null);\n\n    /**\n     * @param  string $property         Object property to be defined.\n     * @param  mixed  $default          Default value.\n     * @param  string|null $separator   Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function defNestedProperty($property, $default, $separator = null);\n\n    /**\n     * @param  string       $property   Object property to be unset.\n     * @param  string|null  $separator  Separator, defaults to '.'\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function unsetNestedProperty($property, $separator = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Interfaces/ObjectCollectionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Interfaces;\n\nuse Doctrine\\Common\\Collections\\Selectable;\nuse Grav\\Framework\\Collection\\CollectionInterface;\nuse Serializable;\n\n/**\n * ObjectCollection Interface\n * @package Grav\\Framework\\Collection\n * @template TKey of array-key\n * @template T\n * @extends CollectionInterface<TKey,T>\n * @extends Selectable<TKey,T>\n */\ninterface ObjectCollectionInterface extends CollectionInterface, Selectable, Serializable\n{\n    /**\n     * @return string\n     */\n    public function getType();\n\n    /**\n     * @return string\n     */\n    public function getKey();\n\n    /**\n     * @param string $key\n     * @return $this\n     */\n    public function setKey($key);\n\n    /**\n     * @param  string       $property   Object property name.\n     * @return bool[]                   List of [key => bool] pairs.\n     */\n    public function hasProperty($property);\n\n    /**\n     * @param  string       $property   Object property to be fetched.\n     * @param  mixed|null   $default    Default value if property has not been set.\n     * @return mixed[]                  List of [key => value] pairs.\n     */\n    public function getProperty($property, $default = null);\n\n    /**\n     * @param  string   $property      Object property to be updated.\n     * @param  mixed    $value         New value.\n     * @return $this\n     */\n    public function setProperty($property, $value);\n\n    /**\n     * @param  string  $property        Object property to be defined.\n     * @param  mixed   $default         Default value.\n     * @return $this\n     */\n    public function defProperty($property, $default);\n\n    /**\n     * @param  string  $property     Object property to be unset.\n     * @return $this\n     */\n    public function unsetProperty($property);\n\n    /**\n     * Create a copy from this collection by cloning all objects in the collection.\n     *\n     * @return static\n     * @phpstan-return static<TKey,T>\n     */\n    public function copy();\n\n    /**\n     * @return array\n     */\n    public function getObjectKeys();\n\n    /**\n     * @param string $name          Method name.\n     * @param array  $arguments     List of arguments passed to the function.\n     * @return array                Return values.\n     */\n    public function call($name, array $arguments = []);\n\n    /**\n     * Group items in the collection by a field and return them as associated array.\n     *\n     * @param string $property\n     * @return array\n     */\n    public function group($property);\n\n    /**\n     * Group items in the collection by a field and return them as associated array of collections.\n     *\n     * @param string $property\n     * @return static[]\n     * @phpstan-return array<static<TKey,T>>\n     */\n    public function collectionGroup($property);\n\n    /**\n     * @param array $ordering\n     * @return ObjectCollectionInterface\n     * @phpstan-return static<TKey,T>\n     */\n    public function orderBy(array $ordering);\n\n    /**\n     * @param int $start\n     * @param int|null $limit\n     * @return ObjectCollectionInterface\n     * @phpstan-return static<TKey,T>\n     */\n    public function limit($start, $limit = null);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Interfaces/ObjectInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Interfaces;\n\nuse JsonSerializable;\nuse Serializable;\n\n/**\n * Object Interface\n * @package Grav\\Framework\\Object\n */\ninterface ObjectInterface extends Serializable, JsonSerializable\n{\n    /**\n     * @return string\n     */\n    public function getType();\n\n    /**\n     * @return string\n     */\n    public function getKey();\n\n    /**\n     * @param  string       $property   Object property name.\n     * @return bool                     True if property has been defined (property can be null).\n     */\n    public function hasProperty($property);\n\n    /**\n     * @param  string       $property   Object property to be fetched.\n     * @param  mixed|null   $default    Default value if property has not been set.\n     * @return mixed                    Property value.\n     */\n    public function getProperty($property, $default = null);\n\n    /**\n     * @param  string   $property      Object property to be updated.\n     * @param  mixed    $value         New value.\n     * @return $this\n     */\n    public function setProperty($property, $value);\n\n    /**\n     * @param  string  $property        Object property to be defined.\n     * @param  mixed   $default         Default value.\n     * @return $this\n     */\n    public function defProperty($property, $default);\n\n    /**\n     * @param  string  $property     Object property to be unset.\n     * @return $this\n     */\n    public function unsetProperty($property);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/LazyObject.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object;\n\nuse ArrayAccess;\nuse Grav\\Framework\\Object\\Access\\NestedArrayAccessTrait;\nuse Grav\\Framework\\Object\\Access\\NestedPropertyTrait;\nuse Grav\\Framework\\Object\\Access\\OverloadedPropertyTrait;\nuse Grav\\Framework\\Object\\Base\\ObjectTrait;\nuse Grav\\Framework\\Object\\Interfaces\\NestedObjectInterface;\nuse Grav\\Framework\\Object\\Property\\LazyPropertyTrait;\n\n/**\n * Lazy Objects keep their data in both protected object properties and falls back to a stored array if property does\n * not exist or is not initialized.\n *\n * @package Grav\\Framework\\Object\n * @implements ArrayAccess<string,mixed>\n */\nclass LazyObject implements NestedObjectInterface, ArrayAccess\n{\n    use ObjectTrait;\n    use LazyPropertyTrait;\n    use NestedPropertyTrait;\n    use OverloadedPropertyTrait;\n    use NestedArrayAccessTrait;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/ObjectCollection.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object;\n\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Grav\\Framework\\Collection\\ArrayCollection;\nuse Grav\\Framework\\Object\\Access\\NestedPropertyCollectionTrait;\nuse Grav\\Framework\\Object\\Base\\ObjectCollectionTrait;\nuse Grav\\Framework\\Object\\Collection\\ObjectExpressionVisitor;\nuse Grav\\Framework\\Object\\Interfaces\\NestedObjectCollectionInterface;\nuse InvalidArgumentException;\nuse function array_slice;\n\n/**\n * Class contains a collection of objects.\n *\n * @template TKey of array-key\n * @template T of \\Grav\\Framework\\Object\\Interfaces\\ObjectInterface\n * @extends ArrayCollection<TKey,T>\n * @implements NestedObjectCollectionInterface<TKey,T>\n */\nclass ObjectCollection extends ArrayCollection implements NestedObjectCollectionInterface\n{\n    /** @phpstan-use ObjectCollectionTrait<TKey,T> */\n    use ObjectCollectionTrait;\n    use NestedPropertyCollectionTrait {\n        NestedPropertyCollectionTrait::group insteadof ObjectCollectionTrait;\n    }\n\n    /**\n     * @param array $elements\n     * @param string|null $key\n     * @throws InvalidArgumentException\n     */\n    public function __construct(array $elements = [], $key = null)\n    {\n        parent::__construct($this->setElements($elements));\n\n        $this->setKey($key ?? '');\n    }\n\n    /**\n     * @param array $ordering\n     * @return static\n     * @phpstan-return static<TKey,T>\n     */\n    public function orderBy(array $ordering)\n    {\n        $criteria = Criteria::create()->orderBy($ordering);\n\n        return $this->matching($criteria);\n    }\n\n    /**\n     * @param int $start\n     * @param int|null $limit\n     * @return static\n     * @phpstan-return static<TKey,T>\n     */\n    public function limit($start, $limit = null)\n    {\n        /** @phpstan-var static<TKey,T> */\n        return $this->createFrom($this->slice($start, $limit));\n    }\n\n    /**\n     * @param Criteria $criteria\n     * @return static\n     * @phpstan-return static<TKey,T>\n     */\n    public function matching(Criteria $criteria)\n    {\n        $expr     = $criteria->getWhereExpression();\n        $filtered = $this->getElements();\n\n        if ($expr) {\n            $visitor  = new ObjectExpressionVisitor();\n            $filter   = $visitor->dispatch($expr);\n            $filtered = array_filter($filtered, $filter);\n        }\n\n        if ($orderings = $criteria->getOrderings()) {\n            $next = null;\n            foreach (array_reverse($orderings) as $field => $ordering) {\n                $next = ObjectExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next);\n            }\n\n            /** @phpstan-ignore-next-line */\n            if ($next) {\n                uasort($filtered, $next);\n            }\n        }\n\n        $offset = $criteria->getFirstResult();\n        $length = $criteria->getMaxResults();\n\n        if ($offset || $length) {\n            $filtered = array_slice($filtered, (int)$offset, $length);\n        }\n\n        /** @phpstan-var static<TKey,T> */\n        return $this->createFrom($filtered);\n    }\n\n    /**\n     * @return array\n     * @phpstan-return array<TKey,T>\n     */\n    protected function getElements()\n    {\n        return $this->toArray();\n    }\n\n    /**\n     * @param array $elements\n     * @return array\n     * @phpstan-return array<TKey,T>\n     */\n    protected function setElements(array $elements)\n    {\n        /** @phpstan-var array<TKey,T> $elements */\n        return $elements;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/ObjectIndex.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object;\n\nuse Doctrine\\Common\\Collections\\Criteria;\nuse Grav\\Framework\\Collection\\AbstractIndexCollection;\nuse Grav\\Framework\\Object\\Interfaces\\NestedObjectCollectionInterface;\nuse Grav\\Framework\\Object\\Interfaces\\ObjectCollectionInterface;\nuse function get_class;\nuse function is_object;\n\n/**\n * Keeps index of objects instead of collection of objects. This class allows you to keep a list of objects and load\n * them on demand. The class can be used seemingly instead of ObjectCollection when the objects haven't been loaded yet.\n *\n * This is an abstract class and has some protected abstract methods to load objects which you need to implement in\n * order to use the class.\n *\n * @template TKey of array-key\n * @template T of \\Grav\\Framework\\Object\\Interfaces\\ObjectInterface\n * @template C of ObjectCollectionInterface\n * @extends AbstractIndexCollection<TKey,T,C>\n * @implements NestedObjectCollectionInterface<TKey,T>\n */\nabstract class ObjectIndex extends AbstractIndexCollection implements NestedObjectCollectionInterface\n{\n    /** @var string */\n    protected static $type;\n\n    /** @var string */\n    protected $_key;\n\n    /**\n     * @param bool $prefix\n     * @return string\n     */\n    public function getType($prefix = true)\n    {\n        $type = $prefix ? $this->getTypePrefix() : '';\n\n        if (static::$type) {\n            return $type . static::$type;\n        }\n\n        $class = get_class($this);\n        return $type . strtolower(substr($class, strrpos($class, '\\\\') + 1));\n    }\n\n    /**\n     * @return string\n     */\n    public function getKey()\n    {\n        return $this->_key ?: $this->getType() . '@@' . spl_object_hash($this);\n    }\n\n    /**\n     * @param string $key\n     * @return $this\n     */\n    public function setKey($key)\n    {\n        $this->_key = $key;\n\n        return $this;\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @return bool[]               True if property has been defined (can be null).\n     */\n    public function hasProperty($property)\n    {\n        return $this->__call('hasProperty', [$property]);\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed $default        Default value if property has not been set.\n     * @return mixed[]             Property values.\n     */\n    public function getProperty($property, $default = null)\n    {\n        return $this->__call('getProperty', [$property, $default]);\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param string $value         New value.\n     * @return ObjectCollectionInterface\n     * @phpstan-return C\n     */\n    public function setProperty($property, $value)\n    {\n        return $this->__call('setProperty', [$property, $value]);\n    }\n\n    /**\n     * @param string  $property     Object property to be defined.\n     * @param mixed   $default      Default value.\n     * @return ObjectCollectionInterface\n     * @phpstan-return C\n     */\n    public function defProperty($property, $default)\n    {\n        return $this->__call('defProperty', [$property, $default]);\n    }\n\n    /**\n     * @param string  $property     Object property to be unset.\n     * @return ObjectCollectionInterface\n     * @phpstan-return C\n     */\n    public function unsetProperty($property)\n    {\n        return $this->__call('unsetProperty', [$property]);\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return bool[]               True if property has been defined (can be null).\n     */\n    public function hasNestedProperty($property, $separator = null)\n    {\n        return $this->__call('hasNestedProperty', [$property, $separator]);\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed  $default       Default value if property has not been set.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return mixed[]              Property values.\n     */\n    public function getNestedProperty($property, $default = null, $separator = null)\n    {\n        return $this->__call('getNestedProperty', [$property, $default, $separator]);\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @param string|null $separator     Separator, defaults to '.'\n     * @return ObjectCollectionInterface\n     * @phpstan-return C\n     */\n    public function setNestedProperty($property, $value, $separator = null)\n    {\n        return $this->__call('setNestedProperty', [$property, $value, $separator]);\n    }\n\n    /**\n     * @param string  $property     Object property to be defined.\n     * @param mixed   $default      Default value.\n     * @param string|null  $separator    Separator, defaults to '.'\n     * @return ObjectCollectionInterface\n     * @phpstan-return C\n     */\n    public function defNestedProperty($property, $default, $separator = null)\n    {\n        return $this->__call('defNestedProperty', [$property, $default, $separator]);\n    }\n\n    /**\n     * @param string  $property     Object property to be unset.\n     * @param string|null  $separator    Separator, defaults to '.'\n     * @return ObjectCollectionInterface\n     * @phpstan-return C\n     */\n    public function unsetNestedProperty($property, $separator = null)\n    {\n        return $this->__call('unsetNestedProperty', [$property, $separator]);\n    }\n\n    /**\n     * Create a copy from this collection by cloning all objects in the collection.\n     *\n     * @return static\n     * @return static<TKey,T,C>\n     */\n    public function copy()\n    {\n        $list = [];\n        foreach ($this->getIterator() as $key => $value) {\n            /** @phpstan-ignore-next-line */\n            $list[$key] = is_object($value) ? clone $value : $value;\n        }\n\n        /** @phpstan-var static<TKey,T,C> */\n        return $this->createFrom($list);\n    }\n\n    /**\n     * @return array\n     */\n    public function getObjectKeys()\n    {\n        return $this->getKeys();\n    }\n\n    /**\n     * @param array $ordering\n     * @return ObjectCollectionInterface\n     * @phpstan-return C\n     */\n    public function orderBy(array $ordering)\n    {\n        return $this->__call('orderBy', [$ordering]);\n    }\n\n    /**\n     * @param string $method\n     * @param array $arguments\n     * @return array|mixed\n     */\n    public function call($method, array $arguments = [])\n    {\n        return $this->__call('call', [$method, $arguments]);\n    }\n\n    /**\n     * Group items in the collection by a field and return them as associated array.\n     *\n     * @param string $property\n     * @return array\n     */\n    public function group($property)\n    {\n        return $this->__call('group', [$property]);\n    }\n\n    /**\n     * Group items in the collection by a field and return them as associated array of collections.\n     *\n     * @param string $property\n     * @return ObjectCollectionInterface[]\n     * @phpstan-return C[]\n     */\n    public function collectionGroup($property)\n    {\n        return $this->__call('collectionGroup', [$property]);\n    }\n\n    /**\n     * @param Criteria $criteria\n     * @return ObjectCollectionInterface\n     * @phpstan-return C\n     */\n    public function matching(Criteria $criteria)\n    {\n        $collection = $this->loadCollection($this->getEntries());\n\n        /** @phpstan-var C $matching */\n        $matching = $collection->matching($criteria);\n\n        return $matching;\n    }\n\n    /**\n     * @param string $name\n     * @param array $arguments\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    abstract public function __call($name, $arguments);\n\n    /**\n     * @return string\n     */\n    protected function getTypePrefix()\n    {\n        return '';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Property;\n\nuse InvalidArgumentException;\nuse function array_key_exists;\n\n/**\n * Array Property Trait\n *\n * Stores all object properties into an array.\n *\n * @package Grav\\Framework\\Object\\Property\n */\ntrait ArrayPropertyTrait\n{\n    /** @var array Properties of the object. */\n    private $_elements;\n\n    /**\n     * @param array $elements\n     * @param string|null $key\n     * @throws InvalidArgumentException\n     */\n    public function __construct(array $elements = [], $key = null)\n    {\n        $this->setElements($elements);\n        $this->setKey($key ?? '');\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @return bool                 True if property has been defined (can be null).\n     */\n    protected function doHasProperty($property)\n    {\n        return array_key_exists($property, $this->_elements);\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed $default        Default value if property has not been set.\n     * @param bool $doCreate        Set true to create variable.\n     * @return mixed                Property value.\n     */\n    protected function &doGetProperty($property, $default = null, $doCreate = false)\n    {\n        if (!array_key_exists($property, $this->_elements)) {\n            if ($doCreate) {\n                $this->_elements[$property] = null;\n            } else {\n                return $default;\n            }\n        }\n\n        return $this->_elements[$property];\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @return void\n     */\n    protected function doSetProperty($property, $value)\n    {\n        $this->_elements[$property] = $value;\n    }\n\n    /**\n     * @param string  $property     Object property to be unset.\n     * @return void\n     */\n    protected function doUnsetProperty($property)\n    {\n        unset($this->_elements[$property]);\n    }\n\n    /**\n     * @param string $property\n     * @param mixed|null $default\n     * @return mixed|null\n     */\n    protected function getElement($property, $default = null)\n    {\n        return array_key_exists($property, $this->_elements) ? $this->_elements[$property] : $default;\n    }\n\n    /**\n     * @return array\n     */\n    protected function getElements()\n    {\n        return array_filter($this->_elements, static function ($val) {\n            return $val !== null;\n        });\n    }\n\n    /**\n     * @param array $elements\n     * @return void\n     */\n    protected function setElements(array $elements)\n    {\n        $this->_elements = $elements;\n    }\n\n    abstract protected function setKey($key);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Property;\n\n/**\n * Lazy Mixed Property Trait\n *\n * Stores defined object properties as class member variables and the rest into an array. Object properties are lazy\n * loaded from the array.\n *\n * You may define following methods for the member variables:\n * - `$this->offsetLoad($offset, $value)` called first time object property gets accessed\n * - `$this->offsetPrepare($offset, $value)` called on every object property set\n * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed\n *\n * @package Grav\\Framework\\Object\\Property\n */\ntrait LazyPropertyTrait\n{\n    use ArrayPropertyTrait, ObjectPropertyTrait {\n        ObjectPropertyTrait::__construct insteadof ArrayPropertyTrait;\n        ArrayPropertyTrait::doHasProperty as hasArrayProperty;\n        ArrayPropertyTrait::doGetProperty as getArrayProperty;\n        ArrayPropertyTrait::doSetProperty as setArrayProperty;\n        ArrayPropertyTrait::doUnsetProperty as unsetArrayProperty;\n        ArrayPropertyTrait::getElement as getArrayElement;\n        ArrayPropertyTrait::getElements as getArrayElements;\n        ArrayPropertyTrait::setElements insteadof ObjectPropertyTrait;\n        ObjectPropertyTrait::doHasProperty as hasObjectProperty;\n        ObjectPropertyTrait::doGetProperty as getObjectProperty;\n        ObjectPropertyTrait::doSetProperty as setObjectProperty;\n        ObjectPropertyTrait::doUnsetProperty as unsetObjectProperty;\n        ObjectPropertyTrait::getElement as getObjectElement;\n        ObjectPropertyTrait::getElements as getObjectElements;\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @return bool                 True if property has been defined (can be null).\n     */\n    protected function doHasProperty($property)\n    {\n        return $this->hasArrayProperty($property) || $this->hasObjectProperty($property);\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed $default        Default value if property has not been set.\n     * @param bool $doCreate\n     * @return mixed                Property value.\n     */\n    protected function &doGetProperty($property, $default = null, $doCreate = false)\n    {\n        if ($this->hasObjectProperty($property)) {\n            return $this->getObjectProperty($property, $default, function ($default = null) use ($property) {\n                return $this->getArrayProperty($property, $default);\n            });\n        }\n\n        return $this->getArrayProperty($property, $default, $doCreate);\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @return void\n     */\n    protected function doSetProperty($property, $value)\n    {\n        if ($this->hasObjectProperty($property)) {\n            $this->setObjectProperty($property, $value);\n        } else {\n            $this->setArrayProperty($property, $value);\n        }\n    }\n\n    /**\n     * @param string  $property     Object property to be unset.\n     * @return void\n     */\n    protected function doUnsetProperty($property)\n    {\n        $this->hasObjectProperty($property) ? $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property);\n    }\n\n    /**\n     * @param string $property\n     * @param mixed|null $default\n     * @return mixed|null\n     */\n    protected function getElement($property, $default = null)\n    {\n        if ($this->isPropertyLoaded($property)) {\n            return $this->getObjectElement($property, $default);\n        }\n\n        return $this->getArrayElement($property, $default);\n    }\n\n    /**\n     * @return array\n     */\n    protected function getElements()\n    {\n        return $this->getObjectElements() + $this->getArrayElements();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Property;\n\n/**\n * Mixed Property Trait\n *\n * Stores defined object properties as class member variables and the rest into an array.\n *\n * You may define following methods for member variables:\n * - `$this->offsetLoad($offset, $value)` called first time object property gets accessed\n * - `$this->offsetPrepare($offset, $value)` called on every object property set\n * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed\n\n *\n * @package Grav\\Framework\\Object\\Property\n */\ntrait MixedPropertyTrait\n{\n    use ArrayPropertyTrait, ObjectPropertyTrait {\n        ObjectPropertyTrait::__construct insteadof ArrayPropertyTrait;\n        ArrayPropertyTrait::doHasProperty as hasArrayProperty;\n        ArrayPropertyTrait::doGetProperty as getArrayProperty;\n        ArrayPropertyTrait::doSetProperty as setArrayProperty;\n        ArrayPropertyTrait::doUnsetProperty as unsetArrayProperty;\n        ArrayPropertyTrait::getElement as getArrayElement;\n        ArrayPropertyTrait::getElements as getArrayElements;\n        ArrayPropertyTrait::setElements as setArrayElements;\n        ObjectPropertyTrait::doHasProperty as hasObjectProperty;\n        ObjectPropertyTrait::doGetProperty as getObjectProperty;\n        ObjectPropertyTrait::doSetProperty as setObjectProperty;\n        ObjectPropertyTrait::doUnsetProperty as unsetObjectProperty;\n        ObjectPropertyTrait::getElement as getObjectElement;\n        ObjectPropertyTrait::getElements as getObjectElements;\n        ObjectPropertyTrait::setElements as setObjectElements;\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @return bool                 True if property has been defined (can be null).\n     */\n    protected function doHasProperty($property)\n    {\n        return $this->hasArrayProperty($property) || $this->hasObjectProperty($property);\n    }\n\n    /**\n     * @param string $property      Object property to be fetched.\n     * @param mixed $default        Default value if property has not been set.\n     * @param bool $doCreate\n     * @return mixed                Property value.\n     */\n    protected function &doGetProperty($property, $default = null, $doCreate = false)\n    {\n        if ($this->hasObjectProperty($property)) {\n            return $this->getObjectProperty($property);\n        }\n\n        return $this->getArrayProperty($property, $default, $doCreate);\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @return void\n     */\n    protected function doSetProperty($property, $value)\n    {\n        $this->hasObjectProperty($property)\n            ? $this->setObjectProperty($property, $value) : $this->setArrayProperty($property, $value);\n    }\n\n    /**\n     * @param string  $property     Object property to be unset.\n     * @return void\n     */\n    protected function doUnsetProperty($property)\n    {\n        $this->hasObjectProperty($property) ?\n            $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property);\n    }\n\n    /**\n     * @param string $property\n     * @param mixed|null $default\n     * @return mixed|null\n     */\n    protected function getElement($property, $default = null)\n    {\n        if ($this->hasObjectProperty($property)) {\n            return $this->getObjectElement($property, $default);\n        }\n\n        return $this->getArrayElement($property, $default);\n    }\n\n    /**\n     * @return array\n     */\n    protected function getElements()\n    {\n        return $this->getObjectElements() + $this->getArrayElements();\n    }\n\n    /**\n     * @param array $elements\n     * @return void\n     */\n    protected function setElements(array $elements)\n    {\n        $this->setObjectElements(array_intersect_key($elements, $this->_definedProperties));\n        $this->setArrayElements(array_diff_key($elements, $this->_definedProperties));\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object\\Property;\n\nuse InvalidArgumentException;\nuse function array_key_exists;\nuse function get_object_vars;\nuse function is_callable;\n\n/**\n * Object Property Trait\n *\n * Stores all properties as class member variables or object properties. All properties need to be defined as protected\n * properties. Undefined properties will throw an error.\n *\n * Additionally you may define following methods:\n * - `$this->offsetLoad($offset, $value)` called first time object property gets accessed\n * - `$this->offsetPrepare($offset, $value)` called on every object property set\n * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed\n *\n * @package Grav\\Framework\\Object\\Property\n */\ntrait ObjectPropertyTrait\n{\n    /** @var array */\n    private $_definedProperties;\n\n    /**\n     * @param array $elements\n     * @param string|null $key\n     * @throws InvalidArgumentException\n     */\n    public function __construct(array $elements = [], $key = null)\n    {\n        $this->initObjectProperties();\n        $this->setElements($elements);\n        $this->setKey($key ?? '');\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @return bool                 True if property has been loaded.\n     */\n    protected function isPropertyLoaded($property)\n    {\n        return !empty($this->_definedProperties[$property]);\n    }\n\n    /**\n     * @param string $offset\n     * @param mixed $value\n     * @return mixed\n     */\n    protected function offsetLoad($offset, $value)\n    {\n        $methodName = \"offsetLoad_{$offset}\";\n\n        return method_exists($this, $methodName)? $this->{$methodName}($value) : $value;\n    }\n\n    /**\n     * @param string $offset\n     * @param mixed $value\n     * @return mixed\n     */\n    protected function offsetPrepare($offset, $value)\n    {\n        $methodName = \"offsetPrepare_{$offset}\";\n\n        return method_exists($this, $methodName) ? $this->{$methodName}($value) : $value;\n    }\n\n    /**\n     * @param string $offset\n     * @param mixed $value\n     * @return mixed\n     */\n    protected function offsetSerialize($offset, $value)\n    {\n        $methodName = \"offsetSerialize_{$offset}\";\n\n        return method_exists($this, $methodName) ? $this->{$methodName}($value) : $value;\n    }\n\n    /**\n     * @param string $property      Object property name.\n     * @return bool                 True if property has been defined (can be null).\n     */\n    protected function doHasProperty($property)\n    {\n        return array_key_exists($property, $this->_definedProperties);\n    }\n\n    /**\n     * @param string $property          Object property to be fetched.\n     * @param mixed $default            Default value if property has not been set.\n     * @param callable|bool $doCreate   Set true to create variable.\n     * @return mixed                    Property value.\n     */\n    protected function &doGetProperty($property, $default = null, $doCreate = false)\n    {\n        if (!array_key_exists($property, $this->_definedProperties)) {\n            throw new InvalidArgumentException(\"Property '{$property}' does not exist in the object!\");\n        }\n\n        if (empty($this->_definedProperties[$property])) {\n            if ($doCreate === true) {\n                $this->_definedProperties[$property] = true;\n                $this->{$property} = null;\n            } elseif (is_callable($doCreate)) {\n                $this->_definedProperties[$property] = true;\n                $this->{$property} = $this->offsetLoad($property, $doCreate());\n            } else {\n                return $default;\n            }\n        }\n\n        return $this->{$property};\n    }\n\n    /**\n     * @param string $property      Object property to be updated.\n     * @param mixed  $value         New value.\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    protected function doSetProperty($property, $value)\n    {\n        if (!array_key_exists($property, $this->_definedProperties)) {\n            throw new InvalidArgumentException(\"Property '{$property}' does not exist in the object!\");\n        }\n\n        $this->_definedProperties[$property] = true;\n        $this->{$property} = $this->offsetPrepare($property, $value);\n    }\n\n    /**\n     * @param string  $property     Object property to be unset.\n     * @return void\n     */\n    protected function doUnsetProperty($property)\n    {\n        if (!array_key_exists($property, $this->_definedProperties)) {\n            return;\n        }\n\n        $this->_definedProperties[$property] = false;\n        $this->{$property} = null;\n    }\n\n    /**\n     * @return void\n     */\n    protected function initObjectProperties()\n    {\n        $this->_definedProperties = [];\n        foreach (get_object_vars($this) as $property => $value) {\n            if ($property[0] !== '_') {\n                $this->_definedProperties[$property] = ($value !== null);\n            }\n        }\n    }\n\n    /**\n     * @param string $property\n     * @param mixed|null $default\n     * @return mixed|null\n     */\n    protected function getElement($property, $default = null)\n    {\n        if (empty($this->_definedProperties[$property])) {\n            return $default;\n        }\n\n        return $this->offsetSerialize($property, $this->{$property});\n    }\n\n    /**\n     * @return array\n     */\n    protected function getElements()\n    {\n        $properties = array_intersect_key(get_object_vars($this), array_filter($this->_definedProperties));\n\n        $elements = [];\n        foreach ($properties as $offset => $value) {\n            $serialized = $this->offsetSerialize($offset, $value);\n            if ($serialized !== null) {\n                $elements[$offset] = $this->offsetSerialize($offset, $value);\n            }\n        }\n\n        return $elements;\n    }\n\n    /**\n     * @param array $elements\n     * @return void\n     */\n    protected function setElements(array $elements)\n    {\n        foreach ($elements as $property => $value) {\n            $this->setProperty($property, $value);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Object/PropertyObject.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Object\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Object;\n\nuse ArrayAccess;\nuse Grav\\Framework\\Object\\Access\\NestedArrayAccessTrait;\nuse Grav\\Framework\\Object\\Access\\NestedPropertyTrait;\nuse Grav\\Framework\\Object\\Access\\OverloadedPropertyTrait;\nuse Grav\\Framework\\Object\\Base\\ObjectTrait;\nuse Grav\\Framework\\Object\\Interfaces\\NestedObjectInterface;\nuse Grav\\Framework\\Object\\Property\\ObjectPropertyTrait;\n\n/**\n * Property Objects keep their data in protected object properties.\n *\n * @implements ArrayAccess<string,mixed>\n */\nclass PropertyObject implements NestedObjectInterface, ArrayAccess\n{\n    use ObjectTrait;\n    use ObjectPropertyTrait;\n    use NestedPropertyTrait;\n    use OverloadedPropertyTrait;\n    use NestedArrayAccessTrait;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Pagination/AbstractPagination.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Pagination\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Pagination;\n\nuse ArrayIterator;\nuse Grav\\Framework\\Pagination\\Interfaces\\PaginationInterface;\nuse Grav\\Framework\\Route\\Route;\nuse function count;\n\n/**\n * Class AbstractPagination\n * @package Grav\\Framework\\Pagination\n */\nclass AbstractPagination implements PaginationInterface\n{\n    /** @var Route Base rouse used for the pagination. */\n    protected $route;\n    /** @var int|null  Current page. */\n    protected $page;\n    /** @var int|null  The record number to start displaying from. */\n    protected $start;\n    /** @var int  Number of records to display per page. */\n    protected $limit;\n    /** @var int  Total number of records. */\n    protected $total;\n    /** @var array Pagination options */\n    protected $options;\n    /** @var bool View all flag. */\n    protected $viewAll;\n    /** @var int  Total number of pages. */\n    protected $pages;\n    /** @var int  Value pagination object begins at. */\n    protected $pagesStart;\n    /** @var int  Value pagination object ends at .*/\n    protected $pagesStop;\n    /** @var array */\n    protected $defaultOptions = [\n        'type' => 'page',\n        'limit' => 10,\n        'display' => 5,\n        'opening' => 0,\n        'ending' => 0,\n        'url' => null,\n        'param' => null,\n        'use_query_param' => false\n    ];\n    /** @var array */\n    private $items;\n\n    /**\n     * @return bool\n     */\n    public function isEnabled(): bool\n    {\n        return $this->count() > 1;\n    }\n\n    /**\n     * @return array\n     */\n    public function getOptions(): array\n    {\n        return $this->options;\n    }\n\n    /**\n     * @return Route|null\n     */\n    public function getRoute(): ?Route\n    {\n        return $this->route;\n    }\n\n    /**\n     * @return int\n     */\n    public function getTotalPages(): int\n    {\n        return $this->pages;\n    }\n\n    /**\n     * @return int\n     */\n    public function getPageNumber(): int\n    {\n        return $this->page ?? 1;\n    }\n\n    /**\n     * @param int $count\n     * @return int|null\n     */\n    public function getPrevNumber(int $count = 1): ?int\n    {\n        $page = $this->page - $count;\n\n        return $page >= 1 ? $page : null;\n    }\n\n    /**\n     * @param int $count\n     * @return int|null\n     */\n    public function getNextNumber(int $count = 1): ?int\n    {\n        $page = $this->page + $count;\n\n        return $page <= $this->pages ? $page : null;\n    }\n\n    /**\n     * @param int $page\n     * @param string|null $label\n     * @return PaginationPage|null\n     */\n    public function getPage(int $page, string $label = null): ?PaginationPage\n    {\n        if ($page < 1 || $page > $this->pages) {\n            return null;\n        }\n\n        $start = ($page - 1) * $this->limit;\n        $type = $this->getOptions()['type'];\n        $param = $this->getOptions()['param'];\n        $useQuery = $this->getOptions()['use_query_param'];\n        if ($type === 'page') {\n            $param = $param ?? 'page';\n            $offset = $page;\n        } else {\n            $param = $param ?? 'start';\n            $offset = $start;\n        }\n\n        if ($useQuery) {\n            $route = $this->route->withQueryParam($param, $offset);\n        } else {\n            $route = $this->route->withGravParam($param, $offset);\n        }\n\n        return new PaginationPage(\n            [\n                'label' => $label ?? (string)$page,\n                'number' => $page,\n                'offset_start' => $start,\n                'offset_end' => min($start + $this->limit, $this->total) - 1,\n                'enabled' => $page !== $this->page || $this->viewAll,\n                'active' => $page === $this->page,\n                'route' => $route\n            ]\n        );\n    }\n\n    /**\n     * @param string|null $label\n     * @param int $count\n     * @return PaginationPage|null\n     */\n    public function getFirstPage(string $label = null, int $count = 0): ?PaginationPage\n    {\n        return $this->getPage(1 + $count, $label ?? $this->getOptions()['label_first'] ?? null);\n    }\n\n    /**\n     * @param string|null $label\n     * @param int $count\n     * @return PaginationPage|null\n     */\n    public function getPrevPage(string $label = null, int $count = 1): ?PaginationPage\n    {\n        return $this->getPage($this->page - $count, $label ?? $this->getOptions()['label_prev'] ?? null);\n    }\n\n    /**\n     * @param string|null $label\n     * @param int $count\n     * @return PaginationPage|null\n     */\n    public function getNextPage(string $label = null, int $count = 1): ?PaginationPage\n    {\n        return $this->getPage($this->page + $count, $label ?? $this->getOptions()['label_next'] ?? null);\n    }\n\n    /**\n     * @param string|null $label\n     * @param int $count\n     * @return PaginationPage|null\n     */\n    public function getLastPage(string $label = null, int $count = 0): ?PaginationPage\n    {\n        return $this->getPage($this->pages - $count, $label ?? $this->getOptions()['label_last'] ?? null);\n    }\n\n    /**\n     * @return int\n     */\n    public function getStart(): int\n    {\n        return $this->start ?? 0;\n    }\n\n    /**\n     * @return int\n     */\n    public function getLimit(): int\n    {\n        return $this->limit;\n    }\n\n    /**\n     * @return int\n     */\n    public function getTotal(): int\n    {\n        return $this->total;\n    }\n\n    /**\n     * @return int\n     */\n    public function count(): int\n    {\n        $this->loadItems();\n\n        return count($this->items);\n    }\n\n    /**\n     * @return ArrayIterator\n     * @phpstan-return ArrayIterator<int,PaginationPage>\n     */\n    #[\\ReturnTypeWillChange]\n    public function getIterator()\n    {\n        $this->loadItems();\n\n        return new ArrayIterator($this->items);\n    }\n\n    /**\n     * @return array\n     */\n    public function getPages(): array\n    {\n        $this->loadItems();\n\n        return $this->items;\n    }\n\n    /**\n     * @return void\n     */\n    protected function loadItems()\n    {\n        $this->calculateRange();\n\n        // Make list like: 1 ... 4 5 6 ... 10\n        $range = range($this->pagesStart, $this->pagesStop);\n        //$range[] = 1;\n        //$range[] = $this->pages;\n        natsort($range);\n        $range = array_unique($range);\n\n        $this->items = [];\n        foreach ($range as $i) {\n            $this->items[$i] = $this->getPage($i);\n        }\n    }\n\n    /**\n     * @param Route $route\n     * @return $this\n     */\n    protected function setRoute(Route $route)\n    {\n        $this->route = $route;\n\n        return $this;\n    }\n\n    /**\n     * @param array|null $options\n     * @return $this\n     */\n    protected function setOptions(array $options = null)\n    {\n        $this->options = $options ? array_merge($this->defaultOptions, $options) : $this->defaultOptions;\n\n        return $this;\n    }\n\n    /**\n     * @param int|null $page\n     * @return $this\n     */\n    protected function setPage(int $page = null)\n    {\n        $this->page = (int)max($page, 1);\n        $this->start = null;\n\n        return $this;\n    }\n\n    /**\n     * @param int|null $start\n     * @return $this\n     */\n    protected function setStart(int $start = null)\n    {\n        $this->start = (int)max($start, 0);\n        $this->page = null;\n\n        return $this;\n    }\n\n    /**\n     * @param int|null $limit\n     * @return $this\n     */\n    protected function setLimit(int $limit = null)\n    {\n        $this->limit = (int)max($limit ?? $this->getOptions()['limit'], 0);\n\n        // No limit, display all records in a single page.\n        $this->viewAll = !$limit;\n\n        return $this;\n    }\n\n    /**\n     * @param int $total\n     * @return $this\n     */\n    protected function setTotal(int $total)\n    {\n        $this->total = (int)max($total, 0);\n\n        return $this;\n    }\n\n    /**\n     * @param Route $route\n     * @param int $total\n     * @param int|null $pos\n     * @param int|null $limit\n     * @param array|null $options\n     * @return void\n     */\n    protected function initialize(Route $route, int $total, int $pos = null, int $limit = null, array $options = null)\n    {\n        $this->setRoute($route);\n        $this->setOptions($options);\n        $this->setTotal($total);\n        if ($this->getOptions()['type'] === 'start') {\n            $this->setStart($pos);\n        } else {\n            $this->setPage($pos);\n        }\n        $this->setLimit($limit);\n        $this->calculateLimits();\n    }\n\n    /**\n     * @return void\n     */\n    protected function calculateLimits()\n    {\n        $limit = $this->limit;\n        $total = $this->total;\n\n        if (!$limit || $limit > $total) {\n            // All records fit into a single page.\n            $this->start = 0;\n            $this->page = 1;\n            $this->pages = 1;\n\n            return;\n        }\n\n        if (null === $this->start) {\n            // If we are using page, convert it to start.\n            $this->start = (int)(($this->page - 1) * $limit);\n        }\n\n        if ($this->start > $total - $limit) {\n            // If start is greater than total count (i.e. we are asked to display records that don't exist)\n            // then set start to display the last natural page of results.\n            $this->start = (int)max(0, (ceil($total / $limit) - 1) * $limit);\n        }\n\n        // Set the total pages and current page values.\n        $this->page = (int)ceil(($this->start + 1) / $limit);\n        $this->pages = (int)ceil($total / $limit);\n    }\n\n    /**\n     * @return void\n     */\n    protected function calculateRange()\n    {\n        $options = $this->getOptions();\n        $displayed = $options['display'];\n        $opening = $options['opening'];\n        $ending = $options['ending'];\n\n        // Set the pagination iteration loop values.\n        $this->pagesStart = $this->page - (int)($displayed / 2);\n        if ($this->pagesStart < 1 + $opening) {\n            $this->pagesStart = 1 + $opening;\n        }\n        if ($this->pagesStart + $displayed - $opening > $this->pages) {\n            $this->pagesStop = $this->pages;\n            if ($this->pages < $displayed) {\n                $this->pagesStart = 1 + $opening;\n            } else {\n                $this->pagesStart = $this->pages - $displayed + 1 + $opening;\n            }\n        } else {\n            $this->pagesStop = (int)max(1, $this->pagesStart + $displayed - 1 - $ending);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Pagination/AbstractPaginationPage.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Pagination\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Pagination;\n\nuse Grav\\Framework\\Pagination\\Interfaces\\PaginationPageInterface;\n\n/**\n * Class AbstractPaginationPage\n * @package Grav\\Framework\\Pagination\n */\nabstract class AbstractPaginationPage implements PaginationPageInterface\n{\n    /** @var array */\n    protected $options;\n\n    /**\n     * @return bool\n     */\n    public function isActive(): bool\n    {\n        return $this->options['active'] ?? false;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isEnabled(): bool\n    {\n        return $this->options['enabled'] ?? false;\n    }\n\n    /**\n     * @return array\n     */\n    public function getOptions(): array\n    {\n        return $this->options;\n    }\n\n    /**\n     * @return int|null\n     */\n    public function getNumber(): ?int\n    {\n        return $this->options['number'] ?? null;\n    }\n\n    /**\n     * @return string\n     */\n    public function getLabel(): string\n    {\n        return $this->options['label'] ?? (string)$this->getNumber();\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getUrl(): ?string\n    {\n        return $this->options['route'] ? (string)$this->options['route']->getUri() : null;\n    }\n\n    /**\n     * @param array $options\n     */\n    protected function setOptions(array $options): void\n    {\n        $this->options = $options;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Pagination/Interfaces/PaginationInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Pagination\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Pagination\\Interfaces;\n\nuse Countable;\nuse Grav\\Framework\\Pagination\\PaginationPage;\nuse IteratorAggregate;\n\n/**\n * Interface PaginationInterface\n * @package Grav\\Framework\\Pagination\\Interfaces\n * @extends IteratorAggregate<int,PaginationPage>\n */\ninterface PaginationInterface extends Countable, IteratorAggregate\n{\n    /**\n     * @return int\n     */\n    public function getTotalPages(): int;\n\n    /**\n     * @return int\n     */\n    public function getPageNumber(): int;\n\n    /**\n     * @param int $count\n     * @return int|null\n     */\n    public function getPrevNumber(int $count = 1): ?int;\n\n    /**\n     * @param int $count\n     * @return int|null\n     */\n    public function getNextNumber(int $count = 1): ?int;\n\n    /**\n     * @return int\n     */\n    public function getStart(): int;\n\n    /**\n     * @return int\n     */\n    public function getLimit(): int;\n\n    /**\n     * @return int\n     */\n    public function getTotal(): int;\n\n    /**\n     * @return int\n     */\n    public function count(): int;\n\n    /**\n     * @return array\n     */\n    public function getOptions(): array;\n\n    /**\n     * @param int $page\n     * @param string|null $label\n     * @return PaginationPage|null\n     */\n    public function getPage(int $page, string $label = null): ?PaginationPage;\n\n    /**\n     * @param string|null $label\n     * @param int $count\n     * @return PaginationPage|null\n     */\n    public function getFirstPage(string $label = null, int $count = 0): ?PaginationPage;\n\n    /**\n     * @param string|null $label\n     * @param int $count\n     * @return PaginationPage|null\n     */\n    public function getPrevPage(string $label = null, int $count = 1): ?PaginationPage;\n\n    /**\n     * @param string|null $label\n     * @param int $count\n     * @return PaginationPage|null\n     */\n    public function getNextPage(string $label = null, int $count = 1): ?PaginationPage;\n\n    /**\n     * @param string|null $label\n     * @param int $count\n     * @return PaginationPage|null\n     */\n    public function getLastPage(string $label = null, int $count = 0): ?PaginationPage;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Pagination/Interfaces/PaginationPageInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Pagination\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Pagination\\Interfaces;\n\n/**\n * Interface PaginationPageInterface\n * @package Grav\\Framework\\Pagination\\Interfaces\n */\ninterface PaginationPageInterface\n{\n    /**\n     * @return bool\n     */\n    public function isActive(): bool;\n\n    /**\n     * @return bool\n     */\n    public function isEnabled(): bool;\n\n    /**\n     * @return array\n     */\n    public function getOptions(): array;\n\n    /**\n     * @return int|null\n     */\n    public function getNumber(): ?int;\n\n    /**\n     * @return string\n     */\n    public function getLabel(): string;\n\n    /**\n     * @return string|null\n     */\n    public function getUrl(): ?string;\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Pagination/Pagination.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Pagination\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Pagination;\n\nuse Grav\\Framework\\Route\\Route;\n\n/**\n * Class Pagination\n * @package Grav\\Framework\\Pagination\n */\nclass Pagination extends AbstractPagination\n{\n    /**\n     * Pagination constructor.\n     * @param Route $route\n     * @param int $total\n     * @param int|null $pos\n     * @param int|null $limit\n     * @param array|null $options\n     */\n    public function __construct(Route $route, int $total, int $pos = null, int $limit = null, array $options = null)\n    {\n        $this->initialize($route, $total, $pos, $limit, $options);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Pagination/PaginationPage.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Pagination\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Pagination;\n\n/**\n * Class PaginationPage\n * @package Grav\\Framework\\Pagination\n */\nclass PaginationPage extends AbstractPaginationPage\n{\n    /**\n     * PaginationPage constructor.\n     * @param array $options\n     */\n    public function __construct(array $options = [])\n    {\n        $this->setOptions($options);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/AbstractUri.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7;\n\nuse Grav\\Framework\\Uri\\UriPartsFilter;\nuse InvalidArgumentException;\nuse Psr\\Http\\Message\\UriInterface;\n\n/**\n * Bare minimum PSR7 implementation.\n *\n * @package Grav\\Framework\\Uri\\Psr7\n * @deprecated 1.6 Using message PSR-7 decorators instead.\n */\nabstract class AbstractUri implements UriInterface\n{\n    /** @var array */\n    protected static $defaultPorts = [\n        'http'  => 80,\n        'https' => 443\n    ];\n\n    /** @var string Uri scheme. */\n    private $scheme = '';\n    /** @var string Uri user. */\n    private $user = '';\n    /** @var string Uri password. */\n    private $password = '';\n    /** @var string Uri host. */\n    private $host = '';\n    /** @var int|null Uri port. */\n    private $port;\n    /** @var string Uri path. */\n    private $path = '';\n    /** @var string Uri query string (without ?). */\n    private $query = '';\n    /** @var string Uri fragment (without #). */\n    private $fragment = '';\n\n    /**\n     * Please define constructor which calls $this->init().\n     */\n    abstract public function __construct();\n\n    /**\n     * @inheritdoc\n     */\n    public function getScheme()\n    {\n        return $this->scheme;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getAuthority()\n    {\n        $authority = $this->host;\n\n        $userInfo = $this->getUserInfo();\n        if ($userInfo !== '') {\n            $authority = $userInfo . '@' . $authority;\n        }\n\n        if ($this->port !== null) {\n            $authority .= ':' . $this->port;\n        }\n\n        return $authority;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getUserInfo()\n    {\n        $userInfo = $this->user;\n\n        if ($this->password !== '') {\n            $userInfo .= ':' . $this->password;\n        }\n\n        return $userInfo;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getHost()\n    {\n        return $this->host;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getPort()\n    {\n        return $this->port;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getPath()\n    {\n        return $this->path;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getQuery()\n    {\n        return $this->query;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getFragment()\n    {\n        return $this->fragment;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withScheme($scheme)\n    {\n        $scheme = UriPartsFilter::filterScheme($scheme);\n\n        if ($this->scheme === $scheme) {\n            return $this;\n        }\n\n        $new = clone $this;\n        $new->scheme = $scheme;\n        $new->unsetDefaultPort();\n        $new->validate();\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     * @throws InvalidArgumentException\n     */\n    public function withUserInfo($user, $password = null)\n    {\n        $user = UriPartsFilter::filterUserInfo($user);\n        $password = UriPartsFilter::filterUserInfo($password ?? '');\n\n        if ($this->user === $user && $this->password === $password) {\n            return $this;\n        }\n\n        $new = clone $this;\n        $new->user = $user;\n        $new->password = $user !== '' ? $password : '';\n        $new->validate();\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withHost($host)\n    {\n        $host = UriPartsFilter::filterHost($host);\n\n        if ($this->host === $host) {\n            return $this;\n        }\n\n        $new = clone $this;\n        $new->host = $host;\n        $new->validate();\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withPort($port)\n    {\n        $port = UriPartsFilter::filterPort($port);\n\n        if ($this->port === $port) {\n            return $this;\n        }\n\n        $new = clone $this;\n        $new->port = $port;\n        $new->unsetDefaultPort();\n        $new->validate();\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withPath($path)\n    {\n        $path = UriPartsFilter::filterPath($path);\n\n        if ($this->path === $path) {\n            return $this;\n        }\n\n        $new = clone $this;\n        $new->path = $path;\n        $new->validate();\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withQuery($query)\n    {\n        $query = UriPartsFilter::filterQueryOrFragment($query);\n\n        if ($this->query === $query) {\n            return $this;\n        }\n\n        $new = clone $this;\n        $new->query = $query;\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     * @throws InvalidArgumentException\n     */\n    public function withFragment($fragment)\n    {\n        $fragment = UriPartsFilter::filterQueryOrFragment($fragment);\n\n        if ($this->fragment === $fragment) {\n            return $this;\n        }\n\n        $new = clone $this;\n        $new->fragment = $fragment;\n\n        return $new;\n    }\n\n    /**\n     * @return string\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        return $this->getUrl();\n    }\n\n    /**\n     * @return array\n     */\n    protected function getParts()\n    {\n        return [\n            'scheme'    => $this->scheme,\n            'host'      => $this->host,\n            'port'      => $this->port,\n            'user'      => $this->user,\n            'pass'      => $this->password,\n            'path'      => $this->path,\n            'query'     => $this->query,\n            'fragment'  => $this->fragment\n        ];\n    }\n\n    /**\n     * Return the fully qualified base URL ( like http://getgrav.org ).\n     *\n     * Note that this method never includes a trailing /\n     *\n     * @return string\n     */\n    protected function getBaseUrl()\n    {\n        $uri = '';\n\n        $scheme = $this->getScheme();\n        if ($scheme !== '') {\n            $uri .= $scheme . ':';\n        }\n\n        $authority = $this->getAuthority();\n        if ($authority !== '' || $scheme === 'file') {\n            $uri .= '//' . $authority;\n        }\n\n        return $uri;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getUrl()\n    {\n        $uri = $this->getBaseUrl() . $this->getPath();\n\n        $query = $this->getQuery();\n        if ($query !== '') {\n            $uri .= '?' . $query;\n        }\n\n        $fragment = $this->getFragment();\n        if ($fragment !== '') {\n            $uri .= '#' . $fragment;\n        }\n\n        return $uri;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getUser()\n    {\n        return $this->user;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getPassword()\n    {\n        return $this->password;\n    }\n\n    /**\n     * @param array $parts\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    protected function initParts(array $parts)\n    {\n        $this->scheme = isset($parts['scheme']) ? UriPartsFilter::filterScheme($parts['scheme']) : '';\n        $this->user = isset($parts['user']) ? UriPartsFilter::filterUserInfo($parts['user']) : '';\n        $this->password = isset($parts['pass']) ? UriPartsFilter::filterUserInfo($parts['pass']) : '';\n        $this->host = isset($parts['host']) ? UriPartsFilter::filterHost($parts['host']) : '';\n        $this->port = isset($parts['port']) ? UriPartsFilter::filterPort((int)$parts['port']) : null;\n        $this->path = isset($parts['path']) ? UriPartsFilter::filterPath($parts['path']) : '';\n        $this->query = isset($parts['query']) ? UriPartsFilter::filterQueryOrFragment($parts['query']) : '';\n        $this->fragment = isset($parts['fragment']) ? UriPartsFilter::filterQueryOrFragment($parts['fragment']) : '';\n\n        $this->unsetDefaultPort();\n        $this->validate();\n    }\n\n    /**\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    private function validate()\n    {\n        if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) {\n            throw new InvalidArgumentException('Uri with a scheme must have a host');\n        }\n\n        if ($this->getAuthority() === '') {\n            if (0 === strpos($this->path, '//')) {\n                throw new InvalidArgumentException('The path of a URI without an authority must not start with two slashes \\'//\\'');\n            }\n            if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) {\n                throw new InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon');\n            }\n        } elseif (isset($this->path[0]) && $this->path[0] !== '/') {\n            throw new InvalidArgumentException('The path of a URI with an authority must start with a slash \\'/\\' or be empty');\n        }\n    }\n\n    /**\n     * @return bool\n     */\n    protected function isDefaultPort()\n    {\n        $scheme = $this->scheme;\n        $port = $this->port;\n\n        return $this->port === null\n            || (isset(static::$defaultPorts[$scheme]) && $port === static::$defaultPorts[$scheme]);\n    }\n\n    /**\n     * @return void\n     */\n    private function unsetDefaultPort()\n    {\n        if ($this->isDefaultPort()) {\n            $this->port = null;\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Request.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7;\n\nuse Grav\\Framework\\Psr7\\Traits\\RequestDecoratorTrait;\nuse Psr\\Http\\Message\\RequestInterface;\nuse Psr\\Http\\Message\\StreamInterface;\nuse Psr\\Http\\Message\\UriInterface;\n\nclass Request implements RequestInterface\n{\n    use RequestDecoratorTrait;\n\n    /**\n     * @param string                               $method  HTTP method\n     * @param string|UriInterface                  $uri     URI\n     * @param array                                $headers Request headers\n     * @param string|null|resource|StreamInterface $body    Request body\n     * @param string                               $version Protocol version\n     */\n    public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1')\n    {\n        $this->message = new \\Nyholm\\Psr7\\Request($method, $uri, $headers, $body, $version);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Response.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7;\n\nuse Grav\\Framework\\Psr7\\Traits\\ResponseDecoratorTrait;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\StreamInterface;\nuse RuntimeException;\nuse function in_array;\n\n/**\n * Class Response\n * @package Slim\\Http\n */\nclass Response implements ResponseInterface\n{\n    use ResponseDecoratorTrait;\n\n    /** @var string EOL characters used for HTTP response. */\n    private const EOL = \"\\r\\n\";\n\n    /**\n     * @param int                                  $status  Status code\n     * @param array                                $headers Response headers\n     * @param string|null|resource|StreamInterface $body    Response body\n     * @param string                               $version Protocol version\n     * @param string|null                          $reason  Reason phrase (optional)\n     */\n    public function __construct(int $status = 200, array $headers = [], $body = null, string $version = '1.1', string $reason = null)\n    {\n        $this->message = new \\Nyholm\\Psr7\\Response($status, $headers, $body, $version, $reason);\n    }\n\n    /**\n     * Json.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * This method prepares the response object to return an HTTP Json\n     * response to the client.\n     *\n     * @param  mixed  $data   The data\n     * @param  int|null $status The HTTP status code.\n     * @param  int    $options Json encoding options\n     * @param  int    $depth Json encoding max depth\n     * @return static\n     * @phpstan-param positive-int $depth\n     */\n    public function withJson($data, int $status = null, int $options = 0, int $depth = 512): ResponseInterface\n    {\n        $json = (string) json_encode($data, $options, $depth);\n\n        if (json_last_error() !== JSON_ERROR_NONE) {\n            throw new RuntimeException(json_last_error_msg(), json_last_error());\n        }\n\n        $response = $this->getResponse()\n            ->withHeader('Content-Type', 'application/json;charset=utf-8')\n            ->withBody(new Stream($json));\n\n        if ($status !== null) {\n            $response = $response->withStatus($status);\n        }\n\n        $new = clone $this;\n        $new->message = $response;\n\n        return $new;\n    }\n\n    /**\n     * Redirect.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * This method prepares the response object to return an HTTP Redirect\n     * response to the client.\n     *\n     * @param string $url The redirect destination.\n     * @param int|null $status The redirect HTTP status code.\n     * @return static\n     */\n    public function withRedirect(string $url, $status = null): ResponseInterface\n    {\n        $response = $this->getResponse()->withHeader('Location', $url);\n\n        if ($status === null) {\n            $status = 302;\n        }\n\n        $new = clone $this;\n        $new->message = $response->withStatus($status);\n\n        return $new;\n    }\n\n    /**\n     * Is this response empty?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isEmpty(): bool\n    {\n        return in_array($this->getResponse()->getStatusCode(), [204, 205, 304], true);\n    }\n\n\n    /**\n     * Is this response OK?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isOk(): bool\n    {\n        return $this->getResponse()->getStatusCode() === 200;\n    }\n\n    /**\n     * Is this response a redirect?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isRedirect(): bool\n    {\n        return in_array($this->getResponse()->getStatusCode(), [301, 302, 303, 307, 308], true);\n    }\n\n    /**\n     * Is this response forbidden?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     * @api\n     */\n    public function isForbidden(): bool\n    {\n        return $this->getResponse()->getStatusCode() === 403;\n    }\n\n    /**\n     * Is this response not Found?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isNotFound(): bool\n    {\n        return $this->getResponse()->getStatusCode() === 404;\n    }\n\n    /**\n     * Is this response informational?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isInformational(): bool\n    {\n        $response = $this->getResponse();\n\n        return $response->getStatusCode() >= 100 && $response->getStatusCode() < 200;\n    }\n\n    /**\n     * Is this response successful?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isSuccessful(): bool\n    {\n        $response = $this->getResponse();\n\n        return $response->getStatusCode() >= 200 && $response->getStatusCode() < 300;\n    }\n\n    /**\n     * Is this response a redirection?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isRedirection(): bool\n    {\n        $response = $this->getResponse();\n\n        return $response->getStatusCode() >= 300 && $response->getStatusCode() < 400;\n    }\n\n    /**\n     * Is this response a client error?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isClientError(): bool\n    {\n        $response = $this->getResponse();\n\n        return $response->getStatusCode() >= 400 && $response->getStatusCode() < 500;\n    }\n\n    /**\n     * Is this response a server error?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isServerError(): bool\n    {\n        $response = $this->getResponse();\n\n        return $response->getStatusCode() >= 500 && $response->getStatusCode() < 600;\n    }\n\n    /**\n     * Convert response to string.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return string\n     */\n    public function __toString(): string\n    {\n        $response = $this->getResponse();\n        $output = sprintf(\n            'HTTP/%s %s %s%s',\n            $response->getProtocolVersion(),\n            $response->getStatusCode(),\n            $response->getReasonPhrase(),\n            self::EOL\n        );\n\n        foreach ($response->getHeaders() as $name => $values) {\n            $output .= sprintf('%s: %s', $name, $response->getHeaderLine($name)) . self::EOL;\n        }\n\n        $output .= self::EOL;\n        $output .= $response->getBody();\n\n        return $output;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/ServerRequest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7;\n\nuse Grav\\Framework\\Psr7\\Traits\\ServerRequestDecoratorTrait;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Message\\StreamInterface;\nuse Psr\\Http\\Message\\UriInterface;\nuse function is_array;\nuse function is_object;\n\n/**\n * Class ServerRequest\n * @package Slim\\Http\n */\nclass ServerRequest implements ServerRequestInterface\n{\n    use ServerRequestDecoratorTrait;\n\n    /**\n     * @param string                               $method       HTTP method\n     * @param string|UriInterface                  $uri          URI\n     * @param array                                $headers      Request headers\n     * @param string|null|resource|StreamInterface $body         Request body\n     * @param string                               $version      Protocol version\n     * @param array                                $serverParams Typically the $_SERVER superglobal\n     */\n    public function __construct(string $method, $uri, array $headers = [], $body = null, string $version = '1.1', array $serverParams = [])\n    {\n        $this->message = new \\Nyholm\\Psr7\\ServerRequest($method, $uri, $headers, $body, $version, $serverParams);\n    }\n\n    /**\n     * Get serverRequest content character set, if known.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return string|null\n     */\n    public function getContentCharset(): ?string\n    {\n        $mediaTypeParams = $this->getMediaTypeParams();\n\n        if (isset($mediaTypeParams['charset'])) {\n            return $mediaTypeParams['charset'];\n        }\n\n        return null;\n    }\n\n    /**\n     * Get serverRequest content type.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return string|null The serverRequest content type, if known\n     */\n    public function getContentType(): ?string\n    {\n        $result = $this->getRequest()->getHeader('Content-Type');\n\n        return $result ? $result[0] : null;\n    }\n\n    /**\n     * Get serverRequest content length, if known.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return int|null\n     */\n    public function getContentLength(): ?int\n    {\n        $result = $this->getRequest()->getHeader('Content-Length');\n\n        return $result ? (int) $result[0] : null;\n    }\n\n    /**\n     * Fetch cookie value from cookies sent by the client to the server.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @param string $key     The attribute name.\n     * @param mixed  $default Default value to return if the attribute does not exist.\n     *\n     * @return mixed\n     */\n    public function getCookieParam($key, $default = null)\n    {\n        $cookies = $this->getRequest()->getCookieParams();\n\n        return $cookies[$key] ?? $default;\n    }\n\n    /**\n     * Get serverRequest media type, if known.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return string|null The serverRequest media type, minus content-type params\n     */\n    public function getMediaType(): ?string\n    {\n        $contentType = $this->getContentType();\n\n        if ($contentType) {\n            $contentTypeParts = preg_split('/\\s*[;,]\\s*/', $contentType);\n            if ($contentTypeParts === false) {\n                return null;\n            }\n            return strtolower($contentTypeParts[0]);\n        }\n\n        return null;\n    }\n\n    /**\n     * Get serverRequest media type params, if known.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return mixed[]\n     */\n    public function getMediaTypeParams(): array\n    {\n        $contentType = $this->getContentType();\n        $contentTypeParams = [];\n\n        if ($contentType) {\n            $contentTypeParts = preg_split('/\\s*[;,]\\s*/', $contentType);\n            if ($contentTypeParts !== false) {\n                $contentTypePartsLength = count($contentTypeParts);\n                for ($i = 1; $i < $contentTypePartsLength; $i++) {\n                    $paramParts = explode('=', $contentTypeParts[$i]);\n                    $contentTypeParams[strtolower($paramParts[0])] = $paramParts[1];\n                }\n            }\n        }\n\n        return $contentTypeParams;\n    }\n\n    /**\n     * Fetch serverRequest parameter value from body or query string (in that order).\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @param  string $key The parameter key.\n     * @param  string|null $default The default value.\n     *\n     * @return mixed The parameter value.\n     */\n    public function getParam($key, $default = null)\n    {\n        $postParams = $this->getParsedBody();\n        $getParams = $this->getQueryParams();\n        $result = $default;\n\n        if (is_array($postParams) && isset($postParams[$key])) {\n            $result = $postParams[$key];\n        } elseif (is_object($postParams) && property_exists($postParams, $key)) {\n            $result = $postParams->$key;\n        } elseif (isset($getParams[$key])) {\n            $result = $getParams[$key];\n        }\n\n        return $result;\n    }\n\n    /**\n     * Fetch associative array of body and query string parameters.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return mixed[]\n     */\n    public function getParams(): array\n    {\n        $params = $this->getQueryParams();\n        $postParams = $this->getParsedBody();\n\n        if ($postParams) {\n            $params = array_merge($params, (array)$postParams);\n        }\n\n        return $params;\n    }\n\n    /**\n     * Fetch parameter value from serverRequest body.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @param string $key\n     * @param mixed $default\n     *\n     * @return mixed\n     */\n    public function getParsedBodyParam($key, $default = null)\n    {\n        $postParams = $this->getParsedBody();\n        $result = $default;\n\n        if (is_array($postParams) && isset($postParams[$key])) {\n            $result = $postParams[$key];\n        } elseif (is_object($postParams) && property_exists($postParams, $key)) {\n            $result = $postParams->{$key};\n        }\n\n        return $result;\n    }\n\n    /**\n     * Fetch parameter value from query string.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @param string $key\n     * @param mixed $default\n     *\n     * @return mixed\n     */\n    public function getQueryParam($key, $default = null)\n    {\n        $getParams = $this->getQueryParams();\n\n        return $getParams[$key] ?? $default;\n    }\n\n    /**\n     * Retrieve a server parameter.\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @param  string $key\n     * @param  mixed  $default\n     * @return mixed\n     */\n    public function getServerParam($key, $default = null)\n    {\n        $serverParams = $this->getRequest()->getServerParams();\n\n        return $serverParams[$key] ?? $default;\n    }\n\n    /**\n     * Does this serverRequest use a given method?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @param  string $method HTTP method\n     * @return bool\n     */\n    public function isMethod($method): bool\n    {\n        return $this->getRequest()->getMethod() === $method;\n    }\n\n    /**\n     * Is this a DELETE serverRequest?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isDelete(): bool\n    {\n        return $this->isMethod('DELETE');\n    }\n\n    /**\n     * Is this a GET serverRequest?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isGet(): bool\n    {\n        return $this->isMethod('GET');\n    }\n\n    /**\n     * Is this a HEAD serverRequest?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isHead(): bool\n    {\n        return $this->isMethod('HEAD');\n    }\n\n    /**\n     * Is this a OPTIONS serverRequest?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isOptions(): bool\n    {\n        return $this->isMethod('OPTIONS');\n    }\n\n    /**\n     * Is this a PATCH serverRequest?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isPatch(): bool\n    {\n        return $this->isMethod('PATCH');\n    }\n\n    /**\n     * Is this a POST serverRequest?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isPost(): bool\n    {\n        return $this->isMethod('POST');\n    }\n\n    /**\n     * Is this a PUT serverRequest?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isPut(): bool\n    {\n        return $this->isMethod('PUT');\n    }\n\n    /**\n     * Is this an XHR serverRequest?\n     *\n     * Note: This method is not part of the PSR-7 standard.\n     *\n     * @return bool\n     */\n    public function isXhr(): bool\n    {\n        return $this->getRequest()->getHeaderLine('X-Requested-With') === 'XMLHttpRequest';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Stream.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7;\n\nuse Grav\\Framework\\Psr7\\Traits\\StreamDecoratorTrait;\nuse Psr\\Http\\Message\\StreamInterface;\n\n/**\n * Class Stream\n * @package Grav\\Framework\\Psr7\n */\nclass Stream implements StreamInterface\n{\n    use StreamDecoratorTrait;\n\n    /**\n     * @param string|resource|StreamInterface $body\n     * @return static\n     */\n    public static function create($body = '')\n    {\n        return new static($body);\n    }\n\n    /**\n     * Stream constructor.\n     *\n     * @param string|resource|StreamInterface $body\n     */\n    public function __construct($body = '')\n    {\n        $this->stream = \\Nyholm\\Psr7\\Stream::create($body);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Traits/MessageDecoratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7\\Traits;\n\nuse Psr\\Http\\Message\\MessageInterface;\nuse Psr\\Http\\Message\\StreamInterface;\n\n/**\n * @author Márk Sági-Kazár <mark.sagikazar@gmail.com>\n */\ntrait MessageDecoratorTrait\n{\n    /** @var MessageInterface */\n    private $message;\n\n    /**\n     * Returns the decorated message.\n     *\n     * Since the underlying Message is immutable as well\n     * exposing it is not an issue, because it's state cannot be altered\n     *\n     * @return MessageInterface\n     */\n    public function getMessage(): MessageInterface\n    {\n        return $this->message;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getProtocolVersion(): string\n    {\n        return $this->message->getProtocolVersion();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withProtocolVersion($version): self\n    {\n        $new = clone $this;\n        $new->message = $this->message->withProtocolVersion($version);\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getHeaders(): array\n    {\n        return $this->message->getHeaders();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function hasHeader($header): bool\n    {\n        return $this->message->hasHeader($header);\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getHeader($header): array\n    {\n        return $this->message->getHeader($header);\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getHeaderLine($header): string\n    {\n        return $this->message->getHeaderLine($header);\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getBody(): StreamInterface\n    {\n        return $this->message->getBody();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withHeader($header, $value): self\n    {\n        $new = clone $this;\n        $new->message = $this->message->withHeader($header, $value);\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withAddedHeader($header, $value): self\n    {\n        $new = clone $this;\n        $new->message = $this->message->withAddedHeader($header, $value);\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withoutHeader($header): self\n    {\n        $new = clone $this;\n        $new->message = $this->message->withoutHeader($header);\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withBody(StreamInterface $body): self\n    {\n        $new = clone $this;\n        $new->message = $this->message->withBody($body);\n\n        return $new;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Traits/RequestDecoratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7\\Traits;\n\nuse Psr\\Http\\Message\\RequestInterface;\nuse Psr\\Http\\Message\\UriInterface;\n\n/**\n * @author Márk Sági-Kazár <mark.sagikazar@gmail.com>\n */\ntrait RequestDecoratorTrait\n{\n    use MessageDecoratorTrait {\n        getMessage as private;\n    }\n\n    /**\n     * Returns the decorated request.\n     *\n     * Since the underlying Request is immutable as well\n     * exposing it is not an issue, because it's state cannot be altered\n     *\n     * @return RequestInterface\n     */\n    public function getRequest(): RequestInterface\n    {\n        /** @var RequestInterface $message */\n        $message = $this->getMessage();\n\n        return $message;\n    }\n\n    /**\n     * Exchanges the underlying request with another.\n     *\n     * @param RequestInterface $request\n     * @return self\n     */\n    public function withRequest(RequestInterface $request): self\n    {\n        $new = clone $this;\n        $new->message = $request;\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getRequestTarget(): string\n    {\n        return $this->getRequest()->getRequestTarget();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withRequestTarget($requestTarget): self\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withRequestTarget($requestTarget);\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getMethod(): string\n    {\n        return $this->getRequest()->getMethod();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withMethod($method): self\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withMethod($method);\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getUri(): UriInterface\n    {\n        return $this->getRequest()->getUri();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withUri(UriInterface $uri, $preserveHost = false): self\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withUri($uri, $preserveHost);\n\n        return $new;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Traits/ResponseDecoratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7\\Traits;\n\nuse Psr\\Http\\Message\\ResponseInterface;\n\n/**\n * @author Márk Sági-Kazár <mark.sagikazar@gmail.com>\n */\ntrait ResponseDecoratorTrait\n{\n    use MessageDecoratorTrait {\n        getMessage as private;\n    }\n\n    /**\n     * Returns the decorated response.\n     *\n     * Since the underlying Response is immutable as well\n     * exposing it is not an issue, because it's state cannot be altered\n     *\n     * @return ResponseInterface\n     */\n    public function getResponse(): ResponseInterface\n    {\n        /** @var ResponseInterface $message */\n        $message = $this->getMessage();\n\n        return $message;\n    }\n\n    /**\n     * Exchanges the underlying response with another.\n     *\n     * @param ResponseInterface $response\n     *\n     * @return self\n     */\n    public function withResponse(ResponseInterface $response): self\n    {\n        $new = clone $this;\n        $new->message = $response;\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getStatusCode(): int\n    {\n        return $this->getResponse()->getStatusCode();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function withStatus($code, $reasonPhrase = ''): self\n    {\n        $new = clone $this;\n        $new->message = $this->getResponse()->withStatus($code, $reasonPhrase);\n\n        return $new;\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getReasonPhrase(): string\n    {\n        return $this->getResponse()->getReasonPhrase();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Traits/ServerRequestDecoratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7\\Traits;\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\n\n/**\n * Trait ServerRequestDecoratorTrait\n * @package Grav\\Framework\\Psr7\\Traits\n */\ntrait ServerRequestDecoratorTrait\n{\n    use RequestDecoratorTrait;\n\n    /**\n     * Returns the decorated request.\n     *\n     * Since the underlying Request is immutable as well\n     * exposing it is not an issue, because it's state cannot be altered\n     *\n     * @return ServerRequestInterface\n     */\n    public function getRequest(): ServerRequestInterface\n    {\n        /** @var ServerRequestInterface $message */\n        $message = $this->getMessage();\n\n        return $message;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getAttribute($name, $default = null)\n    {\n        return $this->getRequest()->getAttribute($name, $default);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getAttributes()\n    {\n        return $this->getRequest()->getAttributes();\n    }\n\n\n    /**\n     * @inheritdoc\n     */\n    public function getCookieParams()\n    {\n        return $this->getRequest()->getCookieParams();\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getParsedBody()\n    {\n        return $this->getRequest()->getParsedBody();\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getQueryParams()\n    {\n        return $this->getRequest()->getQueryParams();\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getServerParams()\n    {\n        return $this->getRequest()->getServerParams();\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getUploadedFiles()\n    {\n        return $this->getRequest()->getUploadedFiles();\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withAttribute($name, $value)\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withAttribute($name, $value);\n\n        return $new;\n    }\n\n    /**\n     * @param array $attributes\n     * @return ServerRequestInterface\n     */\n    public function withAttributes(array $attributes)\n    {\n        $new = clone $this;\n        foreach ($attributes as $attribute => $value) {\n            $new->message = $new->withAttribute($attribute, $value);\n        }\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withoutAttribute($name)\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withoutAttribute($name);\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withCookieParams(array $cookies)\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withCookieParams($cookies);\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withParsedBody($data)\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withParsedBody($data);\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withQueryParams(array $query)\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withQueryParams($query);\n\n        return $new;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function withUploadedFiles(array $uploadedFiles)\n    {\n        $new = clone $this;\n        $new->message = $this->getRequest()->withUploadedFiles($uploadedFiles);\n\n        return $new;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Traits/StreamDecoratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7\\Traits;\n\nuse Psr\\Http\\Message\\StreamInterface;\n\n/**\n * Trait StreamDecoratorTrait\n * @package Grav\\Framework\\Psr7\\Traits\n */\ntrait StreamDecoratorTrait\n{\n    /** @var StreamInterface */\n    protected $stream;\n\n    /**\n     * {@inheritdoc}\n     */\n    public function __toString(): string\n    {\n        return $this->stream->__toString();\n    }\n\n    /**\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __destruct()\n    {\n        $this->stream->close();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function close(): void\n    {\n        $this->stream->close();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function detach()\n    {\n        return $this->stream->detach();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getSize(): ?int\n    {\n        return $this->stream->getSize();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function tell(): int\n    {\n        return $this->stream->tell();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function eof(): bool\n    {\n        return $this->stream->eof();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function isSeekable(): bool\n    {\n        return $this->stream->isSeekable();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function seek($offset, $whence = \\SEEK_SET): void\n    {\n        $this->stream->seek($offset, $whence);\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function rewind(): void\n    {\n        $this->stream->rewind();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function isWritable(): bool\n    {\n        return $this->stream->isWritable();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function write($string): int\n    {\n        return $this->stream->write($string);\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function isReadable(): bool\n    {\n        return $this->stream->isReadable();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function read($length): string\n    {\n        return $this->stream->read($length);\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getContents(): string\n    {\n        return $this->stream->getContents();\n    }\n\n    /**\n     * {@inheritdoc}\n     */\n    public function getMetadata($key = null)\n    {\n        return $this->stream->getMetadata($key);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Traits/UploadedFileDecoratorTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7\\Traits;\n\nuse Psr\\Http\\Message\\StreamInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\n\n/**\n * Trait UploadedFileDecoratorTrait\n * @package Grav\\Framework\\Psr7\\Traits\n */\ntrait UploadedFileDecoratorTrait\n{\n    /** @var UploadedFileInterface */\n    protected $uploadedFile;\n\n    /**\n     * @return StreamInterface\n     */\n    public function getStream(): StreamInterface\n    {\n        return $this->uploadedFile->getStream();\n    }\n\n    /**\n     * @param string $targetPath\n     */\n    public function moveTo($targetPath): void\n    {\n        $this->uploadedFile->moveTo($targetPath);\n    }\n\n    /**\n     * @return int|null\n     */\n    public function getSize(): ?int\n    {\n        return $this->uploadedFile->getSize();\n    }\n\n    /**\n     * @return int\n     */\n    public function getError(): int\n    {\n        return $this->uploadedFile->getError();\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getClientFilename(): ?string\n    {\n        return $this->uploadedFile->getClientFilename();\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getClientMediaType(): ?string\n    {\n        return $this->uploadedFile->getClientMediaType();\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Traits/UriDecorationTrait.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7\\Traits;\n\nuse Psr\\Http\\Message\\UriInterface;\n\n/**\n * Trait UriDecorationTrait\n * @package Grav\\Framework\\Psr7\\Traits\n */\ntrait UriDecorationTrait\n{\n    /** @var UriInterface */\n    protected $uri;\n\n    /**\n     * @return string\n     */\n    public function __toString(): string\n    {\n        return $this->uri->__toString();\n    }\n\n    /**\n     * @return string\n     */\n    public function getScheme(): string\n    {\n        return $this->uri->getScheme();\n    }\n\n    /**\n     * @return string\n     */\n    public function getAuthority(): string\n    {\n        return $this->uri->getAuthority();\n    }\n\n    /**\n     * @return string\n     */\n    public function getUserInfo(): string\n    {\n        return $this->uri->getUserInfo();\n    }\n\n    /**\n     * @return string\n     */\n    public function getHost(): string\n    {\n        return $this->uri->getHost();\n    }\n\n    /**\n     * @return int|null\n     */\n    public function getPort(): ?int\n    {\n        return $this->uri->getPort();\n    }\n\n    /**\n     * @return string\n     */\n    public function getPath(): string\n    {\n        return $this->uri->getPath();\n    }\n\n    /**\n     * @return string\n     */\n    public function getQuery(): string\n    {\n        return $this->uri->getQuery();\n    }\n\n    /**\n     * @return string\n     */\n    public function getFragment(): string\n    {\n        return $this->uri->getFragment();\n    }\n\n    /**\n     * @param string $scheme\n     * @return UriInterface\n     */\n    public function withScheme($scheme): UriInterface\n    {\n        $new = clone $this;\n        $new->uri = $this->uri->withScheme($scheme);\n\n        /** @var UriInterface $new */\n        return $new;\n    }\n\n    /**\n     * @param string $user\n     * @param string|null $password\n     * @return UriInterface\n     */\n    public function withUserInfo($user, $password = null): UriInterface\n    {\n        $new = clone $this;\n        $new->uri = $this->uri->withUserInfo($user, $password);\n\n        /** @var UriInterface $new */\n        return $new;\n    }\n\n    /**\n     * @param string $host\n     * @return UriInterface\n     */\n    public function withHost($host): UriInterface\n    {\n        $new = clone $this;\n        $new->uri = $this->uri->withHost($host);\n\n        /** @var UriInterface $new */\n        return $new;\n    }\n\n    /**\n     * @param int|null $port\n     * @return UriInterface\n     */\n    public function withPort($port): UriInterface\n    {\n        $new = clone $this;\n        $new->uri = $this->uri->withPort($port);\n\n        /** @var UriInterface $new */\n        return $new;\n    }\n\n    /**\n     * @param string $path\n     * @return UriInterface\n     */\n    public function withPath($path): UriInterface\n    {\n        $new = clone $this;\n        $new->uri = $this->uri->withPath($path);\n\n        /** @var UriInterface $new */\n        return $new;\n    }\n\n    /**\n     * @param string $query\n     * @return UriInterface\n     */\n    public function withQuery($query): UriInterface\n    {\n        $new = clone $this;\n        $new->uri = $this->uri->withQuery($query);\n\n        /** @var UriInterface $new */\n        return $new;\n    }\n\n    /**\n     * @param string $fragment\n     * @return UriInterface\n     */\n    public function withFragment($fragment): UriInterface\n    {\n        $new = clone $this;\n        $new->uri = $this->uri->withFragment($fragment);\n\n        /** @var UriInterface $new */\n        return $new;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/UploadedFile.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7;\n\nuse Grav\\Framework\\Psr7\\Traits\\UploadedFileDecoratorTrait;\nuse Psr\\Http\\Message\\StreamInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\n\n/**\n * Class UploadedFile\n * @package Grav\\Framework\\Psr7\n */\nclass UploadedFile implements UploadedFileInterface\n{\n    use UploadedFileDecoratorTrait;\n\n    /** @var array */\n    private $meta = [];\n\n    /**\n     * @param StreamInterface|string|resource $streamOrFile\n     * @param int                             $size\n     * @param int                             $errorStatus\n     * @param string|null                     $clientFilename\n     * @param string|null                     $clientMediaType\n     */\n    public function __construct($streamOrFile, $size, $errorStatus, $clientFilename = null, $clientMediaType = null)\n    {\n        $this->uploadedFile = new \\Nyholm\\Psr7\\UploadedFile($streamOrFile, $size, $errorStatus, $clientFilename, $clientMediaType);\n    }\n\n    /**\n     * @param array $meta\n     * @return $this\n     */\n    public function setMeta(array $meta)\n    {\n        $this->meta = $meta;\n\n        return $this;\n    }\n\n    /**\n     * @param array $meta\n     * @return $this\n     */\n    public function addMeta(array $meta)\n    {\n        $this->meta = array_merge($this->meta, $meta);\n\n        return $this;\n    }\n\n    /**\n     * @return array\n     */\n    public function getMeta(): array\n    {\n        return $this->meta;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Psr7/Uri.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\Psr7\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Psr7;\n\nuse Grav\\Framework\\Psr7\\Traits\\UriDecorationTrait;\nuse Grav\\Framework\\Uri\\UriFactory;\nuse GuzzleHttp\\Psr7\\Uri as GuzzleUri;\nuse Psr\\Http\\Message\\UriInterface;\n\n/**\n * Class Uri\n * @package Grav\\Framework\\Psr7\n */\nclass Uri implements UriInterface\n{\n    use UriDecorationTrait;\n\n    public function __construct(string $uri = '')\n    {\n        $this->uri = new \\Nyholm\\Psr7\\Uri($uri);\n    }\n\n    /**\n     * @return array\n     */\n    public function getQueryParams(): array\n    {\n        return UriFactory::parseQuery($this->getQuery());\n    }\n\n    /**\n     * @param array $params\n     * @return UriInterface\n     */\n    public function withQueryParams(array $params): UriInterface\n    {\n        $query = UriFactory::buildQuery($params);\n\n        return $this->withQuery($query);\n    }\n\n    /**\n     * Whether the URI has the default port of the current scheme.\n     *\n     * `$uri->getPort()` may return the standard port. This method can be used for some non-http/https Uri.\n     *\n     * @return bool\n     */\n    public function isDefaultPort(): bool\n    {\n        return $this->getPort() === null || GuzzleUri::isDefaultPort($this);\n    }\n\n    /**\n     * Whether the URI is absolute, i.e. it has a scheme.\n     *\n     * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true\n     * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative\n     * to another URI, the base URI. Relative references can be divided into several forms:\n     * - network-path references, e.g. '//example.com/path'\n     * - absolute-path references, e.g. '/path'\n     * - relative-path references, e.g. 'subpath'\n     *\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4\n     */\n    public function isAbsolute(): bool\n    {\n        return GuzzleUri::isAbsolute($this);\n    }\n\n    /**\n     * Whether the URI is a network-path reference.\n     *\n     * A relative reference that begins with two slash characters is termed an network-path reference.\n     *\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4.2\n     */\n    public function isNetworkPathReference(): bool\n    {\n        return GuzzleUri::isNetworkPathReference($this);\n    }\n\n    /**\n     * Whether the URI is a absolute-path reference.\n     *\n     * A relative reference that begins with a single slash character is termed an absolute-path reference.\n     *\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4.2\n     */\n    public function isAbsolutePathReference(): bool\n    {\n        return GuzzleUri::isAbsolutePathReference($this);\n    }\n\n    /**\n     * Whether the URI is a relative-path reference.\n     *\n     * A relative reference that does not begin with a slash character is termed a relative-path reference.\n     *\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4.2\n     */\n    public function isRelativePathReference(): bool\n    {\n        return GuzzleUri::isRelativePathReference($this);\n    }\n\n    /**\n     * Whether the URI is a same-document reference.\n     *\n     * A same-document reference refers to a URI that is, aside from its fragment\n     * component, identical to the base URI. When no base URI is given, only an empty\n     * URI reference (apart from its fragment) is considered a same-document reference.\n     *\n     * @param UriInterface|null $base An optional base URI to compare against\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4.4\n     */\n    public function isSameDocumentReference(UriInterface $base = null): bool\n    {\n        return GuzzleUri::isSameDocumentReference($this, $base);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Relationships/Relationships.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Relationships;\n\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\nuse Grav\\Framework\\Contracts\\Relationships\\RelationshipInterface;\nuse Grav\\Framework\\Contracts\\Relationships\\RelationshipsInterface;\nuse Grav\\Framework\\Flex\\FlexIdentifier;\nuse RuntimeException;\nuse function count;\n\n/**\n * Class Relationships\n *\n * @template T of \\Grav\\Framework\\Contracts\\Object\\IdentifierInterface\n * @template P of \\Grav\\Framework\\Contracts\\Object\\IdentifierInterface\n * @implements RelationshipsInterface<T,P>\n */\nclass Relationships implements RelationshipsInterface\n{\n    /** @var P */\n    protected $parent;\n    /** @var array */\n    protected $options;\n\n    /** @var RelationshipInterface<T,P>[] */\n    protected $relationships;\n\n    /**\n     * Relationships constructor.\n     * @param P $parent\n     * @param array $options\n     */\n    public function __construct(IdentifierInterface $parent, array $options)\n    {\n        $this->parent = $parent;\n        $this->options = $options;\n        $this->relationships = [];\n    }\n\n    /**\n     * @return bool\n     * @phpstan-pure\n     */\n    public function isModified(): bool\n    {\n        return !empty($this->getModified());\n    }\n\n    /**\n     * @return RelationshipInterface<T,P>[]\n     * @phpstan-pure\n     */\n    public function getModified(): array\n    {\n        $list = [];\n        foreach ($this->relationships as $name => $relationship) {\n            if ($relationship->isModified()) {\n                $list[$name] = $relationship;\n            }\n        }\n\n        return $list;\n    }\n\n    /**\n     * @return int\n     * @phpstan-pure\n     */\n    public function count(): int\n    {\n        return count($this->options);\n    }\n\n    /**\n     * @param string $offset\n     * @return bool\n     * @phpstan-pure\n     */\n    public function offsetExists($offset): bool\n    {\n        return isset($this->options[$offset]);\n    }\n\n    /**\n     * @param string $offset\n     * @return RelationshipInterface<T,P>|null\n     */\n    public function offsetGet($offset): ?RelationshipInterface\n    {\n        if (!isset($this->relationships[$offset])) {\n            $options = $this->options[$offset] ?? null;\n            if (null === $options) {\n                return null;\n            }\n\n            $this->relationships[$offset] = $this->createRelationship($offset, $options);\n        }\n\n        return $this->relationships[$offset];\n    }\n\n    /**\n     * @param string $offset\n     * @param mixed $value\n     * @return never-return\n     */\n    public function offsetSet($offset, $value)\n    {\n        throw new RuntimeException('Setting relationship is not supported', 500);\n    }\n\n    /**\n     * @param string $offset\n     * @return never-return\n     */\n    public function offsetUnset($offset)\n    {\n        throw new RuntimeException('Removing relationship is not allowed', 500);\n    }\n\n    /**\n     * @return RelationshipInterface<T,P>|null\n     */\n    public function current(): ?RelationshipInterface\n    {\n        $name = key($this->options);\n        if ($name === null) {\n            return null;\n        }\n\n        return $this->offsetGet($name);\n    }\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function key(): string\n    {\n        return key($this->options);\n    }\n\n    /**\n     * @return void\n     * @phpstan-pure\n     */\n    public function next(): void\n    {\n        next($this->options);\n    }\n\n    /**\n     * @return void\n     * @phpstan-pure\n     */\n    public function rewind(): void\n    {\n        reset($this->options);\n    }\n\n    /**\n     * @return bool\n     * @phpstan-pure\n     */\n    public function valid(): bool\n    {\n        return key($this->options) !== null;\n    }\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        $list = [];\n        foreach ($this as $name => $relationship) {\n            $list[$name] = $relationship->jsonSerialize();\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $name\n     * @param array $options\n     * @return ToOneRelationship|ToManyRelationship\n     */\n    private function createRelationship(string $name, array $options): RelationshipInterface\n    {\n        $data = null;\n\n        $parent = $this->parent;\n        if ($parent instanceof FlexIdentifier) {\n            $object = $parent->getObject();\n            if (!method_exists($object, 'initRelationship')) {\n                throw new RuntimeException(sprintf('Bad relationship %s', $name), 500);\n            }\n\n            $data = $object->initRelationship($name);\n        }\n\n        $cardinality = $options['cardinality'] ?? '';\n        switch ($cardinality) {\n            case 'to-one':\n                $relationship = new ToOneRelationship($parent, $name, $options, $data);\n                break;\n            case 'to-many':\n                $relationship = new ToManyRelationship($parent, $name, $options, $data ?? []);\n                break;\n            default:\n                throw new RuntimeException(sprintf('Bad relationship cardinality %s', $cardinality), 500);\n        }\n\n        return $relationship;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Relationships/ToManyRelationship.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Relationships;\n\nuse ArrayIterator;\nuse Grav\\Framework\\Compat\\Serializable;\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\nuse Grav\\Framework\\Contracts\\Relationships\\ToManyRelationshipInterface;\nuse Grav\\Framework\\Relationships\\Traits\\RelationshipTrait;\nuse function count;\nuse function is_callable;\n\n/**\n * Class ToManyRelationship\n *\n * @template T of IdentifierInterface\n * @template P of IdentifierInterface\n * @template-implements ToManyRelationshipInterface<T,P>\n */\nclass ToManyRelationship implements ToManyRelationshipInterface\n{\n    /** @template-use RelationshipTrait<T> */\n    use RelationshipTrait;\n    use Serializable;\n\n    /** @var IdentifierInterface[] */\n    protected $identifiers = [];\n\n    /**\n     * ToManyRelationship constructor.\n     * @param string $name\n     * @param IdentifierInterface $parent\n     * @param iterable<IdentifierInterface> $identifiers\n     */\n    public function __construct(IdentifierInterface $parent, string $name, array $options, iterable $identifiers = [])\n    {\n        $this->parent = $parent;\n        $this->name = $name;\n\n        $this->parseOptions($options);\n        $this->addIdentifiers($identifiers);\n\n        $this->modified = false;\n    }\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getCardinality(): string\n    {\n        return 'to-many';\n    }\n\n    /**\n     * @return int\n     * @phpstan-pure\n     */\n    public function count(): int\n    {\n        return count($this->identifiers);\n    }\n\n    /**\n     * @return array\n     */\n    public function fetch(): array\n    {\n        $list = [];\n        foreach ($this->identifiers as $identifier) {\n            if (is_callable([$identifier, 'getObject'])) {\n                $identifier = $identifier->getObject();\n            }\n            $list[] = $identifier;\n        }\n\n        return $list;\n    }\n\n    /**\n     * @param string $id\n     * @param string|null $type\n     * @return bool\n     * @phpstan-pure\n     */\n    public function has(string $id, string $type = null): bool\n    {\n        return $this->getIdentifier($id, $type) !== null;\n    }\n\n    /**\n     * @param positive-int $pos\n     * @return IdentifierInterface|null\n     */\n    public function getNthIdentifier(int $pos): ?IdentifierInterface\n    {\n        $items = array_keys($this->identifiers);\n        $key = $items[$pos - 1] ?? null;\n        if (null === $key) {\n            return null;\n        }\n\n        return $this->identifiers[$key] ?? null;\n    }\n\n    /**\n     * @param string $id\n     * @param string|null $type\n     * @return IdentifierInterface|null\n     * @phpstan-pure\n     */\n    public function getIdentifier(string $id, string $type = null): ?IdentifierInterface\n    {\n        if (null === $type) {\n            $type = $this->getType();\n        }\n\n        if ($type === 'media' && !str_contains($id, '/')) {\n            $name = $this->name;\n            $id = $this->parent->getType() . '/' . $this->parent->getId() . '/'. $name . '/' . $id;\n        }\n\n        $key = \"{$type}/{$id}\";\n\n        return $this->identifiers[$key] ?? null;\n    }\n\n    /**\n     * @param string $id\n     * @param string|null $type\n     * @return T|null\n     */\n    public function getObject(string $id, string $type = null): ?object\n    {\n        $identifier = $this->getIdentifier($id, $type);\n        if ($identifier && is_callable([$identifier, 'getObject'])) {\n            $identifier = $identifier->getObject();\n        }\n\n        return $identifier;\n    }\n\n    /**\n     * @param IdentifierInterface $identifier\n     * @return bool\n     */\n    public function addIdentifier(IdentifierInterface $identifier): bool\n    {\n        return $this->addIdentifiers([$identifier]);\n    }\n\n    /**\n     * @param IdentifierInterface|null $identifier\n     * @return bool\n     */\n    public function removeIdentifier(IdentifierInterface $identifier = null): bool\n    {\n        return !$identifier || $this->removeIdentifiers([$identifier]);\n    }\n\n    /**\n     * @param iterable<IdentifierInterface> $identifiers\n     * @return bool\n     */\n    public function addIdentifiers(iterable $identifiers): bool\n    {\n        foreach ($identifiers as $identifier) {\n            $type = $identifier->getType();\n            $id = $identifier->getId();\n            $key = \"{$type}/{$id}\";\n\n            $this->identifiers[$key] = $this->checkIdentifier($identifier);\n            $this->modified = true;\n        }\n\n        return true;\n    }\n\n    /**\n     * @param iterable<IdentifierInterface> $identifiers\n     * @return bool\n     */\n    public function replaceIdentifiers(iterable $identifiers): bool\n    {\n        $this->identifiers = [];\n        $this->modified = true;\n\n        return $this->addIdentifiers($identifiers);\n    }\n\n    /**\n     * @param iterable<IdentifierInterface> $identifiers\n     * @return bool\n     */\n    public function removeIdentifiers(iterable $identifiers): bool\n    {\n        foreach ($identifiers as $identifier) {\n            $type = $identifier->getType();\n            $id = $identifier->getId();\n            $key = \"{$type}/{$id}\";\n\n            unset($this->identifiers[$key]);\n            $this->modified = true;\n        }\n\n        return true;\n    }\n\n    /**\n     * @return iterable<IdentifierInterface>\n     * @phpstan-pure\n     */\n    public function getIterator(): iterable\n    {\n        return new ArrayIterator($this->identifiers);\n    }\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        $list = [];\n        foreach ($this->getIterator() as $item) {\n            $list[] = $item->jsonSerialize();\n        }\n\n        return $list;\n    }\n\n    /**\n     * @return array\n     */\n    public function __serialize(): array\n    {\n        return [\n            'parent' => $this->parent,\n            'name' => $this->name,\n            'type' => $this->type,\n            'options' => $this->options,\n            'modified' => $this->modified,\n            'identifiers' => $this->identifiers,\n        ];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    public function __unserialize(array $data): void\n    {\n        $this->parent = $data['parent'];\n        $this->name = $data['name'];\n        $this->type = $data['type'];\n        $this->options = $data['options'];\n        $this->modified = $data['modified'];\n        $this->identifiers = $data['identifiers'];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Relationships/ToOneRelationship.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Relationships;\n\nuse ArrayIterator;\nuse Grav\\Framework\\Compat\\Serializable;\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\nuse Grav\\Framework\\Contracts\\Relationships\\ToOneRelationshipInterface;\nuse Grav\\Framework\\Relationships\\Traits\\RelationshipTrait;\nuse function is_callable;\n\n/**\n * Class ToOneRelationship\n *\n * @template T of IdentifierInterface\n * @template P of IdentifierInterface\n * @template-implements ToOneRelationshipInterface<T,P>\n */\nclass ToOneRelationship implements ToOneRelationshipInterface\n{\n    /** @template-use RelationshipTrait<T> */\n    use RelationshipTrait;\n    use Serializable;\n\n    /** @var IdentifierInterface|null */\n    protected $identifier = null;\n\n    public function __construct(IdentifierInterface $parent, string $name, array $options, IdentifierInterface $identifier = null)\n    {\n        $this->parent = $parent;\n        $this->name = $name;\n\n        $this->parseOptions($options);\n        $this->replaceIdentifier($identifier);\n\n        $this->modified = false;\n    }\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getCardinality(): string\n    {\n        return 'to-one';\n    }\n\n    /**\n     * @return int\n     * @phpstan-pure\n     */\n    public function count(): int\n    {\n        return $this->identifier ? 1 : 0;\n    }\n\n    /**\n     * @return object|null\n     */\n    public function fetch(): ?object\n    {\n        $identifier = $this->identifier;\n        if (is_callable([$identifier, 'getObject'])) {\n            $identifier = $identifier->getObject();\n        }\n\n        return $identifier;\n    }\n\n\n    /**\n     * @param string|null $id\n     * @param string|null $type\n     * @return bool\n     * @phpstan-pure\n     */\n    public function has(string $id = null, string $type = null): bool\n    {\n        return $this->getIdentifier($id, $type) !== null;\n    }\n\n    /**\n     * @param string|null $id\n     * @param string|null $type\n     * @return IdentifierInterface|null\n     * @phpstan-pure\n     */\n    public function getIdentifier(string $id = null, string $type = null): ?IdentifierInterface\n    {\n        if ($id && $this->getType() === 'media' && !str_contains($id, '/')) {\n            $name = $this->name;\n            $id = $this->parent->getType() . '/' . $this->parent->getId() . '/'. $name . '/' . $id;\n        }\n\n        $identifier = $this->identifier ?? null;\n        if (null === $identifier || ($type && $type !== $identifier->getType()) || ($id && $id !== $identifier->getId())) {\n            return null;\n        }\n\n        return $identifier;\n    }\n\n    /**\n     * @param string|null $id\n     * @param string|null $type\n     * @return T|null\n     */\n    public function getObject(string $id = null, string $type = null): ?object\n    {\n        $identifier = $this->getIdentifier($id, $type);\n        if ($identifier && is_callable([$identifier, 'getObject'])) {\n            $identifier = $identifier->getObject();\n        }\n\n        return $identifier;\n    }\n\n    /**\n     * @param IdentifierInterface $identifier\n     * @return bool\n     */\n    public function addIdentifier(IdentifierInterface $identifier): bool\n    {\n        $this->identifier = $this->checkIdentifier($identifier);\n        $this->modified = true;\n\n        return true;\n    }\n\n    /**\n     * @param IdentifierInterface|null $identifier\n     * @return bool\n     */\n    public function replaceIdentifier(IdentifierInterface $identifier = null): bool\n    {\n        if ($identifier === null) {\n            $this->identifier = null;\n            $this->modified = true;\n\n            return true;\n        }\n\n        return $this->addIdentifier($identifier);\n    }\n\n    /**\n     * @param IdentifierInterface|null $identifier\n     * @return bool\n     */\n    public function removeIdentifier(IdentifierInterface $identifier = null): bool\n    {\n        if (null === $identifier || $this->has($identifier->getId(), $identifier->getType())) {\n            $this->identifier = null;\n            $this->modified = true;\n\n            return true;\n        }\n\n        return false;\n    }\n\n    /**\n     * @return iterable<IdentifierInterface>\n     * @phpstan-pure\n     */\n    public function getIterator(): iterable\n    {\n        return new ArrayIterator((array)$this->identifier);\n    }\n\n    /**\n     * @return array|null\n     */\n    public function jsonSerialize(): ?array\n    {\n        return $this->identifier ? $this->identifier->jsonSerialize() : null;\n    }\n\n    /**\n     * @return array\n     */\n    public function __serialize(): array\n    {\n        return [\n            'parent' => $this->parent,\n            'name' => $this->name,\n            'type' => $this->type,\n            'options' => $this->options,\n            'modified' => $this->modified,\n            'identifier' => $this->identifier,\n        ];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    public function __unserialize(array $data): void\n    {\n        $this->parent = $data['parent'];\n        $this->name = $data['name'];\n        $this->type = $data['type'];\n        $this->options = $data['options'];\n        $this->modified = $data['modified'];\n        $this->identifier = $data['identifier'];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Relationships/Traits/RelationshipTrait.php",
    "content": "<?php declare(strict_types=1);\n\nnamespace Grav\\Framework\\Relationships\\Traits;\n\nuse Grav\\Framework\\Contracts\\Object\\IdentifierInterface;\nuse Grav\\Framework\\Flex\\FlexIdentifier;\nuse Grav\\Framework\\Media\\MediaIdentifier;\nuse Grav\\Framework\\Object\\Identifiers\\Identifier;\nuse RuntimeException;\nuse function get_class;\n\n/**\n * Trait RelationshipTrait\n *\n * @template T of object\n */\ntrait RelationshipTrait\n{\n    /** @var IdentifierInterface */\n    protected $parent;\n    /** @var string */\n    protected $name;\n    /** @var string */\n    protected $type;\n    /** @var array */\n    protected $options;\n    /** @var bool */\n    protected $modified = false;\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getName(): string\n    {\n        return $this->name;\n    }\n\n    /**\n     * @return string\n     * @phpstan-pure\n     */\n    public function getType(): string\n    {\n        return $this->type;\n    }\n\n    /**\n     * @return bool\n     * @phpstan-pure\n     */\n    public function isModified(): bool\n    {\n        return $this->modified;\n    }\n\n    /**\n     * @return IdentifierInterface\n     * @phpstan-pure\n     */\n    public function getParent(): IdentifierInterface\n    {\n        return $this->parent;\n    }\n\n    /**\n     * @param IdentifierInterface $identifier\n     * @return bool\n     * @phpstan-pure\n     */\n    public function hasIdentifier(IdentifierInterface $identifier): bool\n    {\n        return $this->getIdentifier($identifier->getId(), $identifier->getType()) !== null;\n    }\n\n    /**\n     * @return int\n     * @phpstan-pure\n     */\n    abstract public function count(): int;\n\n    /**\n     * @return void\n     * @phpstan-pure\n     */\n    public function check(): void\n    {\n        $min = $this->options['min'] ?? 0;\n        $max = $this->options['max'] ?? 0;\n\n        if ($min || $max) {\n            $count = $this->count();\n            if ($min && $count < $min) {\n                throw new RuntimeException(sprintf('%s relationship has too few objects in it', $this->name));\n            }\n            if ($max && $count > $max) {\n                throw new RuntimeException(sprintf('%s relationship has too many objects in it', $this->name));\n            }\n        }\n    }\n\n    /**\n     * @param IdentifierInterface $identifier\n     * @return IdentifierInterface\n     */\n    private function checkIdentifier(IdentifierInterface $identifier): IdentifierInterface\n    {\n        if ($this->type !== $identifier->getType()) {\n            throw new RuntimeException(sprintf('Bad identifier type %s', $identifier->getType()));\n        }\n\n        if (get_class($identifier) !== Identifier::class) {\n            return $identifier;\n        }\n\n        if ($this->type === 'media') {\n            return new MediaIdentifier($identifier->getId());\n        }\n\n        return new FlexIdentifier($identifier->getId(), $identifier->getType());\n    }\n\n    private function parseOptions(array $options): void\n    {\n        $this->type = $options['type'];\n        $this->options = $options;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/Exception/InvalidArgumentException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\RequestHandler\\Exception;\n\nuse Throwable;\n\n/**\n * Class InvalidArgumentException\n * @package Grav\\Framework\\RequestHandler\\Exception\n */\nclass InvalidArgumentException extends \\InvalidArgumentException\n{\n    /** @var mixed|null */\n    private $invalidMiddleware;\n\n    /**\n     * InvalidArgumentException constructor.\n     *\n     * @param string $message\n     * @param mixed|null $invalidMiddleware\n     * @param int $code\n     * @param Throwable|null $previous\n     */\n    public function __construct($message = '', $invalidMiddleware = null, $code = 0, Throwable $previous = null)\n    {\n        parent::__construct($message, $code, $previous);\n\n        $this->invalidMiddleware = $invalidMiddleware;\n    }\n\n    /**\n     * Return the invalid middleware\n     *\n     * @return mixed|null\n     */\n    public function getInvalidMiddleware()\n    {\n        return $this->invalidMiddleware;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/Exception/NotFoundException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\RequestHandler\\Exception;\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Throwable;\nuse function in_array;\n\n/**\n * Class NotFoundException\n * @package Grav\\Framework\\RequestHandler\\Exception\n */\nclass NotFoundException extends RequestException\n{\n    /**\n     * NotFoundException constructor.\n     * @param ServerRequestInterface $request\n     * @param Throwable|null $previous\n     */\n    public function __construct(ServerRequestInterface $request, Throwable $previous = null)\n    {\n        if (in_array(strtoupper($request->getMethod()), ['PUT', 'PATCH', 'DELETE'])) {\n            parent::__construct($request, 'Method Not Allowed', 405, $previous);\n        } else {\n            parent::__construct($request, 'Not Found', 404, $previous);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/Exception/NotHandledException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\RequestHandler\\Exception;\n\n/**\n * Class NotHandledException\n * @package Grav\\Framework\\RequestHandler\\Exception\n */\nclass NotHandledException extends NotFoundException\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/Exception/PageExpiredException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\RequestHandler\\Exception;\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Throwable;\n\n/**\n * Class PageExpiredException\n * @package Grav\\Framework\\RequestHandler\\Exception\n */\nclass PageExpiredException extends RequestException\n{\n    /**\n     * PageExpiredException constructor.\n     * @param ServerRequestInterface $request\n     * @param Throwable|null $previous\n     */\n    public function __construct(ServerRequestInterface $request, Throwable $previous = null)\n    {\n        parent::__construct($request, 'Page Expired', 400, $previous); // 419\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/Exception/RequestException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\RequestHandler\\Exception;\n\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Throwable;\n\n/**\n * Class RequestException\n * @package Grav\\Framework\\RequestHandler\\Exception\n */\nclass RequestException extends \\RuntimeException\n{\n    /** @var array Map of standard HTTP status code/reason phrases */\n    private static $phrases = [\n        400 => 'Bad Request',\n        401 => 'Unauthorized',\n        402 => 'Payment Required',\n        403 => 'Forbidden',\n        404 => 'Not Found',\n        405 => 'Method Not Allowed',\n        406 => 'Not Acceptable',\n        407 => 'Proxy Authentication Required',\n        408 => 'Request Time-out',\n        409 => 'Conflict',\n        410 => 'Gone',\n        411 => 'Length Required',\n        412 => 'Precondition Failed',\n        413 => 'Request Entity Too Large',\n        414 => 'Request-URI Too Large',\n        415 => 'Unsupported Media Type',\n        416 => 'Requested range not satisfiable',\n        417 => 'Expectation Failed',\n        418 => 'I\\'m a teapot',\n        419 => 'Page Expired',\n        422 => 'Unprocessable Entity',\n        423 => 'Locked',\n        424 => 'Failed Dependency',\n        425 => 'Unordered Collection',\n        426 => 'Upgrade Required',\n        428 => 'Precondition Required',\n        429 => 'Too Many Requests',\n        431 => 'Request Header Fields Too Large',\n        451 => 'Unavailable For Legal Reasons',\n\n        500 => 'Internal Server Error',\n        501 => 'Not Implemented',\n        502 => 'Bad Gateway',\n        503 => 'Service Unavailable',\n        504 => 'Gateway Time-out',\n        505 => 'HTTP Version not supported',\n        506 => 'Variant Also Negotiates',\n        507 => 'Insufficient Storage',\n        508 => 'Loop Detected',\n        511 => 'Network Authentication Required',\n    ];\n\n    /** @var ServerRequestInterface */\n    private $request;\n\n    /**\n     * @param ServerRequestInterface $request\n     * @param string $message\n     * @param int $code\n     * @param Throwable|null $previous\n     */\n    public function __construct(ServerRequestInterface $request, string $message, int $code = 500, Throwable $previous = null)\n    {\n        $this->request = $request;\n\n        parent::__construct($message, $code, $previous);\n    }\n\n    /**\n     * @return ServerRequestInterface\n     */\n    public function getRequest(): ServerRequestInterface\n    {\n        return $this->request;\n    }\n\n    public function getHttpCode(): int\n    {\n        $code = $this->getCode();\n\n        return isset(self::$phrases[$code]) ? $code : 500;\n    }\n\n    public function getHttpReason(): ?string\n    {\n        return self::$phrases[$this->getCode()] ?? self::$phrases[500];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/Middlewares/Exceptions.php",
    "content": "<?php declare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\RequestHandler\\Middlewares;\n\nuse Grav\\Common\\Data\\ValidationException;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Framework\\Psr7\\Response;\nuse JsonException;\nuse JsonSerializable;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\MiddlewareInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\nuse Throwable;\nuse function get_class;\n\n/**\n * Class Exceptions\n * @package Grav\\Framework\\RequestHandler\\Middlewares\n */\nclass Exceptions implements MiddlewareInterface\n{\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     * @throws JsonException\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        try {\n            return $handler->handle($request);\n        } catch (Throwable $exception) {\n            $code = $exception->getCode();\n            if ($exception instanceof ValidationException) {\n                $message = $exception->getMessage();\n            } else {\n                $message = htmlspecialchars($exception->getMessage(), ENT_QUOTES | ENT_HTML5, 'UTF-8');\n            }\n\n            $extra = $exception instanceof JsonSerializable ? $exception->jsonSerialize() : [];\n\n            $response = [\n                'code' => $code,\n                'status' => 'error',\n                'message' => $message,\n                'error' => [\n                    'code' => $code,\n                    'message' => $message,\n                ] + $extra\n            ];\n\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            if ($debugger->enabled()) {\n                $response['error'] += [\n                    'type' => get_class($exception),\n                    'file' => $exception->getFile(),\n                    'line' => $exception->getLine(),\n                    'trace' => explode(\"\\n\", $exception->getTraceAsString()),\n                ];\n            }\n\n            /** @var string $json */\n            $json = json_encode($response, JSON_THROW_ON_ERROR);\n\n            return new Response($code ?: 500, ['Content-Type' => 'application/json'], $json);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/Middlewares/MultipartRequestSupport.php",
    "content": "<?php declare(strict_types=1);\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\RequestHandler\\Middlewares;\n\nuse Grav\\Framework\\Psr7\\UploadedFile;\nuse Nyholm\\Psr7\\Stream;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Message\\UploadedFileInterface;\nuse Psr\\Http\\Server\\MiddlewareInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\nuse function array_slice;\nuse function count;\nuse function in_array;\nuse function is_array;\nuse function strlen;\n\n/**\n * Multipart request support for PUT and PATCH.\n */\nclass MultipartRequestSupport implements MiddlewareInterface\n{\n    /**\n     * @param ServerRequestInterface $request\n     * @param RequestHandlerInterface $handler\n     * @return ResponseInterface\n     */\n    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface\n    {\n        $contentType = $request->getHeaderLine('content-type');\n        $method = $request->getMethod();\n        if (!str_starts_with($contentType, 'multipart/form-data') || !in_array($method, ['PUT', 'PATH'], true)) {\n            return $handler->handle($request);\n        }\n\n        $boundary = explode('; boundary=', $contentType, 2)[1] ?? '';\n        $parts = explode(\"--{$boundary}\", $request->getBody()->getContents());\n        $parts = array_slice($parts, 1, count($parts) - 2);\n\n        $params = [];\n        $files = [];\n        foreach ($parts as $part) {\n            $this->processPart($params, $files, $part);\n        }\n\n        return $handler->handle($request->withParsedBody($params)->withUploadedFiles($files));\n    }\n\n    /**\n     * @param array $params\n     * @param array $files\n     * @param string $part\n     * @return void\n     */\n    protected function processPart(array &$params, array &$files, string $part): void\n    {\n        $part = ltrim($part, \"\\r\\n\");\n        [$rawHeaders, $body] = explode(\"\\r\\n\\r\\n\", $part, 2);\n\n        // Parse headers.\n        $rawHeaders = explode(\"\\r\\n\", $rawHeaders);\n        $headers = array_reduce(\n            $rawHeaders,\n            static function (array $headers, $header) {\n                [$name, $value] = explode(':', $header);\n                $headers[strtolower($name)] = ltrim($value, ' ');\n\n                return $headers;\n            },\n            []\n        );\n\n        if (!isset($headers['content-disposition'])) {\n            return;\n        }\n\n        // Parse content disposition header.\n        $contentDisposition = $headers['content-disposition'];\n        preg_match('/^(.+); *name=\"([^\"]+)\"(; *filename=\"([^\"]+)\")?/', $contentDisposition, $matches);\n        $name = $matches[2];\n        $filename = $matches[4] ?? null;\n\n        if ($filename !== null) {\n            $stream = Stream::create($body);\n            $this->addFile($files, $name, new UploadedFile($stream, strlen($body), UPLOAD_ERR_OK, $filename, $headers['content-type'] ?? null));\n        } elseif (strpos($contentDisposition, 'filename') !== false) {\n            // Not uploaded file.\n             $stream = Stream::create('');\n            $this->addFile($files, $name, new UploadedFile($stream, 0, UPLOAD_ERR_NO_FILE));\n        } else {\n            // Regular field.\n            $params[$name] = substr($body, 0, -2);\n        }\n    }\n\n    /**\n     * @param array $files\n     * @param string $name\n     * @param UploadedFileInterface $file\n     * @return void\n     */\n    protected function addFile(array &$files, string $name, UploadedFileInterface $file): void\n    {\n        if (strpos($name, '[]') === strlen($name) - 2) {\n            $name = substr($name, 0, -2);\n\n            if (isset($files[$name]) && is_array($files[$name])) {\n                $files[$name][] = $file;\n            } else {\n                $files[$name] = [$file];\n            }\n        } else {\n            $files[$name] = $file;\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/RequestHandler.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\RequestHandler;\n\nuse Grav\\Framework\\RequestHandler\\Traits\\RequestHandlerTrait;\nuse Pimple\\Container;\nuse Psr\\Container\\ContainerInterface;\nuse Psr\\Http\\Server\\MiddlewareInterface;\nuse Psr\\Http\\Server\\RequestHandlerInterface;\nuse function assert;\n\n/**\n * Class RequestHandler\n * @package Grav\\Framework\\RequestHandler\n */\nclass RequestHandler implements RequestHandlerInterface\n{\n    use RequestHandlerTrait;\n\n    /**\n     * Delegate constructor.\n     *\n     * @param array $middleware\n     * @param callable $default\n     * @param ContainerInterface|null $container\n     */\n    public function __construct(array $middleware, callable $default, ContainerInterface $container = null)\n    {\n        $this->middleware = $middleware;\n        $this->handler = $default;\n        $this->container = $container;\n    }\n\n    /**\n     * Add callable initializing Middleware that will be executed as soon as possible.\n     *\n     * @param string $name\n     * @param callable $callable\n     * @return $this\n     */\n    public function addCallable(string $name, callable $callable): self\n    {\n        if (null !== $this->container) {\n            assert($this->container instanceof Container);\n            $this->container[$name] = $callable;\n        }\n\n        array_unshift($this->middleware, $name);\n\n        return $this;\n    }\n\n    /**\n     * Add Middleware that will be executed as soon as possible.\n     *\n     * @param string $name\n     * @param MiddlewareInterface $middleware\n     * @return $this\n     */\n    public function addMiddleware(string $name, MiddlewareInterface $middleware): self\n    {\n        if (null !== $this->container) {\n            assert($this->container instanceof Container);\n            $this->container[$name] = $middleware;\n        }\n\n        array_unshift($this->middleware, $name);\n\n        return $this;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/RequestHandler/Traits/RequestHandlerTrait.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\RequestHandler\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\ndeclare(strict_types=1);\n\nnamespace Grav\\Framework\\RequestHandler\\Traits;\n\nuse Grav\\Framework\\RequestHandler\\Exception\\InvalidArgumentException;\nuse Psr\\Container\\ContainerInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Http\\Message\\ServerRequestInterface;\nuse Psr\\Http\\Server\\MiddlewareInterface;\nuse function call_user_func;\n\n/**\n * Trait RequestHandlerTrait\n * @package Grav\\Framework\\RequestHandler\\Traits\n */\ntrait RequestHandlerTrait\n{\n    /** @var array<int,string|MiddlewareInterface> */\n    protected $middleware;\n\n    /** @var callable */\n    protected $handler;\n\n    /** @var ContainerInterface|null */\n    protected $container;\n\n    /**\n     * {@inheritdoc}\n     * @throws InvalidArgumentException\n     */\n    public function handle(ServerRequestInterface $request): ResponseInterface\n    {\n        $middleware = array_shift($this->middleware);\n\n        // Use default callable if there is no middleware.\n        if ($middleware === null) {\n            return call_user_func($this->handler, $request);\n        }\n\n        if ($middleware instanceof MiddlewareInterface) {\n            return $middleware->process($request, clone $this);\n        }\n\n        if (null === $this->container || !$this->container->has($middleware)) {\n            throw new InvalidArgumentException(\n                sprintf('The middleware is not a valid %s and is not passed in the Container', MiddlewareInterface::class),\n                $middleware\n            );\n        }\n\n        array_unshift($this->middleware, $this->container->get($middleware));\n\n        return $this->handle($request);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Route/Route.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Route\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Route;\n\nuse Grav\\Framework\\Uri\\Uri;\nuse Grav\\Framework\\Uri\\UriFactory;\nuse InvalidArgumentException;\nuse function array_slice;\n\n/**\n * Implements Grav Route.\n *\n * @package Grav\\Framework\\Route\n */\nclass Route\n{\n    /** @var string */\n    private $root = '';\n    /** @var string */\n    private $language = '';\n    /** @var string */\n    private $route = '';\n    /** @var string */\n    private $extension = '';\n    /** @var array */\n    private $gravParams = [];\n    /** @var array */\n    private $queryParams = [];\n\n    /**\n     * You can use `RouteFactory` functions to create new `Route` objects.\n     *\n     * @param array $parts\n     * @throws InvalidArgumentException\n     */\n    public function __construct(array $parts = [])\n    {\n        $this->initParts($parts);\n    }\n\n    /**\n     * @return array\n     */\n    public function getParts()\n    {\n        return [\n            'path' => $this->getUriPath(true),\n            'query' => $this->getUriQuery(),\n            'grav' => [\n                'root' => $this->root,\n                'language' => $this->language,\n                'route' => $this->route,\n                'extension' => $this->extension,\n                'grav_params' => $this->gravParams,\n                'query_params' => $this->queryParams,\n            ],\n        ];\n    }\n\n    /**\n     * @return string\n     */\n    public function getRootPrefix()\n    {\n        return $this->root;\n    }\n\n    /**\n     * @return string\n     */\n    public function getLanguage()\n    {\n        return $this->language;\n    }\n\n    /**\n     * @return string\n     */\n    public function getLanguagePrefix()\n    {\n        return $this->language !== '' ? '/' . $this->language : '';\n    }\n\n    /**\n     * @param string|null $language\n     * @return string\n     */\n    public function getBase(string $language = null): string\n    {\n        $parts = [$this->root];\n\n        if (null === $language) {\n            $language = $this->language;\n        }\n\n        if ($language !== '') {\n            $parts[] = $language;\n        }\n\n        return implode('/', $parts);\n    }\n\n    /**\n     * @param int $offset\n     * @param int|null $length\n     * @return string\n     */\n    public function getRoute($offset = 0, $length = null)\n    {\n        if ($offset !== 0 || $length !== null) {\n            return ($offset === 0 ? '/' : '') . implode('/', $this->getRouteParts($offset, $length));\n        }\n\n        return '/' . $this->route;\n    }\n\n    /**\n     * @return string\n     */\n    public function getExtension()\n    {\n        return $this->extension;\n    }\n\n    /**\n     * @param int $offset\n     * @param int|null $length\n     * @return array\n     */\n    public function getRouteParts($offset = 0, $length = null)\n    {\n        $parts = explode('/', $this->route);\n\n        if ($offset !== 0 || $length !== null) {\n            $parts = array_slice($parts, $offset, $length);\n        }\n\n        return $parts;\n    }\n\n    /**\n     * Return array of both query and Grav parameters.\n     *\n     * If a parameter exists in both, prefer Grav parameter.\n     *\n     * @return array\n     */\n    public function getParams()\n    {\n        return $this->gravParams + $this->queryParams;\n    }\n\n    /**\n     * @return array\n     */\n    public function getGravParams()\n    {\n        return $this->gravParams;\n    }\n\n    /**\n     * @return array\n     */\n    public function getQueryParams()\n    {\n        return $this->queryParams;\n    }\n\n    /**\n     * Return value of the parameter, looking into both Grav parameters and query parameters.\n     *\n     * If the parameter exists in both, return Grav parameter.\n     *\n     * @param string $param\n     * @return string|array|null\n     */\n    public function getParam($param)\n    {\n        return $this->getGravParam($param) ?? $this->getQueryParam($param);\n    }\n\n    /**\n     * @param string $param\n     * @return string|null\n     */\n    public function getGravParam($param)\n    {\n        return $this->gravParams[$param] ?? null;\n    }\n\n    /**\n     * @param string $param\n     * @return string|array|null\n     */\n    public function getQueryParam($param)\n    {\n        return $this->queryParams[$param] ?? null;\n    }\n\n    /**\n     * Allow the ability to set the route to something else\n     *\n     * @param string $route\n     * @return Route\n     */\n    public function withRoute($route)\n    {\n        $new = $this->copy();\n        $new->route = $route;\n\n        return $new;\n    }\n\n    /**\n     * Allow the ability to set the root to something else\n     *\n     * @param string $root\n     * @return Route\n     */\n    public function withRoot($root)\n    {\n        $new = $this->copy();\n        $new->root = $root;\n\n        return $new;\n    }\n\n    /**\n     * @param string|null $language\n     * @return Route\n     */\n    public function withLanguage($language)\n    {\n        $new = $this->copy();\n        $new->language = $language ?? '';\n\n        return $new;\n    }\n\n    /**\n     * @param string $path\n     * @return Route\n     */\n    public function withAddedPath($path)\n    {\n        $new = $this->copy();\n        $new->route .= '/' . ltrim($path, '/');\n\n        return $new;\n    }\n\n    /**\n     * @param string $extension\n     * @return Route\n     */\n    public function withExtension($extension)\n    {\n        $new = $this->copy();\n        $new->extension = $extension;\n\n        return $new;\n    }\n\n    /**\n     * @param string $param\n     * @param mixed $value\n     * @return Route\n     */\n    public function withGravParam($param, $value)\n    {\n        return $this->withParam('gravParams', $param, null !== $value ? (string)$value : null);\n    }\n\n    /**\n     * @param string $param\n     * @param mixed $value\n     * @return Route\n     */\n    public function withQueryParam($param, $value)\n    {\n        return $this->withParam('queryParams', $param, $value);\n    }\n\n    /**\n     * @return Route\n     */\n    public function withoutParams()\n    {\n        return $this->withoutGravParams()->withoutQueryParams();\n    }\n\n    /**\n     * @return Route\n     */\n    public function withoutGravParams()\n    {\n        $new = $this->copy();\n        $new->gravParams = [];\n\n        return $new;\n    }\n\n    /**\n     * @return Route\n     */\n    public function withoutQueryParams()\n    {\n        $new = $this->copy();\n        $new->queryParams = [];\n\n        return $new;\n    }\n\n    /**\n     * @return Uri\n     */\n    public function getUri()\n    {\n        return UriFactory::createFromParts($this->getParts());\n    }\n\n    /**\n     * @param bool $includeRoot\n     * @return string\n     */\n    public function toString(bool $includeRoot = false)\n    {\n        $url = $this->getUriPath($includeRoot);\n\n        if ($this->queryParams) {\n            $url .= '?' . $this->getUriQuery();\n        }\n\n        return rtrim($url,'/');\n    }\n\n    /**\n     * @return string\n     * @deprecated 1.6 Use ->toString(true) or ->getUri() instead.\n     */\n    #[\\ReturnTypeWillChange]\n    public function __toString()\n    {\n        user_error(__CLASS__ . '::' . __FUNCTION__ . '() will change in the future to return route, not relative url: use ->toString(true) or ->getUri() instead.', E_USER_DEPRECATED);\n\n        return $this->toString(true);\n    }\n\n    /**\n     * @param string $type\n     * @param string $param\n     * @param mixed $value\n     * @return Route\n     */\n    protected function withParam($type, $param, $value)\n    {\n        $values = $this->{$type} ?? [];\n        $oldValue = $values[$param] ?? null;\n\n        if ($oldValue === $value) {\n            return $this;\n        }\n\n        $new = $this->copy();\n        if ($value === null) {\n            unset($values[$param]);\n        } else {\n            $values[$param] = $value;\n        }\n\n        $new->{$type} = $values;\n\n        return $new;\n    }\n\n    /**\n     * @return Route\n     */\n    protected function copy()\n    {\n        return clone $this;\n    }\n\n    /**\n     * @param bool $includeRoot\n     * @return string\n     */\n    protected function getUriPath($includeRoot = false)\n    {\n        $parts = $includeRoot ? [$this->root] : [''];\n\n        if ($this->language !== '') {\n            $parts[] = $this->language;\n        }\n\n        $parts[] = $this->extension ? $this->route . '.' . $this->extension : $this->route;\n\n\n        if ($this->gravParams) {\n            $parts[] = RouteFactory::buildParams($this->gravParams);\n        }\n\n        return implode('/', $parts);\n    }\n\n    /**\n     * @return string\n     */\n    protected function getUriQuery()\n    {\n        return UriFactory::buildQuery($this->queryParams);\n    }\n\n    /**\n     * @param array $parts\n     * @return void\n     */\n    protected function initParts(array $parts)\n    {\n        if (isset($parts['grav'])) {\n            $gravParts = $parts['grav'];\n            $this->root = $gravParts['root'];\n            $this->language = $gravParts['language'];\n            $this->route = $gravParts['route'];\n            $this->extension = $gravParts['extension'] ?? '';\n            $this->gravParams = $gravParts['params'] ?? [];\n            $this->queryParams = $parts['query_params'] ?? [];\n        } else {\n            $this->root = RouteFactory::getRoot();\n            $this->language = RouteFactory::getLanguage();\n\n            $path = $parts['path'] ?? '/';\n            if (isset($parts['params'])) {\n                $this->route = trim(rawurldecode($path), '/');\n                $this->gravParams = $parts['params'];\n            } else {\n                $this->route = trim(RouteFactory::stripParams($path, true), '/');\n                $this->gravParams = RouteFactory::getParams($path);\n            }\n            if (isset($parts['query'])) {\n                $this->queryParams = UriFactory::parseQuery($parts['query']);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Route/RouteFactory.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Route\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Route;\n\nuse Grav\\Common\\Uri;\nuse function dirname;\nuse function strlen;\n\n/**\n * Class RouteFactory\n * @package Grav\\Framework\\Route\n */\nclass RouteFactory\n{\n    /** @var string */\n    private static $root = '';\n    /** @var string */\n    private static $language = '';\n    /** @var string */\n    private static $delimiter = ':';\n\n    /**\n     * @param array $parts\n     * @return Route\n     */\n    public static function createFromParts(array $parts): Route\n    {\n        return new Route($parts);\n    }\n\n    /**\n     * @param Uri $uri\n     * @return Route\n     */\n    public static function createFromLegacyUri(Uri $uri): Route\n    {\n        $parts = $uri->toArray();\n        $parts += [\n            'grav' => []\n        ];\n        $path = $parts['path'] ?? '';\n        $parts['grav'] += [\n            'root' => self::$root,\n            'language' => self::$language,\n            'route' => trim($path, '/'),\n            'params' => $parts['params'] ?? [],\n        ];\n\n        return static::createFromParts($parts);\n    }\n\n    /**\n     * @param string $path\n     * @return Route\n     */\n    public static function createFromString(string $path): Route\n    {\n        $path = ltrim($path, '/');\n        if (self::$language && mb_strpos($path, self::$language) === 0) {\n            $path = ltrim(mb_substr($path, mb_strlen(self::$language)), '/');\n        }\n\n        $parts = [\n            'path' => $path,\n            'query' => '',\n            'query_params' => [],\n            'grav' => [\n                'root' => self::$root,\n                'language' => self::$language,\n                'route' => static::trimParams($path),\n                'params' => static::getParams($path)\n            ],\n        ];\n\n        return new Route($parts);\n    }\n\n    /**\n     * @return string\n     */\n    public static function getRoot(): string\n    {\n        return self::$root;\n    }\n\n    /**\n     * @param string $root\n     */\n    public static function setRoot($root): void\n    {\n        self::$root = rtrim($root, '/');\n    }\n\n    /**\n     * @return string\n     */\n    public static function getLanguage(): string\n    {\n        return self::$language;\n    }\n\n    /**\n     * @param string $language\n     */\n    public static function setLanguage(string $language): void\n    {\n        self::$language = trim($language, '/');\n    }\n\n    /**\n     * @return string\n     */\n    public static function getParamValueDelimiter(): string\n    {\n        return self::$delimiter;\n    }\n\n    /**\n     * @param string $delimiter\n     */\n    public static function setParamValueDelimiter(string $delimiter): void\n    {\n        self::$delimiter = $delimiter ?: ':';\n    }\n\n    /**\n     * @param array $params\n     * @return string\n     */\n    public static function buildParams(array $params): string\n    {\n        if (!$params) {\n            return '';\n        }\n\n        $delimiter = self::$delimiter;\n\n        $output = [];\n        foreach ($params as $key => $value) {\n            $output[] = \"{$key}{$delimiter}{$value}\";\n        }\n\n        return implode('/', $output);\n    }\n\n    /**\n     * @param string $path\n     * @param bool $decode\n     * @return string\n     */\n    public static function stripParams(string $path, bool $decode = false): string\n    {\n        $pos = strpos($path, self::$delimiter);\n\n        if ($pos === false) {\n            return $path;\n        }\n\n        $path = dirname(substr($path, 0, $pos));\n        if ($path === '.') {\n            return '';\n        }\n\n        return $decode ? rawurldecode($path) : $path;\n    }\n\n    /**\n     * @param string $path\n     * @return array\n     */\n    public static function getParams(string $path): array\n    {\n        $params = ltrim(substr($path, strlen(static::stripParams($path))), '/');\n\n        return $params !== '' ? static::parseParams($params) : [];\n    }\n\n    /**\n     * @param string $str\n     * @return string\n     */\n    public static function trimParams(string $str): string\n    {\n        if ($str === '') {\n            return $str;\n        }\n\n        $delimiter = self::$delimiter;\n\n        /** @var array $params */\n        $params = explode('/', $str);\n        $list = [];\n        foreach ($params as $param) {\n            if (mb_strpos($param, $delimiter) === false) {\n                $list[] = $param;\n            }\n        }\n\n        return implode('/', $list);\n    }\n\n    /**\n     * @param string $str\n     * @return array\n     */\n    public static function parseParams(string $str): array\n    {\n        if ($str === '') {\n            return [];\n        }\n\n        $delimiter = self::$delimiter;\n\n        /** @var array $params */\n        $params = explode('/', $str);\n        $list = [];\n        foreach ($params as &$param) {\n            /** @var array $parts */\n            $parts = explode($delimiter, $param, 2);\n            if (isset($parts[1])) {\n                $var = rawurldecode($parts[0]);\n                $val = rawurldecode($parts[1]);\n                $list[$var] = $val;\n            }\n        }\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Session/Exceptions/SessionException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Session\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Session\\Exceptions;\n\nuse RuntimeException;\n\n/**\n * Class SessionException\n * @package Grav\\Framework\\Session\\Exceptions\n */\nclass SessionException extends RuntimeException\n{\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Session/Messages.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Session\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Session;\n\nuse Grav\\Framework\\Compat\\Serializable;\nuse function array_key_exists;\n\n/**\n * Implements session messages.\n */\nclass Messages implements \\Serializable\n{\n    use Serializable;\n\n    /** @var array */\n    protected $messages = [];\n    /** @var bool */\n    protected $isCleared = false;\n\n    /**\n     * Add message to the queue.\n     *\n     * @param string $message\n     * @param string $scope\n     * @return $this\n     */\n    public function add(string $message, string $scope = 'default'): Messages\n    {\n        $key = md5($scope . '~' . $message);\n        $item = ['message' => $message, 'scope' => $scope];\n\n        // don't add duplicates\n        if (!array_key_exists($key, $this->messages)) {\n            $this->messages[$key] = $item;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Clear message queue.\n     *\n     * @param string|null $scope\n     * @return $this\n     */\n    public function clear(string $scope = null): Messages\n    {\n        if ($scope === null) {\n            if ($this->messages !== []) {\n                $this->isCleared = true;\n                $this->messages = [];\n            }\n        } else {\n            foreach ($this->messages as $key => $message) {\n                if ($message['scope'] === $scope) {\n                    $this->isCleared = true;\n                    unset($this->messages[$key]);\n                }\n            }\n        }\n\n        return $this;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isCleared(): bool\n    {\n        return $this->isCleared;\n    }\n\n    /**\n     * Fetch all messages.\n     *\n     * @param string|null $scope\n     * @return array\n     */\n    public function all(string $scope = null): array\n    {\n        if ($scope === null) {\n            return array_values($this->messages);\n        }\n\n        $messages = [];\n        foreach ($this->messages as $message) {\n            if ($message['scope'] === $scope) {\n                $messages[] = $message;\n            }\n        }\n\n        return $messages;\n    }\n\n    /**\n     * Fetch and clear message queue.\n     *\n     * @param string|null $scope\n     * @return array\n     */\n    public function fetch(string $scope = null): array\n    {\n        $messages = $this->all($scope);\n        $this->clear($scope);\n\n        return $messages;\n    }\n\n    /**\n     * @return array\n     */\n    public function __serialize(): array\n    {\n        return [\n          'messages' => $this->messages\n        ];\n    }\n\n    /**\n     * @param array $data\n     * @return void\n     */\n    public function __unserialize(array $data): void\n    {\n        $this->messages = $data['messages'];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Session/Session.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Session\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Session;\n\nuse ArrayIterator;\nuse Exception;\nuse Throwable;\nuse Grav\\Common\\Debugger;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\User\\Interfaces\\UserInterface;\nuse Grav\\Framework\\Session\\Exceptions\\SessionException;\nuse RuntimeException;\nuse function is_array;\nuse function is_bool;\nuse function is_string;\n\n/**\n * Class Session\n * @package Grav\\Framework\\Session\n */\nclass Session implements SessionInterface\n{\n    /** @var array */\n    protected $options = [];\n    /** @var bool */\n    protected $started = false;\n\n    /** @var Session */\n    protected static $instance;\n\n    /**\n     * @inheritdoc\n     */\n    public static function getInstance()\n    {\n        if (null === self::$instance) {\n            throw new RuntimeException(\"Session hasn't been initialized.\", 500);\n        }\n\n        return self::$instance;\n    }\n\n    /**\n     * Session constructor.\n     *\n     * @param array $options\n     */\n    public function __construct(array $options = [])\n    {\n        // Session is a singleton.\n        if (\\PHP_SAPI === 'cli') {\n            self::$instance = $this;\n\n            return;\n        }\n\n        if (null !== self::$instance) {\n            throw new RuntimeException('Session has already been initialized.', 500);\n        }\n\n        // Destroy any existing sessions started with session.auto_start\n        if ($this->isSessionStarted()) {\n            session_unset();\n            session_destroy();\n        }\n\n        // Set default options.\n        $options += [\n            'cache_limiter' => 'nocache',\n            'use_trans_sid' => 0,\n            'use_cookies' => 1,\n            'lazy_write' => 1,\n            'use_strict_mode' => 1\n        ];\n\n        $this->setOptions($options);\n\n        session_register_shutdown();\n\n        self::$instance = $this;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getId()\n    {\n        return session_id() ?: null;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function setId($id)\n    {\n        session_id($id);\n\n        return $this;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getName()\n    {\n        return session_name() ?: null;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function setName($name)\n    {\n        session_name($name);\n\n        return $this;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function setOptions(array $options)\n    {\n        if (headers_sent() || \\PHP_SESSION_ACTIVE === session_status()) {\n            return;\n        }\n\n        $allowedOptions = [\n            'save_path' => true,\n            'name' => true,\n            'save_handler' => true,\n            'gc_probability' => true,\n            'gc_divisor' => true,\n            'gc_maxlifetime' => true,\n            'serialize_handler' => true,\n            'cookie_lifetime' => true,\n            'cookie_path' => true,\n            'cookie_domain' => true,\n            'cookie_secure' => true,\n            'cookie_httponly' => true,\n            'use_strict_mode' => true,\n            'use_cookies' => true,\n            'use_only_cookies' => true,\n            'cookie_samesite' => true,\n            'referer_check' => true,\n            'cache_limiter' => true,\n            'cache_expire' => true,\n            'use_trans_sid' => true,\n            'trans_sid_tags' => true,\n            'trans_sid_hosts' => true,\n            'sid_length' => true,\n            'sid_bits_per_character' => true,\n            'upload_progress.enabled' => true,\n            'upload_progress.cleanup' => true,\n            'upload_progress.prefix' => true,\n            'upload_progress.name' => true,\n            'upload_progress.freq' => true,\n            'upload_progress.min-freq' => true,\n            'lazy_write' => true\n        ];\n\n        foreach ($options as $key => $value) {\n            if (is_array($value)) {\n                // Allow nested options.\n                foreach ($value as $key2 => $value2) {\n                    $ckey = \"{$key}.{$key2}\";\n                    if (isset($value2, $allowedOptions[$ckey])) {\n                        $this->setOption($ckey, $value2);\n                    }\n                }\n            } elseif (isset($value, $allowedOptions[$key])) {\n                $this->setOption($key, $value);\n            }\n        }\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function start($readonly = false)\n    {\n        if (\\PHP_SAPI === 'cli') {\n            return $this;\n        }\n\n        $sessionName = $this->getName();\n        if (null === $sessionName) {\n            return $this;\n        }\n\n        $sessionExists = isset($_COOKIE[$sessionName]);\n\n        // Protection against invalid session cookie names throwing exception: http://php.net/manual/en/function.session-id.php#116836\n        if ($sessionExists && !preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $_COOKIE[$sessionName])) {\n            unset($_COOKIE[$sessionName]);\n            $sessionExists = false;\n        }\n\n        $options = $this->options;\n        if ($readonly) {\n            $options['read_and_close'] = '1';\n        }\n\n        try {\n            $success = @session_start($options);\n            if (!$success) {\n                $last = error_get_last();\n                $error = $last ? $last['message'] : 'Unknown error';\n\n                throw new RuntimeException($error);\n            }\n\n            // Handle changing session id.\n            if ($this->__isset('session_destroyed')) {\n                $newId = $this->__get('session_new_id');\n                if (!$newId || $this->__get('session_destroyed') < time() - 300) {\n                    // Should not happen usually. This could be attack or due to unstable network. Destroy this session.\n                    $this->invalidate();\n\n                    throw new RuntimeException('Obsolete session access.', 500);\n                }\n\n                // Not fully expired yet. Could be lost cookie by unstable network. Start session with new session id.\n                session_write_close();\n\n                // Start session with new session id.\n                $useStrictMode = $options['use_strict_mode'] ?? 0;\n                if ($useStrictMode) {\n                    ini_set('session.use_strict_mode', '0');\n                }\n                session_id($newId);\n                if ($useStrictMode) {\n                    ini_set('session.use_strict_mode', '1');\n                }\n\n                $success = @session_start($options);\n                if (!$success) {\n                    $last = error_get_last();\n                    $error = $last ? $last['message'] : 'Unknown error';\n\n                    throw new RuntimeException($error);\n                }\n            }\n        } catch (Exception $e) {\n            throw new SessionException('Failed to start session: ' . $e->getMessage(), 500);\n        }\n\n        $this->started = true;\n        $this->onSessionStart();\n\n        try {\n            $user = $this->__get('user');\n            if ($user && (!$user instanceof UserInterface || (method_exists($user, 'isValid') && !$user->isValid()))) {\n                throw new RuntimeException('Bad user');\n            }\n        } catch (Throwable $e) {\n            $this->invalidate();\n            throw new SessionException('Invalid User object, session destroyed.', 500);\n        }\n\n\n        // Extend the lifetime of the session.\n        if ($sessionExists) {\n            $this->setCookie();\n        }\n\n        return $this;\n    }\n\n    /**\n     * Regenerate session id but keep the current session information.\n     *\n     * Session id must be regenerated on login, logout or after long time has been passed.\n     *\n     * @return $this\n     * @since 1.7\n     */\n    public function regenerateId()\n    {\n        if (!$this->isSessionStarted()) {\n            return $this;\n        }\n\n        // TODO: session_create_id() segfaults in PHP 7.3 (PHP bug #73461), remove phpstan rule when removing this one.\n        if (PHP_VERSION_ID < 70400) {\n            $newId = 0;\n        } else {\n            // Session id creation may fail with some session storages.\n            $newId = @session_create_id() ?: 0;\n        }\n\n        // Set destroyed timestamp for the old session as well as pointer to the new id.\n        $this->__set('session_destroyed', time());\n        $this->__set('session_new_id', $newId);\n\n        // Keep the old session alive to avoid lost sessions by unstable network.\n        if (!$newId) {\n            /** @var Debugger $debugger */\n            $debugger = Grav::instance()['debugger'];\n            $debugger->addMessage('Session fixation lost session detection is turned of due to server limitations.', 'warning');\n\n            session_regenerate_id(false);\n        } else {\n            session_write_close();\n\n            // Start session with new session id.\n            $useStrictMode = $this->options['use_strict_mode'] ?? 0;\n            if ($useStrictMode) {\n                ini_set('session.use_strict_mode', '0');\n            }\n            session_id($newId);\n            if ($useStrictMode) {\n                ini_set('session.use_strict_mode', '1');\n            }\n\n            $this->removeCookie();\n\n            $this->onBeforeSessionStart();\n\n            $success = @session_start($this->options);\n            if (!$success) {\n                $last = error_get_last();\n                $error = $last ? $last['message'] : 'Unknown error';\n\n                throw new RuntimeException($error);\n            }\n\n            $this->onSessionStart();\n        }\n\n        // New session does not have these.\n        $this->__unset('session_destroyed');\n        $this->__unset('session_new_id');\n\n        return $this;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function invalidate()\n    {\n        $name = $this->getName();\n        if (null !== $name) {\n            $this->removeCookie();\n\n            setcookie(\n                $name,\n                '',\n                $this->getCookieOptions(-42000)\n            );\n        }\n\n        if ($this->isSessionStarted()) {\n            session_unset();\n            session_destroy();\n        }\n\n        $this->started = false;\n\n        return $this;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function close()\n    {\n        if ($this->started) {\n            session_write_close();\n        }\n\n        $this->started = false;\n\n        return $this;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function clear()\n    {\n        session_unset();\n\n        return $this;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function getAll()\n    {\n        return $_SESSION;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    #[\\ReturnTypeWillChange]\n    public function getIterator()\n    {\n        return new ArrayIterator($_SESSION);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function isStarted()\n    {\n        return $this->started;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    #[\\ReturnTypeWillChange]\n    public function __isset($name)\n    {\n        return isset($_SESSION[$name]);\n    }\n\n    /**\n     * @inheritdoc\n     */\n    #[\\ReturnTypeWillChange]\n    public function __get($name)\n    {\n        return $_SESSION[$name] ?? null;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    #[\\ReturnTypeWillChange]\n    public function __set($name, $value)\n    {\n        $_SESSION[$name] = $value;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    #[\\ReturnTypeWillChange]\n    public function __unset($name)\n    {\n        unset($_SESSION[$name]);\n    }\n\n    /**\n     * http://php.net/manual/en/function.session-status.php#113468\n     * Check if session is started nicely.\n     * @return bool\n     */\n    protected function isSessionStarted()\n    {\n        return \\PHP_SAPI !== 'cli' ? \\PHP_SESSION_ACTIVE === session_status() : false;\n    }\n\n    protected function onBeforeSessionStart(): void\n    {\n    }\n\n    protected function onSessionStart(): void\n    {\n    }\n\n    /**\n     * Store something in cookie temporarily.\n     *\n     * @param int|null $lifetime\n     * @return array\n     */\n    public function getCookieOptions(int $lifetime = null): array\n    {\n        $params = session_get_cookie_params();\n\n        return [\n            'expires'  => time() + ($lifetime ?? $params['lifetime']),\n            'path'     => $params['path'],\n            'domain'   => $params['domain'],\n            'secure'   => $params['secure'],\n            'httponly' => $params['httponly'],\n            'samesite' => $params['samesite']\n        ];\n    }\n\n    /**\n     * @return void\n     */\n    protected function setCookie(): void\n    {\n        $this->removeCookie();\n\n        $sessionName = $this->getName();\n        $sessionId = $this->getId();\n        if (null === $sessionName || null === $sessionId) {\n            return;\n        }\n\n        setcookie(\n            $sessionName,\n            $sessionId,\n            $this->getCookieOptions()\n        );\n    }\n\n    protected function removeCookie(): void\n    {\n        $search = \" {$this->getName()}=\";\n        $cookies = [];\n        $found = false;\n\n        foreach (headers_list() as $header) {\n            // Identify cookie headers\n            if (strpos($header, 'Set-Cookie:') === 0) {\n                // Add all but session cookie(s).\n                if (!str_contains($header, $search)) {\n                    $cookies[] = $header;\n                } else {\n                    $found = true;\n                }\n            }\n        }\n\n        // Nothing to do.\n        if (false === $found) {\n            return;\n        }\n\n        // Remove all cookies and put back all but session cookie.\n        header_remove('Set-Cookie');\n        foreach($cookies as $cookie) {\n            header($cookie, false);\n        }\n    }\n\n    /**\n     * @param string $key\n     * @param mixed $value\n     * @return void\n     */\n    protected function setOption($key, $value)\n    {\n        if (!is_string($value)) {\n            if (is_bool($value)) {\n                $value = $value ? '1' : '0';\n            } else {\n                $value = (string)$value;\n            }\n        }\n\n        $this->options[$key] = $value;\n        ini_set(\"session.{$key}\", $value);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Session/SessionInterface.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Session\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Session;\n\nuse ArrayIterator;\nuse IteratorAggregate;\nuse RuntimeException;\n\n/**\n * Class Session\n * @package Grav\\Framework\\Session\n * @extends IteratorAggregate<array-key,mixed>\n */\ninterface SessionInterface extends IteratorAggregate\n{\n    /**\n     * Get current session instance.\n     *\n     * @return Session\n     * @throws RuntimeException\n     */\n    public static function getInstance();\n\n    /**\n     * Get session ID\n     *\n     * @return string|null Session ID\n     */\n    public function getId();\n\n    /**\n     * Set session ID\n     *\n     * @param string $id Session ID\n     * @return $this\n     */\n    public function setId($id);\n\n    /**\n     * Get session name\n     *\n     * @return string|null\n     */\n    public function getName();\n\n    /**\n     * Set session name\n     *\n     * @param string $name\n     * @return $this\n     */\n    public function setName($name);\n\n    /**\n     * Sets session.* ini variables.\n     *\n     * @param array $options\n     * @return void\n     * @see http://php.net/session.configuration\n     */\n    public function setOptions(array $options);\n\n    /**\n     * Starts the session storage\n     *\n     * @param bool $readonly\n     * @return $this\n     * @throws RuntimeException\n     */\n    public function start($readonly = false);\n\n    /**\n     * Invalidates the current session.\n     *\n     * @return $this\n     */\n    public function invalidate();\n\n    /**\n     * Force the session to be saved and closed\n     *\n     * @return $this\n     */\n    public function close();\n\n    /**\n     * Free all session variables.\n     *\n     * @return $this\n     */\n    public function clear();\n\n    /**\n     * Returns all session variables.\n     *\n     * @return array\n     */\n    public function getAll();\n\n    /**\n     * Retrieve an external iterator\n     *\n     * @return ArrayIterator Return an ArrayIterator of $_SESSION\n     * @phpstan-return ArrayIterator<array-key,mixed>\n     */\n    #[\\ReturnTypeWillChange]\n    public function getIterator();\n\n    /**\n     * Checks if the session was started.\n     *\n     * @return bool\n     */\n    public function isStarted();\n\n    /**\n     * Checks if session variable is defined.\n     *\n     * @param string $name\n     * @return bool\n     */\n    #[\\ReturnTypeWillChange]\n    public function __isset($name);\n\n    /**\n     * Returns session variable.\n     *\n     * @param string $name\n     * @return mixed\n     */\n    #[\\ReturnTypeWillChange]\n    public function __get($name);\n\n    /**\n     * Sets session variable.\n     *\n     * @param string $name\n     * @param mixed  $value\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __set($name, $value);\n\n    /**\n     * Removes session variable.\n     *\n     * @param string $name\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __unset($name);\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Uri/Uri.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Uri\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Uri;\n\nuse Grav\\Framework\\Psr7\\AbstractUri;\nuse GuzzleHttp\\Psr7\\Uri as GuzzleUri;\nuse InvalidArgumentException;\nuse Psr\\Http\\Message\\UriInterface;\n\n/**\n * Implements PSR-7 UriInterface.\n *\n * @package Grav\\Framework\\Uri\n */\nclass Uri extends AbstractUri\n{\n    /** @var array Array of Uri query. */\n    private $queryParams;\n\n    /**\n     * You can use `UriFactory` functions to create new `Uri` objects.\n     *\n     * @param array $parts\n     * @return void\n     * @throws InvalidArgumentException\n     */\n    public function __construct(array $parts = [])\n    {\n        $this->initParts($parts);\n    }\n\n    /**\n     * @return string\n     */\n    public function getUser()\n    {\n        return parent::getUser();\n    }\n\n    /**\n     * @return string\n     */\n    public function getPassword()\n    {\n        return parent::getPassword();\n    }\n\n    /**\n     * @return array\n     */\n    public function getParts()\n    {\n        return parent::getParts();\n    }\n\n    /**\n     * @return string\n     */\n    public function getUrl()\n    {\n        return parent::getUrl();\n    }\n\n    /**\n     * @return string\n     */\n    public function getBaseUrl()\n    {\n        return parent::getBaseUrl();\n    }\n\n    /**\n     * @param string $key\n     * @return string|null\n     */\n    public function getQueryParam($key)\n    {\n        $queryParams = $this->getQueryParams();\n\n        return $queryParams[$key] ?? null;\n    }\n\n    /**\n     * @param string $key\n     * @return UriInterface\n     */\n    public function withoutQueryParam($key)\n    {\n        return GuzzleUri::withoutQueryValue($this, $key);\n    }\n\n    /**\n     * @param string $key\n     * @param string|null $value\n     * @return UriInterface\n     */\n    public function withQueryParam($key, $value)\n    {\n        return GuzzleUri::withQueryValue($this, $key, $value);\n    }\n\n    /**\n     * @return array\n     */\n    public function getQueryParams()\n    {\n        if ($this->queryParams === null) {\n            $this->queryParams = UriFactory::parseQuery($this->getQuery());\n        }\n\n        return $this->queryParams;\n    }\n\n    /**\n     * @param array $params\n     * @return UriInterface\n     */\n    public function withQueryParams(array $params)\n    {\n        $query = UriFactory::buildQuery($params);\n\n        return $this->withQuery($query);\n    }\n\n    /**\n     * Whether the URI has the default port of the current scheme.\n     *\n     * `$uri->getPort()` may return the standard port. This method can be used for some non-http/https Uri.\n     *\n     * @return bool\n     */\n    public function isDefaultPort()\n    {\n        return $this->getPort() === null || GuzzleUri::isDefaultPort($this);\n    }\n\n    /**\n     * Whether the URI is absolute, i.e. it has a scheme.\n     *\n     * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true\n     * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative\n     * to another URI, the base URI. Relative references can be divided into several forms:\n     * - network-path references, e.g. '//example.com/path'\n     * - absolute-path references, e.g. '/path'\n     * - relative-path references, e.g. 'subpath'\n     *\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4\n     */\n    public function isAbsolute()\n    {\n        return GuzzleUri::isAbsolute($this);\n    }\n\n    /**\n     * Whether the URI is a network-path reference.\n     *\n     * A relative reference that begins with two slash characters is termed an network-path reference.\n     *\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4.2\n     */\n    public function isNetworkPathReference()\n    {\n        return GuzzleUri::isNetworkPathReference($this);\n    }\n\n    /**\n     * Whether the URI is a absolute-path reference.\n     *\n     * A relative reference that begins with a single slash character is termed an absolute-path reference.\n     *\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4.2\n     */\n    public function isAbsolutePathReference()\n    {\n        return GuzzleUri::isAbsolutePathReference($this);\n    }\n\n    /**\n     * Whether the URI is a relative-path reference.\n     *\n     * A relative reference that does not begin with a slash character is termed a relative-path reference.\n     *\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4.2\n     */\n    public function isRelativePathReference()\n    {\n        return GuzzleUri::isRelativePathReference($this);\n    }\n\n    /**\n     * Whether the URI is a same-document reference.\n     *\n     * A same-document reference refers to a URI that is, aside from its fragment\n     * component, identical to the base URI. When no base URI is given, only an empty\n     * URI reference (apart from its fragment) is considered a same-document reference.\n     *\n     * @param UriInterface|null $base An optional base URI to compare against\n     * @return bool\n     * @link https://tools.ietf.org/html/rfc3986#section-4.4\n     */\n    public function isSameDocumentReference(UriInterface $base = null)\n    {\n        return GuzzleUri::isSameDocumentReference($this, $base);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Uri/UriFactory.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Uri\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Uri;\n\nuse InvalidArgumentException;\nuse function is_string;\n\n/**\n * Class Uri\n * @package Grav\\Framework\\Uri\n */\nclass UriFactory\n{\n    /**\n     * @param array $env\n     * @return Uri\n     * @throws InvalidArgumentException\n     */\n    public static function createFromEnvironment(array $env)\n    {\n        return new Uri(static::parseUrlFromEnvironment($env));\n    }\n\n    /**\n     * @param string $uri\n     * @return Uri\n     * @throws InvalidArgumentException\n     */\n    public static function createFromString($uri)\n    {\n        return new Uri(static::parseUrl($uri));\n    }\n\n    /**\n     * Creates a URI from a array of `parse_url()` components.\n     *\n     * @param array $parts\n     * @return Uri\n     * @throws InvalidArgumentException\n     */\n    public static function createFromParts(array $parts)\n    {\n        return new Uri($parts);\n    }\n\n    /**\n     * @param array $env\n     * @return array\n     * @throws InvalidArgumentException\n     */\n    public static function parseUrlFromEnvironment(array $env)\n    {\n        // Build scheme.\n        if (isset($env['REQUEST_SCHEME'])) {\n            $scheme = strtolower($env['REQUEST_SCHEME']);\n        } else {\n            $https = $env['HTTPS'] ?? '';\n            $scheme = (empty($https) || strtolower($https) === 'off') ? 'http' : 'https';\n        }\n\n        // Build user and password.\n        $user = $env['PHP_AUTH_USER'] ?? '';\n        $pass = $env['PHP_AUTH_PW'] ?? '';\n\n        // Build host.\n        $host = 'localhost';\n        if (isset($env['HTTP_HOST'])) {\n            $host = $env['HTTP_HOST'];\n        } elseif (isset($env['SERVER_NAME'])) {\n            $host = $env['SERVER_NAME'];\n        }\n        // Remove port from HTTP_HOST generated $hostname\n        $host = explode(':', $host)[0];\n\n        // Build port.\n        $port = isset($env['SERVER_PORT']) ? (int)$env['SERVER_PORT'] : null;\n\n        // Build path.\n        $request_uri = $env['REQUEST_URI'] ?? '';\n        $path = parse_url('http://example.com' . $request_uri, PHP_URL_PATH);\n\n        // Build query string.\n        $query = $env['QUERY_STRING'] ?? '';\n        if ($query === '') {\n            $query = parse_url('http://example.com' . $request_uri, PHP_URL_QUERY);\n        }\n\n        // Support ngnix routes.\n        if (strpos((string) $query, '_url=') === 0) {\n            parse_str($query, $q);\n            unset($q['_url']);\n            $query = http_build_query($q);\n        }\n\n        return [\n            'scheme' => $scheme,\n            'user' => $user,\n            'pass' => $pass,\n            'host' => $host,\n            'port' => $port,\n            'path' => $path,\n            'query' => $query\n        ];\n    }\n\n    /**\n     * UTF-8 aware parse_url() implementation.\n     *\n     * @param string $url\n     * @return array\n     * @throws InvalidArgumentException\n     */\n    public static function parseUrl($url)\n    {\n        if (!is_string($url)) {\n            throw new InvalidArgumentException('URL must be a string');\n        }\n\n        $encodedUrl = preg_replace_callback(\n            '%[^:/@?&=#]+%u',\n            static function ($matches) {\n                return rawurlencode($matches[0]);\n            },\n            $url\n        );\n\n        $parts = is_string($encodedUrl) ? parse_url($encodedUrl) : false;\n        if ($parts === false) {\n            throw new InvalidArgumentException(\"Malformed URL: {$url}\");\n        }\n\n        return $parts;\n    }\n\n    /**\n     * Parse query string and return it as an array.\n     *\n     * @param string $query\n     * @return mixed\n     */\n    public static function parseQuery($query)\n    {\n        parse_str($query, $params);\n\n        return $params;\n    }\n\n    /**\n     * Build query string from variables.\n     *\n     * @param array $params\n     * @return string\n     */\n    public static function buildQuery(array $params)\n    {\n        if (!$params) {\n            return '';\n        }\n\n        $separator = ini_get('arg_separator.output') ?: '&';\n\n        return http_build_query($params, '', $separator, PHP_QUERY_RFC3986);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Framework/Uri/UriPartsFilter.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Framework\\Uri\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Framework\\Uri;\n\nuse InvalidArgumentException;\nuse function is_int;\nuse function is_string;\n\n/**\n * Class Uri\n * @package Grav\\Framework\\Uri\n */\nclass UriPartsFilter\n{\n    const HOSTNAME_REGEX = '/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$/u';\n\n    /**\n     * @param string $scheme\n     * @return string\n     * @throws InvalidArgumentException If the scheme is invalid.\n     */\n    public static function filterScheme($scheme)\n    {\n        if (!is_string($scheme)) {\n            throw new InvalidArgumentException('Uri scheme must be a string');\n        }\n\n        return strtolower($scheme);\n    }\n\n    /**\n     * Filters the user info string.\n     *\n     * @param string $info The raw user or password.\n     * @return string The percent-encoded user or password string.\n     * @throws InvalidArgumentException\n     */\n    public static function filterUserInfo($info)\n    {\n        if (!is_string($info)) {\n            throw new InvalidArgumentException('Uri user info must be a string');\n        }\n\n        return preg_replace_callback(\n            '/(?:[^a-zA-Z0-9_\\-\\.~!\\$&\\'\\(\\)\\*\\+,;=]+|%(?![A-Fa-f0-9]{2}))/u',\n            function ($match) {\n                return rawurlencode($match[0]);\n            },\n            $info\n        ) ?? '';\n    }\n\n    /**\n     * @param string $host\n     * @return string\n     * @throws InvalidArgumentException If the host is invalid.\n     */\n    public static function filterHost($host)\n    {\n        if (!is_string($host)) {\n            throw new InvalidArgumentException('Uri host must be a string');\n        }\n\n        if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {\n            $host = '[' . $host . ']';\n        } elseif ($host && preg_match(static::HOSTNAME_REGEX, $host) !== 1) {\n            throw new InvalidArgumentException('Uri host name validation failed');\n        }\n\n        return strtolower($host);\n    }\n\n    /**\n     * Filter Uri port.\n     *\n     * This method\n     *\n     * @param int|null $port\n     * @return int|null\n     * @throws InvalidArgumentException If the port is invalid.\n     */\n    public static function filterPort($port = null)\n    {\n        if (null === $port || (is_int($port) && ($port >= 0 && $port <= 65535))) {\n            return $port;\n        }\n\n        throw new InvalidArgumentException('Uri port must be null or an integer between 0 and 65535');\n    }\n\n    /**\n     * Filter Uri path.\n     *\n     * This method percent-encodes all reserved characters in the provided path string. This method\n     * will NOT double-encode characters that are already percent-encoded.\n     *\n     * @param  string $path The raw uri path.\n     * @return string       The RFC 3986 percent-encoded uri path.\n     * @throws InvalidArgumentException If the path is invalid.\n     * @link   http://www.faqs.org/rfcs/rfc3986.html\n     */\n    public static function filterPath($path)\n    {\n        if (!is_string($path)) {\n            throw new InvalidArgumentException('Uri path must be a string');\n        }\n\n        return preg_replace_callback(\n            '/(?:[^a-zA-Z0-9_\\-\\.~:@&=\\+\\$,\\/;%]+|%(?![A-Fa-f0-9]{2}))/u',\n            function ($match) {\n                return rawurlencode($match[0]);\n            },\n            $path\n        ) ?? '';\n    }\n\n    /**\n     * Filters the query string or fragment of a URI.\n     *\n     * @param string $query The raw uri query string.\n     * @return string The percent-encoded query string.\n     * @throws InvalidArgumentException If the query is invalid.\n     */\n    public static function filterQueryOrFragment($query)\n    {\n        if (!is_string($query)) {\n            throw new InvalidArgumentException('Uri query string and fragment must be a string');\n        }\n\n        return preg_replace_callback(\n            '/(?:[^a-zA-Z0-9_\\-\\.~!\\$&\\'\\(\\)\\*\\+,;=%:@\\/\\?]+|%(?![A-Fa-f0-9]{2}))/u',\n            function ($match) {\n                return rawurlencode($match[0]);\n            },\n            $query\n        ) ?? '';\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Installer/Install.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Installer\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Installer;\n\nuse Composer\\Autoload\\ClassLoader;\nuse Exception;\nuse Grav\\Common\\Cache;\nuse Grav\\Common\\GPM\\Installer;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Plugins;\nuse RuntimeException;\nuse function class_exists;\nuse function dirname;\nuse function function_exists;\nuse function is_string;\n\n/**\n * Grav installer.\n *\n * NOTE: This class can be initialized during upgrade from an older version of Grav. Make sure it runs there!\n */\nfinal class Install\n{\n    /** @var int Installer version. */\n    public $version = 1;\n\n    /** @var array */\n    public $requires = [\n        'php' => [\n            'name' => 'PHP',\n            'versions' => [\n                '8.1' => '8.1.0',\n                '8.0' => '8.0.0',\n                '7.4' => '7.4.1',\n                '7.3' => '7.3.6',\n                '' => '8.0.13'\n            ]\n        ],\n        'grav' => [\n            'name' => 'Grav',\n            'versions' => [\n                '1.6' => '1.6.0',\n                '' => '1.6.28'\n            ]\n        ],\n        'plugins' => [\n            'admin' => [\n                'name' => 'Admin',\n                'optional' => true,\n                'versions' => [\n                    '1.9' => '1.9.0',\n                    '' => '1.9.13'\n                ]\n            ],\n            'email' => [\n                'name' => 'Email',\n                'optional' => true,\n                'versions' => [\n                    '3.0' => '3.0.0',\n                    '' => '3.0.10'\n                ]\n            ],\n            'form' => [\n                'name' => 'Form',\n                'optional' => true,\n                'versions' => [\n                    '4.1' => '4.1.0',\n                    '4.0' => '4.0.0',\n                    '3.0' => '3.0.0',\n                    '' => '4.1.2'\n                ]\n            ],\n            'login' => [\n                'name' => 'Login',\n                'optional' => true,\n                'versions' => [\n                    '3.3' => '3.3.0',\n                    '3.0' => '3.0.0',\n                    '' => '3.3.6'\n                ]\n            ],\n        ]\n    ];\n\n    /** @var array */\n    public $ignores = [\n        'backup',\n        'cache',\n        'images',\n        'logs',\n        'tmp',\n        'user',\n        '.htaccess',\n        'robots.txt'\n    ];\n\n    /** @var array */\n    private $classMap = [\n        InstallException::class => __DIR__ . '/InstallException.php',\n        Versions::class => __DIR__ . '/Versions.php',\n        VersionUpdate::class => __DIR__ . '/VersionUpdate.php',\n        VersionUpdater::class => __DIR__ . '/VersionUpdater.php',\n        YamlUpdater::class => __DIR__ . '/YamlUpdater.php',\n    ];\n\n    /** @var string|null */\n    private $zip;\n\n    /** @var string|null */\n    private $location;\n\n    /** @var VersionUpdater|null */\n    private $updater;\n\n    /** @var static */\n    private static $instance;\n\n    /**\n     * Backward-compatibility: older versions (e.g. 1.7.50) with safe-upgrade call\n     * forceSafeUpgrade() / getLastManifest() on the Install singleton loaded from\n     * the update package.  These stubs prevent fatal errors when downgrading from\n     * a safe-upgrade-aware release to one that removed it.\n     */\n\n    /** @var bool|null */\n    private static $forceSafeUpgrade = null;\n\n    /**\n     * @param bool|null $state\n     * @return void\n     */\n    public static function forceSafeUpgrade(?bool $state = true): void\n    {\n        self::$forceSafeUpgrade = $state;\n    }\n\n    /**\n     * @return array|null\n     */\n    public function getLastManifest(): ?array\n    {\n        return null;\n    }\n\n    /**\n     * @return static\n     */\n    public static function instance()\n    {\n        if (null === self::$instance) {\n            self::$instance = new static();\n        }\n\n        return self::$instance;\n    }\n\n    private function __construct()\n    {\n    }\n\n    /**\n     * @param string|null $zip\n     * @return $this\n     */\n    public function setZip(?string $zip)\n    {\n        $this->zip = $zip;\n\n        return $this;\n    }\n\n    /**\n     * @param string|null $zip\n     * @return void\n     */\n    #[\\ReturnTypeWillChange]\n    public function __invoke(?string $zip)\n    {\n        $this->zip = $zip;\n\n        $failedRequirements = $this->checkRequirements();\n        if ($failedRequirements) {\n            $error = ['Following requirements have failed:'];\n\n            foreach ($failedRequirements as $name => $req) {\n                $error[] = \"{$req['title']} >= <strong>v{$req['minimum']}</strong> required, you have <strong>v{$req['installed']}</strong>\";\n            }\n\n            $errors = implode(\"<br />\\n\", $error);\n            if (\\defined('GRAV_CLI') && GRAV_CLI) {\n                $errors = \"\\n\\n\" . strip_tags($errors) . \"\\n\\n\";\n                $errors .= <<<ERR\nPlease install Grav 1.6.31 first by running following commands:\n\nwget -q https://getgrav.org/download/core/grav-update/1.6.31 -O tmp/grav-update-v1.6.31.zip\nbin/gpm direct-install -y tmp/grav-update-v1.6.31.zip\nrm tmp/grav-update.zip\nERR;\n            }\n\n            throw new RuntimeException($errors);\n        }\n\n        $this->prepare();\n        $this->install();\n        $this->finalize();\n    }\n\n    /**\n     * NOTE: This method can only be called after $grav['plugins']->init().\n     *\n     * @return array List of failed requirements. If the list is empty, installation can go on.\n     */\n    public function checkRequirements(): array\n    {\n        $results = [];\n\n        $this->checkVersion($results, 'php', 'php', $this->requires['php'], PHP_VERSION);\n        $this->checkVersion($results, 'grav', 'grav', $this->requires['grav'], GRAV_VERSION);\n        $this->checkPlugins($results, $this->requires['plugins']);\n\n        return $results;\n    }\n\n    /**\n     * @return void\n     * @throws RuntimeException\n     */\n    public function prepare(): void\n    {\n        // Locate the new Grav update and the target site from the filesystem.\n        $location = realpath(__DIR__);\n        $target = realpath(GRAV_ROOT . '/index.php');\n\n        if (!$location) {\n            throw new RuntimeException('Internal Error', 500);\n        }\n\n        if ($target && dirname($location, 4) === dirname($target)) {\n            // We cannot copy files into themselves, abort!\n            throw new RuntimeException('Grav has already been installed here!', 400);\n        }\n\n        // Load the installer classes.\n        foreach ($this->classMap as $class_name => $path) {\n            // Make sure that none of the Grav\\Installer classes have been loaded, otherwise installation may fail!\n            if (class_exists($class_name, false)) {\n                throw new RuntimeException(sprintf('Cannot update Grav, class %s has already been loaded!', $class_name), 500);\n            }\n\n            require $path;\n        }\n\n        $this->legacySupport();\n\n        $this->location = dirname($location, 4);\n\n        $versions = Versions::instance(USER_DIR . 'config/versions.yaml');\n        $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', $this->getVersion(), $versions);\n\n        $this->updater->preflight();\n    }\n\n    /**\n     * @return void\n     * @throws RuntimeException\n     */\n    public function install(): void\n    {\n        if (!$this->location) {\n            throw new RuntimeException('Oops, installer was run without prepare()!', 500);\n        }\n\n        try {\n            if (null === $this->updater) {\n                $versions = Versions::instance(USER_DIR . 'config/versions.yaml');\n                $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', $this->getVersion(), $versions);\n            }\n\n            // Update user/config/version.yaml before copying the files to avoid frontend from setting the version schema.\n            $this->updater->install();\n\n            Installer::install(\n                $this->zip ?? '',\n                GRAV_ROOT,\n                ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true, 'ignores' => $this->ignores],\n                $this->location,\n                !($this->zip && is_file($this->zip))\n            );\n        } catch (Exception $e) {\n            Installer::setError($e->getMessage());\n        }\n\n        $errorCode = Installer::lastErrorCode();\n\n        $success = !(is_string($errorCode) || ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR)));\n\n        if (!$success) {\n            throw new RuntimeException(Installer::lastErrorMsg());\n        }\n    }\n\n    /**\n     * @return void\n     * @throws RuntimeException\n     */\n    public function finalize(): void\n    {\n        // Finalize can be run without installing Grav first.\n        if (null === $this->updater) {\n            $versions = Versions::instance(USER_DIR . 'config/versions.yaml');\n            $this->updater = new VersionUpdater('core/grav', __DIR__ . '/updates', GRAV_VERSION, $versions);\n            $this->updater->install();\n        }\n\n        $this->updater->postflight();\n\n        Cache::clearCache('all');\n\n        clearstatcache();\n        if (function_exists('opcache_reset')) {\n            @opcache_reset();\n        }\n    }\n\n    /**\n     * @param array $results\n     * @param string $type\n     * @param string $name\n     * @param array $check\n     * @param string|null $version\n     * @return void\n     */\n    protected function checkVersion(array &$results, $type, $name, array $check, $version): void\n    {\n        if (null === $version && !empty($check['optional'])) {\n            return;\n        }\n\n        $major = $minor = 0;\n        $versions = $check['versions'] ?? [];\n        foreach ($versions as $major => $minor) {\n            if (!$major || version_compare($version ?? '0', $major, '<')) {\n                continue;\n            }\n\n            if (version_compare($version ?? '0', $minor, '>=')) {\n                return;\n            }\n\n            break;\n        }\n\n        if (!$major) {\n            $minor = reset($versions);\n        }\n\n        $recommended = end($versions);\n\n        if (version_compare($recommended, $minor, '<=')) {\n            $recommended = null;\n        }\n\n        $results[$name] = [\n            'type' => $type,\n            'name' => $name,\n            'title' => $check['name'] ?? $name,\n            'installed' => $version,\n            'minimum' => $minor,\n            'recommended' => $recommended\n        ];\n    }\n\n    /**\n     * @param array $results\n     * @param array $plugins\n     * @return void\n     */\n    protected function checkPlugins(array &$results, array $plugins): void\n    {\n        if (!class_exists('Plugins')) {\n            return;\n        }\n\n        foreach ($plugins as $name => $check) {\n            $plugin = Plugins::get($name);\n            if (!$plugin) {\n                $this->checkVersion($results, 'plugin', $name, $check, null);\n                continue;\n            }\n\n            $blueprint = $plugin->blueprints();\n            $version = (string)$blueprint->get('version');\n            $check['name'] = ($blueprint->get('name') ?? $check['name'] ?? $name) . ' Plugin';\n            $this->checkVersion($results, 'plugin', $name, $check, $version);\n        }\n    }\n\n    /**\n     * @return string\n     */\n    protected function getVersion(): string\n    {\n        $definesFile = \"{$this->location}/system/defines.php\";\n        $content = file_get_contents($definesFile);\n        if (false === $content) {\n            return '';\n        }\n\n        preg_match(\"/define\\('GRAV_VERSION', '([^']+)'\\);/mu\", $content, $matches);\n\n        return $matches[1] ?? '';\n    }\n\n    protected function legacySupport(): void\n    {\n        // Support install for Grav 1.6.0 - 1.6.20 by loading the original class from the older version of Grav.\n        class_exists(\\Grav\\Console\\Cli\\CacheCommand::class, true);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Installer/InstallException.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Installer\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Installer;\n\nuse Throwable;\n\n/**\n * Class InstallException\n * @package Grav\\Installer\n */\nclass InstallException extends \\RuntimeException\n{\n    /**\n     * InstallException constructor.\n     * @param string $message\n     * @param Throwable $previous\n     */\n    public function __construct(string $message, Throwable $previous)\n    {\n        parent::__construct($message, $previous->getCode(), $previous);\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Installer/VersionUpdate.php",
    "content": "<?php\n\nnamespace Grav\\Installer;\n\nuse Closure;\nuse Grav\\Common\\Utils;\n\n/**\n * Class VersionUpdate\n * @package Grav\\Installer\n */\nfinal class VersionUpdate\n{\n    /** @var string */\n    private $revision;\n    /** @var string */\n    private $version;\n    /** @var string */\n    private $date;\n    /** @var string */\n    private $patch;\n    /** @var VersionUpdater */\n    private $updater;\n    /** @var callable[] */\n    private $methods;\n\n    public function __construct(string $file, VersionUpdater $updater)\n    {\n        $name = basename($file, '.php');\n\n        $this->revision = $name;\n        [$this->version, $this->date, $this->patch] = explode('_', $name);\n        $this->updater = $updater;\n        $this->methods = require $file;\n    }\n\n    public function getRevision(): string\n    {\n        return $this->revision;\n    }\n\n    public function getVersion(): string\n    {\n        return $this->version;\n    }\n\n    public function getDate(): string\n    {\n        return $this->date;\n    }\n\n    public function getPatch(): string\n    {\n        return $this->patch;\n    }\n\n    public function getUpdater(): VersionUpdater\n    {\n        return $this->updater;\n    }\n\n    /**\n     * Run right before installation.\n     */\n    public function preflight(VersionUpdater $updater): void\n    {\n        $method = $this->methods['preflight'] ?? null;\n        if ($method instanceof Closure) {\n            $method->call($this);\n        }\n    }\n\n    /**\n     * Runs right after installation.\n     */\n    public function postflight(VersionUpdater $updater): void\n    {\n        $method = $this->methods['postflight'] ?? null;\n        if ($method instanceof Closure) {\n            $method->call($this);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Installer/VersionUpdater.php",
    "content": "<?php\n\nnamespace Grav\\Installer;\n\nuse DirectoryIterator;\n\n/**\n * Class VersionUpdater\n * @package Grav\\Installer\n */\nfinal class VersionUpdater\n{\n    /** @var string */\n    private $name;\n    /** @var string */\n    private $path;\n    /** @var string */\n    private $version;\n    /** @var Versions */\n    private $versions;\n    /** @var VersionUpdate[] */\n    private $updates;\n\n    /**\n     * VersionUpdater constructor.\n     * @param string $name\n     * @param string $path\n     * @param string $version\n     * @param Versions $versions\n     */\n    public function __construct(string $name, string $path, string $version, Versions $versions)\n    {\n        $this->name = $name;\n        $this->path = $path;\n        $this->version = $version;\n        $this->versions = $versions;\n\n        $this->loadUpdates();\n    }\n\n    /**\n     * Pre-installation method.\n     */\n    public function preflight(): void\n    {\n        foreach ($this->updates as $revision => $update) {\n            $update->preflight($this);\n        }\n    }\n\n    /**\n     * Install method.\n     */\n    public function install(): void\n    {\n        $versions = $this->getVersions();\n        $versions->updateVersion($this->name, $this->version);\n        $versions->save();\n    }\n\n    /**\n     * Post-installation method.\n     */\n    public function postflight(): void\n    {\n        $versions = $this->getVersions();\n\n        foreach ($this->updates as $revision => $update) {\n            $update->postflight($this);\n\n            $versions->setSchema($this->name, $revision);\n            $versions->save();\n        }\n    }\n\n    /**\n     * @return Versions\n     */\n    public function getVersions(): Versions\n    {\n        return $this->versions;\n    }\n\n    /**\n     * @param string|null $name\n     * @return string|null\n     */\n    public function getExtensionVersion(string $name = null): ?string\n    {\n        return $this->versions->getVersion($name ?? $this->name);\n    }\n\n    /**\n     * @param string|null $name\n     * @return string|null\n     */\n    public function getExtensionSchema(string $name = null): ?string\n    {\n        return $this->versions->getSchema($name ?? $this->name);\n    }\n\n    /**\n     * @param string|null $name\n     * @return array\n     */\n    public function getExtensionHistory(string $name = null): array\n    {\n        return $this->versions->getHistory($name ?? $this->name);\n    }\n\n    protected function loadUpdates(): void\n    {\n        $this->updates = [];\n\n        $schema = $this->getExtensionSchema();\n        $iterator = new DirectoryIterator($this->path);\n        foreach ($iterator as $item) {\n            if (!$item->isFile() || $item->getExtension() !== 'php') {\n                continue;\n            }\n\n            $revision = $item->getBasename('.php');\n            if (!$schema || version_compare($revision, $schema, '>')) {\n                $realPath = $item->getRealPath();\n                if ($realPath) {\n                    $this->updates[$revision] = new VersionUpdate($realPath, $this);\n                }\n            }\n        }\n\n        uksort($this->updates, 'version_compare');\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Installer/Versions.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Installer\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Installer;\n\nuse Symfony\\Component\\Yaml\\Yaml;\nuse function is_array;\nuse function is_string;\n\n/**\n * Grav Versions\n *\n * NOTE: This class can be initialized during upgrade from an older version of Grav. Make sure it runs there!\n */\nfinal class Versions\n{\n    /** @var string */\n    protected $filename;\n    /** @var array */\n    protected $items;\n    /** @var bool */\n    protected $updated = false;\n\n    /** @var self[] */\n    protected static $instance;\n\n    /**\n     * @param string|null $filename\n     * @return self\n     */\n    public static function instance(string $filename = null): self\n    {\n        $filename = $filename ?? USER_DIR . 'config/versions.yaml';\n\n        if (!isset(self::$instance[$filename])) {\n            self::$instance[$filename] = new self($filename);\n        }\n\n        return self::$instance[$filename];\n    }\n\n    /**\n     * @return bool True if the file was updated.\n     */\n    public function save(): bool\n    {\n        if (!$this->updated) {\n            return false;\n        }\n\n        file_put_contents($this->filename, Yaml::dump($this->items, 4, 2));\n\n        $this->updated = false;\n\n        return true;\n    }\n\n    /**\n     * @return array\n     */\n    public function getAll(): array\n    {\n        return $this->items;\n    }\n\n    /**\n     * @return array|null\n     */\n    public function getGrav(): ?array\n    {\n        return $this->get('core/grav');\n    }\n\n    /**\n     * @return array\n     */\n    public function getPlugins(): array\n    {\n        return $this->get('plugins', []);\n    }\n\n    /**\n     * @param string $name\n     * @return array|null\n     */\n    public function getPlugin(string $name): ?array\n    {\n        return $this->get(\"plugins/{$name}\");\n    }\n\n    /**\n     * @return array\n     */\n    public function getThemes(): array\n    {\n        return $this->get('themes', []);\n    }\n\n    /**\n     * @param string $name\n     * @return array|null\n     */\n    public function getTheme(string $name): ?array\n    {\n        return $this->get(\"themes/{$name}\");\n    }\n\n    /**\n     * @param string $extension\n     * @return array|null\n     */\n    public function getExtension(string $extension): ?array\n    {\n        return $this->get($extension);\n    }\n\n    /**\n     * @param string $extension\n     * @param array|null $value\n     */\n    public function setExtension(string $extension, ?array $value): void\n    {\n        if (null !== $value) {\n            $this->set($extension, $value);\n        } else {\n            $this->undef($extension);\n        }\n    }\n\n    /**\n     * @param string $extension\n     * @return string|null\n     */\n    public function getVersion(string $extension): ?string\n    {\n        $version = $this->get(\"{$extension}/version\", null);\n\n        return is_string($version) ? $version : null;\n    }\n\n    /**\n     * @param string $extension\n     * @param string|null $version\n     */\n    public function setVersion(string $extension, ?string $version): void\n    {\n        $this->updateHistory($extension, $version);\n    }\n\n    /**\n     * NOTE: Updates also history.\n     *\n     * @param string $extension\n     * @param string|null $version\n     */\n    public function updateVersion(string $extension, ?string $version): void\n    {\n        $this->set(\"{$extension}/version\", $version);\n        $this->updateHistory($extension, $version);\n    }\n\n    /**\n     * @param string $extension\n     * @return string|null\n     */\n    public function getSchema(string $extension): ?string\n    {\n        $version = $this->get(\"{$extension}/schema\", null);\n\n        return is_string($version) ? $version : null;\n    }\n\n    /**\n     * @param string $extension\n     * @param string|null $schema\n     */\n    public function setSchema(string $extension, ?string $schema): void\n    {\n        if (null !== $schema) {\n            $this->set(\"{$extension}/schema\", $schema);\n        } else {\n            $this->undef(\"{$extension}/schema\");\n        }\n    }\n\n    /**\n     * @param string $extension\n     * @return array\n     */\n    public function getHistory(string $extension): array\n    {\n        $name = \"{$extension}/history\";\n        $history = $this->get($name, []);\n\n        // Fix for broken Grav 1.6 history\n        if ($extension === 'grav') {\n            $history = $this->fixHistory($history);\n        }\n\n        return $history;\n    }\n\n    /**\n     * @param string $extension\n     * @param string|null $version\n     */\n    public function updateHistory(string $extension, ?string $version): void\n    {\n        $name = \"{$extension}/history\";\n        $history = $this->getHistory($extension);\n        $history[] = ['version' => $version, 'date' => gmdate('Y-m-d H:i:s')];\n        $this->set($name, $history);\n    }\n\n    /**\n     * Clears extension history. Useful when creating skeletons.\n     *\n     * @param string $extension\n     */\n    public function removeHistory(string $extension): void\n    {\n        $this->undef(\"{$extension}/history\");\n    }\n\n    /**\n     * @param array $history\n     * @return array\n     */\n    private function fixHistory(array $history): array\n    {\n        if (isset($history['version'], $history['date'])) {\n            $fix = [['version' => $history['version'], 'date' => $history['date']]];\n            unset($history['version'], $history['date']);\n            $history = array_merge($fix, $history);\n        }\n\n        return $history;\n    }\n\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @param string $name Slash separated path to the requested value.\n     * @param mixed $default Default value (or null).\n     * @return mixed Value.\n     */\n    private function get(string $name, $default = null)\n    {\n        $path = explode('/', $name);\n        $current = $this->items;\n\n        foreach ($path as $field) {\n            if (is_array($current) && isset($current[$field])) {\n                $current = $current[$field];\n            } else {\n                return $default;\n            }\n        }\n\n        return $current;\n    }\n\n    /**\n     * Set value by using dot notation for nested arrays/objects.\n     *\n     * @param string $name Slash separated path to the requested value.\n     * @param mixed $value New value.\n     */\n    private function set(string $name, $value): void\n    {\n        $path = explode('/', $name);\n        $current = &$this->items;\n\n        foreach ($path as $field) {\n            // Handle arrays and scalars.\n            if (!is_array($current)) {\n                $current = [$field => []];\n            } elseif (!isset($current[$field])) {\n                $current[$field] = [];\n            }\n            $current = &$current[$field];\n        }\n\n        $current = $value;\n        $this->updated = true;\n    }\n\n    /**\n     * Unset value by using dot notation for nested arrays/objects.\n     *\n     * @param string $name Dot separated path to the requested value.\n     */\n    private function undef(string $name): void\n    {\n        $path = $name !== '' ? explode('/', $name) : [];\n        if (!$path) {\n            return;\n        }\n\n        $var = array_pop($path);\n        $current = &$this->items;\n\n        foreach ($path as $field) {\n            if (!is_array($current) || !isset($current[$field])) {\n                return;\n            }\n            $current = &$current[$field];\n        }\n\n        unset($current[$var]);\n        $this->updated = true;\n    }\n\n    private function __construct(string $filename)\n    {\n        $this->filename = $filename;\n        $content = is_file($filename) ? file_get_contents($filename) : null;\n        if (false === $content) {\n            throw new \\RuntimeException('Versions file cannot be read');\n        }\n        $this->items = $content ? Yaml::parse($content) : [];\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Installer/YamlUpdater.php",
    "content": "<?php\n\n/**\n * @package    Grav\\Installer\n *\n * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.\n * @license    MIT License; see LICENSE file for details.\n */\n\nnamespace Grav\\Installer;\n\nuse Grav\\Common\\Utils;\nuse Symfony\\Component\\Yaml\\Yaml;\nuse function assert;\nuse function count;\nuse function is_array;\nuse function strlen;\n\n/**\n * Grav YAML updater.\n *\n * NOTE: This class can be initialized during upgrade from an older version of Grav. Make sure it runs there!\n */\nfinal class YamlUpdater\n{\n    /** @var string */\n    protected $filename;\n    /** @var string[]  */\n    protected $lines;\n    /** @var array */\n    protected $comments;\n    /** @var array */\n    protected $items;\n    /** @var bool */\n    protected $updated = false;\n\n    /** @var self[] */\n    protected static $instance;\n\n    public static function instance(string $filename): self\n    {\n        if (!isset(self::$instance[$filename])) {\n            self::$instance[$filename] = new self($filename);\n        }\n\n        return self::$instance[$filename];\n    }\n\n    /**\n     * @return bool\n     */\n    public function save(): bool\n    {\n        if (!$this->updated) {\n            return false;\n        }\n\n        try {\n            if (!$this->isHandWritten()) {\n                $yaml = Yaml::dump($this->items, 5, 2);\n            } else {\n                $yaml = implode(\"\\n\", $this->lines);\n\n                $items = Yaml::parse($yaml);\n                if ($items !== $this->items) {\n                    throw new \\RuntimeException('Failed saving the content');\n                }\n            }\n\n            file_put_contents($this->filename, $yaml);\n\n        } catch (\\Exception $e) {\n            throw new \\RuntimeException('Failed to update ' . basename($this->filename) . ': ' . $e->getMessage());\n        }\n\n        return true;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isHandWritten(): bool\n    {\n        return !empty($this->comments);\n    }\n\n    /**\n     * @return array\n     */\n    public function getComments(): array\n    {\n        $comments = [];\n        foreach ($this->lines as $i => $line) {\n            if ($this->isLineEmpty($line)) {\n                $comments[$i+1] = $line;\n            } elseif ($comment = $this->getInlineComment($line)) {\n                $comments[$i+1] = $comment;\n            }\n        }\n\n        return $comments;\n    }\n\n    /**\n     * @param string $variable\n     * @param mixed $value\n     */\n    public function define(string $variable, $value): void\n    {\n        // If variable has already value, we're good.\n        if ($this->get($variable) !== null) {\n            return;\n        }\n\n        // If one of the parents isn't array, we're good, too.\n        if (!$this->canDefine($variable)) {\n            return;\n        }\n\n        $this->set($variable, $value);\n        if (!$this->isHandWritten()) {\n            return;\n        }\n\n        $parts = explode('.', $variable);\n\n        $lineNos = $this->findPath($this->lines, $parts);\n        $count = count($lineNos);\n        $last = array_key_last($lineNos);\n\n        $value = explode(\"\\n\", trim(Yaml::dump([$last => $this->get(implode('.', array_keys($lineNos)))], max(0, 5-$count), 2)));\n        $currentLine = array_pop($lineNos) ?: 0;\n        $parentLine = array_pop($lineNos);\n\n        if ($parentLine !== null) {\n            $c = $this->getLineIndentation($this->lines[$parentLine] ?? '');\n            $n = $this->getLineIndentation($this->lines[$parentLine+1] ?? $this->lines[$parentLine] ?? '');\n            $indent = $n > $c ? $n : $c + 2;\n        } else {\n            $indent = 0;\n            array_unshift($value, '');\n        }\n        $spaces = str_repeat(' ', $indent);\n        foreach ($value as &$line) {\n            $line = $spaces . $line;\n        }\n        unset($line);\n\n        array_splice($this->lines, abs($currentLine)+1, 0, $value);\n    }\n\n    public function undefine(string $variable): void\n    {\n        // If variable does not have value, we're good.\n        if ($this->get($variable) === null) {\n            return;\n        }\n\n        // If one of the parents isn't array, we're good, too.\n        if (!$this->canDefine($variable)) {\n            return;\n        }\n\n        $this->undef($variable);\n        if (!$this->isHandWritten()) {\n            return;\n        }\n\n        // TODO: support also removing property from handwritten configuration file.\n    }\n\n    private function __construct(string $filename)\n    {\n        $content = is_file($filename) ? (string)file_get_contents($filename) : '';\n        $content = rtrim(str_replace([\"\\r\\n\", \"\\r\"], \"\\n\", $content));\n\n        $this->filename = $filename;\n        $this->lines = explode(\"\\n\", $content);\n        $this->comments = $this->getComments();\n        $this->items = $content ? Yaml::parse($content) : [];\n    }\n\n    /**\n     * Return array of offsets for the parent nodes. Negative value means position, but not found.\n     *\n     * @param array $lines\n     * @param array $parts\n     * @return int[]\n     */\n    private function findPath(array $lines, array $parts)\n    {\n        $test = true;\n        $indent = -1;\n        $current = array_shift($parts);\n\n        $j = 1;\n        $found = [];\n        $space = '';\n        foreach ($lines as $i => $line) {\n            if ($this->isLineEmpty($line)) {\n                if ($this->isLineComment($line) && $this->getLineIndentation($line) > $indent) {\n                    $j = $i;\n                }\n                continue;\n            }\n\n            if ($test === true) {\n                $test = false;\n                $spaces = strlen($line) - strlen(ltrim($line, ' '));\n                if ($spaces <= $indent) {\n                    $found[$current] = -$j;\n\n                    return $found;\n                }\n\n                $indent = $spaces;\n                $space = $indent ? str_repeat(' ', $indent) : '';\n            }\n\n\n            if (0 === \\strncmp($line, $space, strlen($space))) {\n                $pattern = \"/^{$space}(['\\\"]?){$current}\\\\1\\:/\";\n\n                if (preg_match($pattern, $line)) {\n                    $found[$current] = $i;\n                    $current = array_shift($parts);\n                    if ($current === null) {\n                        return $found;\n                    }\n                    $test = true;\n                }\n            } else {\n                $found[$current] = -$j;\n\n                return $found;\n            }\n\n            $j = $i;\n        }\n\n        $found[$current] = -$j;\n\n        return $found;\n    }\n\n    /**\n     * Returns true if the current line is blank or if it is a comment line.\n     *\n     * @param string $line Contents of the line\n     * @return bool Returns true if the current line is empty or if it is a comment line, false otherwise\n     */\n    private function isLineEmpty(string $line): bool\n    {\n        return $this->isLineBlank($line) || $this->isLineComment($line);\n    }\n\n    /**\n     * Returns true if the current line is blank.\n     *\n     * @param string $line Contents of the line\n     * @return bool Returns true if the current line is blank, false otherwise\n     */\n    private function isLineBlank(string $line): bool\n    {\n        return '' === trim($line, ' ');\n    }\n\n    /**\n     * Returns true if the current line is a comment line.\n     *\n     * @param string $line Contents of the line\n     * @return bool Returns true if the current line is a comment line, false otherwise\n     */\n    private function isLineComment(string $line): bool\n    {\n        //checking explicitly the first char of the trim is faster than loops or strpos\n        $ltrimmedLine = ltrim($line, ' ');\n\n        return '' !== $ltrimmedLine && '#' === $ltrimmedLine[0];\n    }\n\n    /**\n     * @param string $line\n     * @return bool\n     */\n    private function isInlineComment(string $line): bool\n    {\n        return $this->getInlineComment($line) !== null;\n    }\n\n    /**\n     * @param string $line\n     * @return string|null\n     */\n    private function getInlineComment(string $line): ?string\n    {\n        $pos = strpos($line, ' #');\n        if (false === $pos) {\n            return null;\n        }\n\n        $parts = explode(' #', $line);\n        $part = '';\n        while ($part .= array_shift($parts)) {\n            // Remove quoted values.\n            $part = preg_replace('/(([\\'\"])[^\\2]*\\2)/', '', $part);\n            assert(null !== $part);\n            $part = preg_split('/[\\'\"]/', $part, 2);\n            assert(false !== $part);\n            if (!isset($part[1])) {\n                $part = $part[0];\n                array_unshift($parts, str_repeat(' ', strlen($part) - strlen(trim($part, ' '))));\n                break;\n            }\n            $part = $part[1];\n        }\n\n\n        return implode(' #', $parts);\n    }\n\n    /**\n     * Returns the current line indentation.\n     *\n     * @param string $line\n     * @return int The current line indentation\n     */\n    private function getLineIndentation(string $line): int\n    {\n        return \\strlen($line) - \\strlen(ltrim($line, ' '));\n    }\n\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @param string $name Dot separated path to the requested value.\n     * @param mixed $default Default value (or null).\n     * @return mixed Value.\n     */\n    private function get(string $name, $default = null)\n    {\n        $path = explode('.', $name);\n        $current = $this->items;\n\n        foreach ($path as $field) {\n            if (is_array($current) && isset($current[$field])) {\n                $current = $current[$field];\n            } else {\n                return $default;\n            }\n        }\n\n        return $current;\n    }\n\n    /**\n     * Set value by using dot notation for nested arrays/objects.\n     *\n     * @param string $name Dot separated path to the requested value.\n     * @param mixed $value New value.\n     */\n    private function set(string $name, $value): void\n    {\n        $path = explode('.', $name);\n        $current = &$this->items;\n\n        foreach ($path as $field) {\n            // Handle arrays and scalars.\n            if (!is_array($current)) {\n                $current = [$field => []];\n            } elseif (!isset($current[$field])) {\n                $current[$field] = [];\n            }\n            $current = &$current[$field];\n        }\n\n        $current = $value;\n        $this->updated = true;\n    }\n\n    /**\n     * Unset value by using dot notation for nested arrays/objects.\n     *\n     * @param string $name Dot separated path to the requested value.\n     */\n    private function undef(string $name): void\n    {\n        $path = $name !== '' ? explode('.', $name) : [];\n        if (!$path) {\n            return;\n        }\n\n        $var = array_pop($path);\n        $current = &$this->items;\n\n        foreach ($path as $field) {\n            if (!is_array($current) || !isset($current[$field])) {\n                return;\n            }\n            $current = &$current[$field];\n        }\n\n        unset($current[$var]);\n        $this->updated = true;\n    }\n\n    /**\n     * Get value by using dot notation for nested arrays/objects.\n     *\n     * @param string $name Dot separated path to the requested value.\n     * @return bool\n     */\n    private function canDefine(string $name): bool\n    {\n        $path = explode('.', $name);\n        $current = $this->items;\n\n        foreach ($path as $field) {\n            if (is_array($current)) {\n                if (!isset($current[$field])) {\n                    return true;\n                }\n                $current = $current[$field];\n            } else {\n                return false;\n            }\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "system/src/Grav/Installer/updates/1.7.0_2020-11-20_1.php",
    "content": "<?php\n\nuse Grav\\Installer\\InstallException;\nuse Grav\\Installer\\VersionUpdate;\nuse Grav\\Installer\\YamlUpdater;\n\nreturn [\n    'preflight' => null,\n    'postflight' =>\n        function () {\n            /** @var VersionUpdate $this */\n            try {\n                // Keep old defaults for backwards compatibility.\n                $yaml = YamlUpdater::instance(GRAV_ROOT . '/user/config/system.yaml');\n                $yaml->define('twig.autoescape', false);\n                $yaml->define('strict_mode.yaml_compat', true);\n                $yaml->define('strict_mode.twig_compat', true);\n                $yaml->define('strict_mode.blueprint_compat', true);\n                $yaml->save();\n            } catch (\\Exception $e) {\n                throw new InstallException('Could not update system configuration to maintain backwards compatibility', $e);\n            }\n        }\n];\n"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredBlockNode.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Compiler;\nuse Twig\\Node\\BlockNode;\n\nfinal class DeferredBlockNode extends BlockNode\n{\n    public function compile(Compiler $compiler) : void\n    {\n        $name = $this->getAttribute('name');\n\n        $compiler\n            ->write(\"public function block_$name(\\$context, array \\$blocks = [])\\n\", \"{\\n\")\n            ->indent()\n            ->write(\"\\$this->deferred->defer(\\$this, '$name');\\n\")\n            ->outdent()\n            ->write(\"}\\n\\n\")\n        ;\n\n        $compiler\n            ->addDebugInfo($this)\n            ->write(\"public function block_{$name}_deferred(\\$context, array \\$blocks = [])\\n\", \"{\\n\")\n            ->indent()\n            ->subcompile($this->getNode('body'))\n            ->write(\"\\$this->deferred->resolve(\\$this, \\$context, \\$blocks);\\n\")\n            ->outdent()\n            ->write(\"}\\n\\n\")\n        ;\n    }\n}\n"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredDeclareNode.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Compiler;\nuse Twig\\Node\\Node;\n\nfinal class DeferredDeclareNode extends Node\n{\n    public function compile(Compiler $compiler) : void\n    {\n        $compiler\n            ->write(\"private \\$deferred;\\n\")\n        ;\n    }\n}"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredExtension.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Environment;\nuse Twig\\Extension\\AbstractExtension;\nuse Twig\\Template;\n\nfinal class DeferredExtension extends AbstractExtension\n{\n    private $blocks = [];\n\n    public function getTokenParsers() : array\n    {\n        return [new DeferredTokenParser()];\n    }\n\n    public function getNodeVisitors() : array\n    {\n        if (Environment::VERSION_ID < 20000) {\n            // Twig 1.x support\n            return [new DeferredNodeVisitorCompat()];\n        }\n\n        return [new DeferredNodeVisitor()];\n    }\n\n    public function defer(Template $template, string $blockName) : void\n    {\n        $templateName = $template->getTemplateName();\n        $this->blocks[$templateName][] = $blockName;\n        $index = \\count($this->blocks[$templateName]) - 1;\n\n        \\ob_start(function (string $buffer) use ($index, $templateName) {\n            unset($this->blocks[$templateName][$index]);\n\n            return $buffer;\n        });\n    }\n\n    public function resolve(Template $template, array $context, array $blocks) : void\n    {\n        $templateName = $template->getTemplateName();\n        if (empty($this->blocks[$templateName])) {\n            return;\n        }\n\n        while ($blockName = \\array_pop($this->blocks[$templateName])) {\n            $buffer = \\ob_get_clean();\n\n            $blocks[$blockName] = [$template, 'block_'.$blockName.'_deferred'];\n            $template->displayBlock($blockName, $context, $blocks);\n\n            echo $buffer;\n        }\n\n        if ($parent = $template->getParent($context)) {\n            $this->resolve($parent, $context, $blocks);\n        }\n    }\n}\n"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredInitializeNode.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Compiler;\nuse Twig\\Node\\Node;\n\nfinal class DeferredInitializeNode extends Node\n{\n    public function compile(Compiler $compiler) : void\n    {\n        $compiler\n            ->write(\"\\$this->deferred = \\$this->env->getExtension('\".DeferredExtension::class.\"');\\n\")\n        ;\n    }\n}\n"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredNode.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Compiler;\nuse Twig\\Node\\Node;\n\nfinal class DeferredNode extends Node\n{\n    public function compile(Compiler $compiler) : void\n    {\n        $compiler\n            ->write(\"\\$this->deferred->resolve(\\$this, \\$context, \\$blocks);\\n\")\n        ;\n    }\n}\n"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredNodeVisitor.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Environment;\nuse Twig\\Node\\ModuleNode;\nuse Twig\\Node\\Node;\nuse Twig\\NodeVisitor\\NodeVisitorInterface;\n\nfinal class DeferredNodeVisitor implements NodeVisitorInterface\n{\n    private $hasDeferred = false;\n\n    public function enterNode(Node $node, Environment $env) : Node\n    {\n        if (!$this->hasDeferred && $node instanceof DeferredBlockNode) {\n            $this->hasDeferred = true;\n        }\n\n        return $node;\n    }\n\n    public function leaveNode(Node $node, Environment $env) : ?Node\n    {\n        if ($this->hasDeferred && $node instanceof ModuleNode) {\n            $node->getNode('constructor_end')->setNode('deferred_initialize', new DeferredInitializeNode());\n            $node->getNode('display_end')->setNode('deferred_resolve', new DeferredResolveNode());\n            $node->getNode('class_end')->setNode('deferred_declare', new DeferredDeclareNode());\n            $this->hasDeferred = false;\n        }\n\n        return $node;\n    }\n\n    public function getPriority() : int\n    {\n        return 0;\n    }\n}\n"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Environment;\nuse Twig\\Node\\ModuleNode;\nuse Twig\\Node\\Node;\nuse Twig\\NodeVisitor\\NodeVisitorInterface;\n\nfinal class DeferredNodeVisitorCompat implements NodeVisitorInterface\n{\n    private $hasDeferred = false;\n\n    /**\n     * @param \\Twig_NodeInterface $node\n     * @param Environment $env\n     * @return Node\n     */\n    public function enterNode(\\Twig_NodeInterface $node, Environment $env): Node\n    {\n        if (!$this->hasDeferred && $node instanceof DeferredBlockNode) {\n            $this->hasDeferred = true;\n        }\n\n        \\assert($node instanceof Node);\n\n        return $node;\n    }\n\n    /**\n     * @param \\Twig_NodeInterface $node\n     * @param Environment $env\n     * @return Node|null\n     */\n    public function leaveNode(\\Twig_NodeInterface $node, Environment $env): ?Node\n    {\n        if ($this->hasDeferred && $node instanceof ModuleNode) {\n            $node->getNode('constructor_end')->setNode('deferred_initialize', new DeferredInitializeNode());\n            $node->getNode('display_end')->setNode('deferred_resolve', new DeferredResolveNode());\n            $node->getNode('class_end')->setNode('deferred_declare', new DeferredDeclareNode());\n            $this->hasDeferred = false;\n        }\n\n        \\assert($node instanceof Node);\n\n        return $node;\n    }\n\n    /**\n     * @return int\n     */\n    public function getPriority() : int\n    {\n        return 0;\n    }\n}\n"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredResolveNode.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Compiler;\nuse Twig\\Node\\Node;\n\nfinal class DeferredResolveNode extends Node\n{\n    public function compile(Compiler $compiler) : void\n    {\n        $compiler\n            ->write(\"\\$this->deferred->resolve(\\$this, \\$context, \\$blocks);\\n\")\n        ;\n    }\n}\n"
  },
  {
    "path": "system/src/Twig/DeferredExtension/DeferredTokenParser.php",
    "content": "<?php\n\n/**\n * This file is part of the rybakit/twig-deferred-extension package.\n *\n * (c) Eugene Leonovich <gen.work@gmail.com>\n *\n * For the full copyright and license information, please view the LICENSE\n * file that was distributed with this source code.\n */\n\ndeclare(strict_types=1);\n\nnamespace Twig\\DeferredExtension;\n\nuse Twig\\Node\\BlockNode;\nuse Twig\\Node\\Node;\nuse Twig\\Parser;\nuse Twig\\Token;\nuse Twig\\TokenParser\\AbstractTokenParser;\nuse Twig\\TokenParser\\BlockTokenParser;\n\nfinal class DeferredTokenParser extends AbstractTokenParser\n{\n    private $blockTokenParser;\n\n    public function setParser(Parser $parser) : void\n    {\n        parent::setParser($parser);\n\n        $this->blockTokenParser = new BlockTokenParser();\n        $this->blockTokenParser->setParser($parser);\n    }\n\n    public function parse(Token $token) : Node\n    {\n        $stream = $this->parser->getStream();\n        $nameToken = $stream->next();\n        $deferredToken = $stream->nextIf(Token::NAME_TYPE, 'deferred');\n        $stream->injectTokens([$nameToken]);\n\n        $node = $this->blockTokenParser->parse($token);\n\n        if ($deferredToken) {\n            $this->replaceBlockNode($nameToken->getValue());\n        }\n\n        return $node;\n    }\n\n    public function getTag() : string\n    {\n        return 'block';\n    }\n\n    private function replaceBlockNode(string $name) : void\n    {\n        $block = $this->parser->getBlock($name)->getNode('0');\n        $this->parser->setBlock($name, $this->createDeferredBlockNode($block));\n    }\n\n    private function createDeferredBlockNode(BlockNode $block) : DeferredBlockNode\n    {\n        $name = $block->getAttribute('name');\n        $deferredBlock = new DeferredBlockNode($name, new Node([]), $block->getTemplateLine());\n\n        foreach ($block as $nodeName => $node) {\n            $deferredBlock->setNode($nodeName, $node);\n        }\n\n        if ($sourceContext = $block->getSourceContext()) {\n            $deferredBlock->setSourceContext($sourceContext);\n        }\n\n        return $deferredBlock;\n    }\n}\n"
  },
  {
    "path": "system/templates/default.html.twig",
    "content": "{# Default output if no theme #}\n<h3 style=\"color:red\">ERROR: <code>{{ page.template() ~'.'~ page.templateFormat() ~\".twig\" }}</code> template not found for page: <code>{{ page.route() }}</code></h3>\n<h1>{{ page.title() }}</h1>\n{{ page.content()|raw }}\n"
  },
  {
    "path": "system/templates/external.html.twig",
    "content": "{# Default external template #}\n"
  },
  {
    "path": "system/templates/flex/404.html.twig",
    "content": "{% set item = collection ?? object %}\n{% set type = collection ? 'collection' : 'object' %}\n\nERROR: Layout '{{ layout }}' for flex {{ type }} '{{ item.flexType() }}'  was not found."
  },
  {
    "path": "system/templates/flex/_default/collection/debug.html.twig",
    "content": "<h1>{{ directory.getTitle() }} <small>debug dump</small></h1>\n\n{% for object in collection %}\n    {% render object layout: layout %}\n{% endfor %}\n"
  },
  {
    "path": "system/templates/flex/_default/object/debug.html.twig",
    "content": "<section>\n    <h2>{{ object.key }}</h2>\n    <pre>{{ object.jsonSerialize()|yaml_encode }}</pre>\n</section>"
  },
  {
    "path": "system/templates/modular/default.html.twig",
    "content": "{# Default output if no theme #}\n<h3 style=\"color:red\">ERROR: <code>{{ page.template() ~'.'~ page.templateFormat() ~\".twig\" }}</code> template not found for page: <code>{{ page.route() }}</code></h3>\n<h1>{{ page.title() }}</h1>\n{{ page.content()|raw }}\n"
  },
  {
    "path": "system/templates/partials/messages.html.twig",
    "content": "{% set status_mapping = {'info':'green', 'error': 'red', 'warning': 'yellow'} %}\n\n{% if grav.messages.all %}\n    <div id=\"messages\">\n        {% for message in grav.messages.fetch %}\n\n            {% set scope = message.scope|e %}\n            {% set color = status_mapping[scope] %}\n\n            <div class=\"notices {{ scope }} {{ color }}\"><p>{{ message.message|raw }}</p></div>\n\n        {% endfor %}\n    </div>\n{% endif %}\n"
  },
  {
    "path": "system/templates/partials/metadata.html.twig",
    "content": "{% for meta in page.metadata %}\n    <meta {% if meta.name %}name=\"{{ meta.name|e }}\" {% endif %}{% if meta.http_equiv %}http-equiv=\"{{ meta.http_equiv|e }}\" {% endif %}{% if meta.charset %}charset=\"{{ meta.charset|e }}\" {% endif %}{% if meta.property %}property=\"{{ meta.property|e }}\" {% endif %}{% if meta.content %}content=\"{{ meta.content|raw }}\" {% endif %}/>\n{% endfor %}\n"
  },
  {
    "path": "tests/_bootstrap.php",
    "content": "<?php\nnamespace Grav;\n\nuse Codeception\\Util\\Fixtures;\nuse Faker\\Factory;\nuse Grav\\Common\\Grav;\n\nini_set('error_log', __DIR__ . '/error.log');\n\n$grav = function () {\n    Grav::resetInstance();\n    $grav = Grav::instance();\n    $grav['config']->init();\n\n    // This must be set first before the other init\n    $grav['config']->set('system.languages.supported', ['en', 'fr', 'vi']);\n    $grav['config']->set('system.languages.default_lang', 'en');\n\n    foreach (array_keys($grav['setup']->getStreams()) as $stream) {\n        @stream_wrapper_unregister($stream);\n    }\n\n    $grav['streams'];\n\n    $grav['uri']->init();\n    $grav['debugger']->init();\n    $grav['assets']->init();\n\n    $grav['config']->set('system.cache.enabled', false);\n    $grav['locator']->addPath('tests', '', 'tests', false);\n\n    return $grav;\n};\n\nFixtures::add('grav', $grav);\n"
  },
  {
    "path": "tests/_support/AcceptanceTester.php",
    "content": "<?php\n\n\n/**\n * Inherited Methods\n * @method void wantToTest($text)\n * @method void wantTo($text)\n * @method void execute($callable)\n * @method void expectTo($prediction)\n * @method void expect($prediction)\n * @method void amGoingTo($argumentation)\n * @method void am($role)\n * @method void lookForwardTo($achieveValue)\n * @method void comment($description)\n * @method \\Codeception\\Lib\\Friend haveFriend($name, $actorClass = NULL)\n *\n * @SuppressWarnings(PHPMD)\n*/\nclass AcceptanceTester extends \\Codeception\\Actor\n{\n    use _generated\\AcceptanceTesterActions;\n\n   /**\n    * Define custom actions here\n    */\n}\n"
  },
  {
    "path": "tests/_support/FunctionalTester.php",
    "content": "<?php\n\n\n/**\n * Inherited Methods\n * @method void wantToTest($text)\n * @method void wantTo($text)\n * @method void execute($callable)\n * @method void expectTo($prediction)\n * @method void expect($prediction)\n * @method void amGoingTo($argumentation)\n * @method void am($role)\n * @method void lookForwardTo($achieveValue)\n * @method void comment($description)\n * @method \\Codeception\\Lib\\Friend haveFriend($name, $actorClass = NULL)\n *\n * @SuppressWarnings(PHPMD)\n*/\nclass FunctionalTester extends \\Codeception\\Actor\n{\n    use _generated\\FunctionalTesterActions;\n\n   /**\n    * Define custom actions here\n    */\n}\n"
  },
  {
    "path": "tests/_support/Helper/Acceptance.php",
    "content": "<?php\nnamespace Helper;\n\n// here you can define custom actions\n// all public methods declared in helper class will be available in $I\n\nclass Acceptance extends \\Codeception\\Module\n{\n\n}\n"
  },
  {
    "path": "tests/_support/Helper/Functional.php",
    "content": "<?php\nnamespace Helper;\n\n// here you can define custom actions\n// all public methods declared in helper class will be available in $I\n\nclass Functional extends \\Codeception\\Module\n{\n\n}\n"
  },
  {
    "path": "tests/_support/Helper/Unit.php",
    "content": "<?php\nnamespace Helper;\n\nuse Codeception;\n\n// here you can define custom actions\n// all public methods declared in helper class will be available in $I\n\n/**\n * Class Unit\n * @package Helper\n */\nclass Unit extends Codeception\\Module\n{\n    /**\n     * HOOK: used after configuration is loaded\n     */\n    public function _initialize()\n    {\n    }\n\n    /**\n     * HOOK: on every Actor class initialization\n     */\n    public function _cleanup()\n    {\n    }\n\n    /**\n     * HOOK: before suite\n     *\n     * @param array $settings\n     */\n    public function _beforeSuite($settings = [])\n    {\n    }\n\n    /**\n     * HOOK: after suite\n     **/\n    public function _afterSuite()\n    {\n    }\n\n    /**\n     * HOOK: before each step\n     *\n     * @param Codeception\\Step $step*\n     */\n    public function _beforeStep(Codeception\\Step $step)\n    {\n    }\n\n    /**\n     * HOOK: after each step\n     *\n     * @param Codeception\\Step $step\n     */\n    public function _afterStep(Codeception\\Step $step)\n    {\n    }\n\n    /**\n     * HOOK: before each suite\n     *\n     * @param Codeception\\TestCase $test\n     */\n    public function _before(Codeception\\TestCase $test)\n    {\n    }\n\n    /**\n     * HOOK: before each suite\n     *\n     * @param Codeception\\TestCase $test\n     */\n    public function _after(Codeception\\TestCase $test)\n    {\n    }\n\n    /**\n     * HOOK: on fail\n     *\n     * @param Codeception\\TestCase $test\n     * @param $fail\n     */\n    public function _failed(Codeception\\TestCase $test, $fail)\n    {\n    }\n}\n"
  },
  {
    "path": "tests/_support/UnitTester.php",
    "content": "<?php\n\n\n/**\n * Inherited Methods\n * @method void wantToTest($text)\n * @method void wantTo($text)\n * @method void execute($callable)\n * @method void expectTo($prediction)\n * @method void expect($prediction)\n * @method void amGoingTo($argumentation)\n * @method void am($role)\n * @method void lookForwardTo($achieveValue)\n * @method void comment($description)\n * @method \\Codeception\\Lib\\Friend haveFriend($name, $actorClass = NULL)\n *\n * @SuppressWarnings(PHPMD)\n*/\nclass UnitTester extends \\Codeception\\Actor\n{\n    use _generated\\UnitTesterActions;\n\n   /**\n    * Define custom actions here\n    */\n}\n"
  },
  {
    "path": "tests/acceptance/_bootstrap.php",
    "content": "<?php\n// Here you can initialize variables that will be available to your tests\n"
  },
  {
    "path": "tests/acceptance.suite.yml",
    "content": "# Codeception Test Suite Configuration\n#\n# Suite for acceptance tests.\n# Perform tests in browser using the WebDriver or PhpBrowser.\n# If you need both WebDriver and PHPBrowser tests - create a separate suite.\n\nclass_name: AcceptanceTester\nmodules:\n    enabled:\n        - PhpBrowser:\n            url: http://localhost:8080/grav\n        - \\Helper\\Acceptance"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/01.item1-1/01.item1-1-1/default.md",
    "content": "---\ntitle: Item 1-1-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/01.item1-1/02.item1-1-2/default.md",
    "content": "---\ntitle: Item 1-1-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/01.item1-1/03.item1-1-3/default.md",
    "content": "---\ntitle: Item 1-1-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/01.item1-1/default.md",
    "content": "---\ntitle: Item 1-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/02.item1-2/01.item1-2-1/default.md",
    "content": "---\ntitle: Item 1-2-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/02.item1-2/02.item1-2-2/default.md",
    "content": "---\ntitle: Item 1-2-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/02.item1-2/03.item1-2-3/default.md",
    "content": "---\ntitle: Item 1-2-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/02.item1-2/default.md",
    "content": "---\ntitle: Item 1-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/03.item1-3/01.item1-3-1/default.md",
    "content": "---\ntitle: Item 1-3-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/03.item1-3/02.item1-3-2/default.md",
    "content": "---\ntitle: Item 1-3-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/03.item1-3/03.item1-3-3/default.md",
    "content": "---\ntitle: Item 1-3-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/03.item1-3/default.md",
    "content": "---\ntitle: Item 1-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/01.item1/default.md",
    "content": "---\ntitle: Item 1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/01.item2-1/01.item2-1-1/default.md",
    "content": "---\ntitle: Item 2-1-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/01.item2-1/02.item2-1-2/default.md",
    "content": "---\ntitle: Item 2-1-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/01.item2-1/03.item2-1-3/default.md",
    "content": "---\ntitle: Item 2-1-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/01.item2-1/default.md",
    "content": "---\ntitle: Item 2-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/02.item2-2/01.item2-2-1/default.md",
    "content": "---\ntitle: Item 2-2-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/02.item2-2/02.item2-2-2/default.md",
    "content": "---\ntitle: Item 2-2-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/02.item2-2/03.item2-2-3/default.md",
    "content": "---\ntitle: Item 2-2-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/02.item2-2/default.md",
    "content": "---\ntitle: Item 2-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/03.item2-3/01.item2-3-1/default.md",
    "content": "---\ntitle: Item 2-3-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/03.item2-3/02.item2-3-2/default.md",
    "content": "---\ntitle: Item 2-3-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/03.item2-3/03.item2-3-3/default.md",
    "content": "---\ntitle: Item 2-3-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/03.item2-3/default.md",
    "content": "---\ntitle: Item 2-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/02.item2/default.md",
    "content": "---\ntitle: Item 2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/01.item3-1/01.item3-1-1/default.md",
    "content": "---\ntitle: Animal\ntaxonomy:\n    tag: [animal, cat]\n---\n\n<h1>Tags: animal</h1\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/01.item3-1/02.item3-1-2/default.md",
    "content": "---\ntitle: Item 3-1-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/01.item3-1/03.item3-1-3/default.md",
    "content": "---\ntitle: Item 3-1-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/01.item3-1/default.md",
    "content": "---\ntitle: Item 2-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/02.item3-2/01.item3-2-1/default.md",
    "content": "---\ntitle: Item 3-2-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/02.item3-2/02.item3-2-2/default.md",
    "content": "---\ntitle: Item 3-2-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/02.item3-2/03.item3-2-3/default.md",
    "content": "---\ntitle: Item 3-2-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/02.item3-2/default.md",
    "content": "---\ntitle: Item 2-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/03.item3-3/01.item3-3-1/default.md",
    "content": "---\ntitle: Item 3-3-1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/03.item3-3/02.item3-3-2/default.md",
    "content": "---\ntitle: Item 3-3-2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/03.item3-3/03.item3-3-3/default.md",
    "content": "---\ntitle: Item 3-3-3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/03.item3-3/default.md",
    "content": "---\ntitle: Item 2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/nested-site/user/pages/03.item3/default.md",
    "content": "---\ntitle: Item 3\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
  },
  {
    "path": "tests/fake/simple-site/user/pages/01.home/default.md",
    "content": "---\ntitle: Home\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum."
  },
  {
    "path": "tests/fake/simple-site/user/pages/02.blog/blog.md",
    "content": "---\ntitle: Blog\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum."
  },
  {
    "path": "tests/fake/simple-site/user/pages/02.blog/post-one/item.md",
    "content": "---\ntitle: Post 1\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum."
  },
  {
    "path": "tests/fake/simple-site/user/pages/02.blog/post-two/item.md",
    "content": "---\ntitle: Post 2\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum."
  },
  {
    "path": "tests/fake/simple-site/user/pages/03.about/default.md",
    "content": "---\ntitle: About\n---\n\nLorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod\ntempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,\nquis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo\nconsequat. Duis aute irure dolor in reprehenderit in voluptate velit esse\ncillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non\nproident, sunt in culpa qui officia deserunt mollit anim id est laborum."
  },
  {
    "path": "tests/fake/simple-site/user/pages/04.page-translated/default.en.md",
    "content": ""
  },
  {
    "path": "tests/fake/simple-site/user/pages/04.page-translated/default.fr.md",
    "content": "---\ntitle: Simple Page avec traduction\n---\n\nSimple Page Content in English"
  },
  {
    "path": "tests/fake/simple-site/user/pages/05.translatedlong/part2/default.en.md",
    "content": ""
  },
  {
    "path": "tests/fake/simple-site/user/pages/05.translatedlong/part2/default.fr.md",
    "content": "---\ntitle: Simple Page avec traduction\n---\n\nPage Simple FR"
  },
  {
    "path": "tests/fake/single-page-translated/user/pages/01.simple-page/default.en.md",
    "content": ""
  },
  {
    "path": "tests/fake/single-page-translated/user/pages/01.simple-page/default.fr.md",
    "content": "---\ntitle: Simple Page avec traduction\n---\n\nPage Simple FR"
  },
  {
    "path": "tests/fake/single-pages/01.simple-page/default.md",
    "content": "---\ntitle: Simple Page\n---\n\nSimple Page Content"
  },
  {
    "path": "tests/functional/Grav/Console/DirectInstallCommandTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\Gpm\\DirectInstallCommand;\n\n/**\n * Class DirectInstallCommandTest\n */\nclass DirectInstallCommandTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var DirectInstallCommand */\n    protected $directInstall;\n\n\n    protected function _before(): void\n    {\n        $this->grav = Fixtures::get('grav');\n        $this->directInstallCommand = new DirectInstallCommand();\n    }\n}\n\n/**\n * Why this test file is empty\n *\n * Wasn't able to call a symfony\\console. Kept having $output problem.\n * symfony console \\NullOutput didn't cut it.\n *\n * We would also need to Mock tests since downloading packages would\n * make tests slow and unreliable. But it's not worth the time ATM.\n *\n * Look at Gpm/InstallCommandTest.php\n *\n * For the full story: https://git.io/vSlI3\n */\n"
  },
  {
    "path": "tests/functional/_bootstrap.php",
    "content": "<?php\n// Here you can initialize variables that will be available to your tests\n"
  },
  {
    "path": "tests/functional.suite.yml",
    "content": "# Codeception Test Suite Configuration\n#\n# Suite for functional (integration) tests\n# Emulate web requests and make application process them\n# Include one of framework modules (Symfony2, Yii2, Laravel5) to use it\n\nclass_name: FunctionalTester\nmodules:\n    enabled:\n        # add framework module here\n        - \\Helper\\Functional"
  },
  {
    "path": "tests/phpstan/classes/Toolbox/UniformResourceLocatorExtension.php",
    "content": "<?php\n\nnamespace PHPStan\\Toolbox;\n\nuse PhpParser\\Node\\Expr\\MethodCall;\nuse PHPStan\\Analyser\\Scope;\nuse PHPStan\\Reflection\\MethodReflection;\nuse PHPStan\\Reflection\\ParametersAcceptorSelector;\nuse PHPStan\\Type\\DynamicMethodReturnTypeExtension;\nuse PHPStan\\Type\\StringType;\nuse PHPStan\\Type\\Type;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n/**\n * Extension to handle UniformResourceLocator return types.\n */\nclass UniformResourceLocatorExtension implements DynamicMethodReturnTypeExtension\n{\n    /**\n     * @return string\n     */\n    public function getClass(): string\n    {\n        return UniformResourceLocator::class;\n    }\n\n    /**\n     * @param MethodReflection $methodReflection\n     * @return bool\n     */\n    public function isMethodSupported(MethodReflection $methodReflection): bool\n    {\n        return $methodReflection->getName() === 'findResource';\n    }\n\n    /**\n     * @param MethodReflection $methodReflection\n     * @param MethodCall $methodCall\n     * @param Scope $scope\n     * @return Type\n     */\n    public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type\n    {\n        $first = $methodCall->getArgs()[2] ?? false;\n        if ($first) {\n            return new StringType();\n        }\n\n        return ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType();\n    }\n}\n"
  },
  {
    "path": "tests/phpstan/extension.neon",
    "content": "services:\n    -\n        class: PHPStan\\Toolbox\\UniformResourceLocatorExtension\n        tags:\n            - phpstan.broker.dynamicMethodReturnTypeExtension\n"
  },
  {
    "path": "tests/phpstan/phpstan-bootstrap.php",
    "content": "<?php declare(strict_types=1);\n/**\n *To help phpstan dealing with LogicException in Common\\User\\User.php\n */\n\ndefine('GRAV_USER_INSTANCE', 'FLEX');\ndefine('GRAV_REQUEST_TIME', microtime(true));\n"
  },
  {
    "path": "tests/phpstan/phpstan.neon",
    "content": "#phpVersion: 70300\nincludes:\n    #- '../../vendor/phpstan/phpstan-strict-rules/rules.neon'\n    - '../../vendor/phpstan/phpstan-deprecation-rules/rules.neon'\n    - 'extension.neon'\nparameters:\n    fileExtensions:\n        - php\n        - dist\n    bootstrapFiles:\n        - phpstan-bootstrap.php\n    excludePaths:\n        - */system/src/Grav/Common/Errors/Resources/layout.html.php\n        - */system/src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php    # Twig 1\n        - */system/src/Twig/DeferredExtension/DeferredNodeVisitor.php          # Twig 2+3\n        # Ignore vendor dev dependencies and tests\n        - */vendor/*/*/tests\n        - */vendor/behat\n        - */vendor/codeception\n        - */vendor/phpstan\n        - */vendor/phpunit\n        - */vendor/phpspec\n        - */vendor/phpdocumentor\n        - */vendor/sebastian\n        - */vendor/theseer\n        - */vendor/webmozart\n\n    inferPrivatePropertyTypeFromConstructor: true\n    reportUnmatchedIgnoredErrors: false\n    treatPhpDocTypesAsCertain: false\n\n    # These checks are new in phpstan 1, ignore them for now.\n    checkMissingIterableValueType: false\n    #checkGenericClassInNonGenericObjectType: false\n\n    universalObjectCratesClasses:\n        - Grav\\Common\\Config\\Config\n        - Grav\\Common\\Config\\Languages\n        - Grav\\Common\\Config\\Setup\n        - Grav\\Common\\Data\\Data\n        - Grav\\Common\\GPM\\Common\\Package\n        - Grav\\Common\\GPM\\Local\\Package\n        - Grav\\Common\\GPM\\Remote\\Package\n        - Grav\\Common\\Page\\Header\n        - Grav\\Common\\Session\n    dynamicConstantNames:\n        - GRAV_CLI\n    ignoreErrors:\n        # New in phpstan 1, ignore them for now.\n        - '#Unsafe usage of new static\\(\\)#'\n\n        # TODO: Errors that needs some more thinking (bad design?)\n        - '#Access to an undefined property RocketTheme\\\\Toolbox\\\\Event\\\\Event::#'\n        - '#Instantiation of deprecated class RocketTheme\\\\Toolbox\\\\Event\\\\Event#'\n        - '#extends deprecated class RocketTheme\\\\Toolbox\\\\Event\\\\Event#'\n        - '#Access to an undefined property Grav\\\\Common\\\\Data\\\\Blueprint::#'\n        -\n            message: '#Cannot call method path\\(\\) on string#'\n            path: '*/system/src/Grav/Common/Page/Media.php'\n\n        # TODO: system.twig.umask_fix will not work with Twig 2 anymore\n        -\n            message: '#Call to deprecated method writeCacheFile\\(\\) of class Twig\\\\Environment#'\n            path: '*/system/src/Grav/Common/Twig/WriteCacheFileTrait.php'\n\n        # PSR-16 Exception interfaces do not extend \\Throwable\n        - '#PHPDoc tag \\@throws with type Psr\\\\SimpleCache\\\\(CacheException|InvalidArgumentException) is not subtype of Throwable#'\n\n        # Medium __call() methods\n        - '#Call to an undefined method Grav\\\\Common\\\\Page\\\\Medium\\\\(\\w*)Medium::#'\n\n        # These errors are about plugins (need to find a better solution)\n        -\n            message: '#Call to static method sendEmail\\(\\) on an unknown class Grav\\\\Plugin\\\\Email\\\\Utils#'\n            path: '*/system/src/Grav/Common/Scheduler/Job.php'\n        -\n            message: '#unknown class Grav\\\\Plugin\\\\Admin#'\n            path: '*/system/src/Grav/Common/Page/Pages.php'\n        -\n            message: '#unknown class Grav\\\\Plugin\\\\Admin#'\n            path: '*/system/src/Grav/Common/Flex/Pages/PageObject.php'\n        -\n            message: '#unknown class Grav\\\\Plugin\\\\Admin\\\\Admin#'\n            path: '*/system/src/Grav/Common/Flex/Types/Pages/PageObject.php'\n        -\n            message: '#unknown class Grav\\\\Plugin\\\\Form\\\\Forms#'\n            path: '*/system/src/Grav/Common/Processors/PagesProcessor.php'\n\n        # Clockwork does not define functions for __call() call\n        -\n            message: '#Call to an undefined method Clockwork\\\\Clockwork::(info|userData|addEvent|alert)\\(\\)#'\n            path: '*/system/src/Grav/Common/Debugger.php'\n\n        # These errors can be ignored (they depend on installed extensions)\n        -\n            message: '#Instantiated class (Memcache|Memcached|Redis|RedisException) not found#'\n            path: '*/system/src/Grav/Common/Cache.php'\n        -\n            message: '#unknown class (Memcache|Memcached|Redis|RedisException)#'\n            path: '*/system/src/Grav/Common/Cache.php'\n        -\n            message: '#unknown class Collator#'\n            path: '*/system/src/Grav/Common/Page/Pages.php'\n        -\n            message: '#unknown class Collator#'\n            path: '*/system/src/Grav/Common/Flex/Types/Pages/PageCollection.php'\n        -\n            message: '#Ternary operator condition is always true#'\n            path: '*/system/src/Grav/Framework/Cache/AbstractCache.php'\n        -\n            message: '#Call to function is_object\\(\\) with int will always evaluate to false#'\n            path: '*/system/src/Grav/Framework/Cache/AbstractCache.php'\n\n        # XHprof\n        - '#tideways_xhprof_enable#'\n\n        # Support for deprecated features\n        -\n            message: '#Instantiation of deprecated class Doctrine\\\\Common\\\\Cache\\\\(\\w+)Cache#'\n            path: '*/system/src/Grav/Common/Cache.php'\n        -\n            message: '#Instantiation of deprecated class Doctrine\\\\Common\\\\Cache\\\\(\\w+)Cache#'\n            path: '*/system/src/Grav/Common/GPM/Remote/*.php'\n        -\n            message: '#Call to deprecated method order#'\n            path: '*/system/src/Grav/Common/Page/Pages.php'\n        -\n            message: '#Fetching class constant class of deprecated class Grav\\\\Common\\\\User\\\\User#'\n            path: '*/system/src/Grav/Common/Service/AccountsServiceProvider.php'\n        -\n            message: '#Call to deprecated method getLegacyFiles\\(\\)#'\n            path: '*/system/src/Grav/Common/Session.php'\n        -\n            message: '#Call to deprecated method \\w+\\(\\) of class Grav\\\\Common\\\\Flex\\\\Types\\\\Users\\\\UserObject#'\n            path: '*/system/src/Grav/Common/Flex/Types/Users/UserObject.php'\n        -\n            message: '#Call to deprecated method \\w+\\(\\) of class Grav\\\\Framework\\\\Flex\\\\FlexObject#'\n            path: '*/system/src/Grav/Framework/Flex/FlexObject.php'\n        -\n            message: '#Call to deprecated method \\w+\\(\\) of class Grav\\\\Framework\\\\Flex\\\\FlexIndex#'\n            path: '*/system/src/Grav/Framework/Flex/FlexIndex.php'\n        -\n            message: '#Call to deprecated method \\w+\\(\\) of class Grav\\\\Framework\\\\Flex\\\\FlexCollection#'\n            path: '*/system/src/Grav/Framework/Flex/FlexCollection.php'\n        -\n            message: '#Call to deprecated method (getAuthorizeScope|getActiveUser)\\(\\) of class Grav\\\\Framework\\\\Flex\\\\FlexObject#'\n            path: '*/system/src/Grav/Framework/Flex/Pages/Traits/PageAuthorsTrait.php'\n        -\n            message: '#deprecated class#'\n            path: '*/system/src/Grav/Framework/Uri/Uri.php'\n        -\n            message: '#Method Symfony\\\\Contracts\\\\EventDispatcher\\\\EventDispatcherInterface::dispatch#'\n            path: '*/system/src/Grav/Common/Grav.php'\n\n        - '#has typehint with deprecated class RocketTheme\\\\Toolbox\\\\Event\\\\Event#'\n        - '#Call to deprecated method stopPropagation\\(\\) of class Symfony\\\\Component\\\\EventDispatcher\\\\Event#'\n        - '#Parameter \\#2 \\$listener of method Symfony\\\\Component\\\\EventDispatcher\\\\EventDispatcher::addListener\\(\\)#'\n        - '#Parameter \\#2 \\$listener of method Symfony\\\\Component\\\\EventDispatcher\\\\EventDispatcher::removeListener\\(\\)#'\n        - '#Class Grav\\\\Common\\\\GPM\\\\Response not found#'\n\n        # Installer updates\n        -\n            message: '#Variable \\$this in PHPDoc tag @var does not exist#'\n            path: '*/system/src/Grav/Installer/updates/*'\n        -\n            message: '#YamlUpdater::isInlineComment\\(\\) is unused#'\n            path: '*/system/src/Grav/Installer/YamlUpdater.php'\n\n        # Twig Deferred extension compatibility\n        -\n            message: '#typehint with deprecated interface#'\n            path: '*/system/src/Twig/DeferredExtension/DeferredNodeVisitorCompat.php'\n        -\n            message: '#Function twig_array_filter not found#'\n            path: '*/system/src/Grav/Common/Twig/Extension/GravExtension.php'\n"
  },
  {
    "path": "tests/phpstan/plugins-bootstrap.php",
    "content": "<?php\n\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Plugin;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n\\define('GRAV_CLI', true);\n\\define('GRAV_REQUEST_TIME', microtime(true));\n\\define('GRAV_USER_INSTANCE', 'FLEX');\n\n$autoload = require __DIR__ . '/../../vendor/autoload.php';\n\nif (!ini_get('date.timezone')) {\n    date_default_timezone_set('UTC');\n}\n\nif (!file_exists(GRAV_ROOT . '/index.php')) {\n    exit('FATAL: Must be run from ROOT directory of Grav!');\n}\n\n$grav = Grav::instance(['loader' => $autoload]);\n$grav->setup('tests');\n$grav['config']->init();\n\n// Find all plugins in Grav installation and autoload their classes.\n\n/** @var UniformResourceLocator $locator */\n$locator = Grav::instance()['locator'];\n$iterator = $locator->getIterator('plugins://');\n/** @var DirectoryIterator $directory */\nforeach ($iterator as $directory) {\n    if (!$directory->isDir()) {\n        continue;\n    }\n    $plugin = $directory->getBasename();\n    $file = $directory->getPathname() . '/' . $plugin . '.php';\n    $classloader = null;\n    if (file_exists($file)) {\n        require_once $file;\n\n        $pluginClass = \"\\\\Grav\\\\Plugin\\\\{$plugin}Plugin\";\n\n        if (is_subclass_of($pluginClass, Plugin::class, true)) {\n            $class = new $pluginClass($plugin, $grav);\n            if (is_callable([$class, 'autoload'])) {\n                $classloader = $class->autoload();\n            }\n        }\n    }\n    if (null === $classloader) {\n        $autoloader = $directory->getPathname() . '/vendor/autoload.php';\n        if (file_exists($autoloader)) {\n            require $autoloader;\n        }\n    }\n}\n\ndefine('GANTRY_DEBUGGER', true);\ndefine('GANTRY5_DEBUG', true);\ndefine('GANTRY5_PLATFORM', 'grav');\ndefine('GANTRY5_ROOT', GRAV_ROOT);\ndefine('GANTRY5_VERSION', '@version@');\ndefine('GANTRY5_VERSION_DATE', '@versiondate@');\ndefine('GANTRYADMIN_PATH', '');\n"
  },
  {
    "path": "tests/phpstan/plugins.neon",
    "content": "includes:\n    #- '../../vendor/phpstan/phpstan-strict-rules/rules.neon'\n    - '../../vendor/phpstan/phpstan-deprecation-rules/rules.neon'\n    - 'extension.neon'\nparameters:\n    fileExtensions:\n        - php\n    excludePaths:\n        - %currentWorkingDirectory%/user/plugins/*/vendor/*\n        - %currentWorkingDirectory%/user/plugins/*/tests/*\n        - %currentWorkingDirectory%/user/plugins/gantry5/src/platforms\n        - %currentWorkingDirectory%/user/plugins/gantry5/src/classes/Gantry/Framework/Services/ErrorServiceProvider.php\n        # Ignore vendor dev dependencies and tests\n        - */vendor/*/*/tests\n        - */vendor/behat\n        - */vendor/codeception\n        - */vendor/phpstan\n        - */vendor/phpunit\n        - */vendor/phpspec\n        - */vendor/phpdocumentor\n        - */vendor/sebastian\n        - */vendor/theseer\n        - */vendor/webmozart\n    bootstrapFiles:\n        - plugins-bootstrap.php\n    inferPrivatePropertyTypeFromConstructor: true\n    reportUnmatchedIgnoredErrors: false\n\n    # These checks are new in phpstan 1, ignore them for now.\n    checkMissingIterableValueType: false\n    checkGenericClassInNonGenericObjectType: false\n\n    universalObjectCratesClasses:\n        - Grav\\Common\\Config\\Config\n        - Grav\\Common\\Config\\Languages\n        - Grav\\Common\\Config\\Setup\n        - Grav\\Common\\Data\\Data\n        - Grav\\Common\\GPM\\Common\\Package\n        - Grav\\Common\\GPM\\Local\\Package\n        - Grav\\Common\\GPM\\Remote\\Package\n        - Grav\\Common\\Page\\Header\n        - Grav\\Common\\Session\n        - Gantry\\Component\\Config\\Config\n    dynamicConstantNames:\n        - GRAV_CLI\n        - GANTRY_DEBUGGER\n        - GANTRY5_DEBUG\n        - GANTRY5_VERSION\n        - GANTRY5_VERSION_DATE\n        - GANTRY5_PLATFORM\n        - GANTRY5_ROOT\n    ignoreErrors:\n        # New in phpstan 1, ignore them for now.\n        - '#Unsafe usage of new static\\(\\)#'\n        - '#Cannot instantiate interface Grav\\\\Framework\\\\#'\n\n        # PSR-16 Exception interfaces do not extend \\Throwable\n        - '#PHPDoc tag \\@throws with type (.*|)?Psr\\\\SimpleCache\\\\(CacheException|InvalidArgumentException)(|.*)? is not subtype of Throwable#'\n\n        - '#Access to an undefined property RocketTheme\\\\Toolbox\\\\Event\\\\Event::#'\n        - '#Instantiation of deprecated class RocketTheme\\\\Toolbox\\\\Event\\\\Event#'\n        - '#extends deprecated class RocketTheme\\\\Toolbox\\\\Event\\\\Event#'\n        - '#implements deprecated interface RocketTheme\\\\Toolbox\\\\Event\\\\EventSubscriberInterface#'\n        - '#Call to method __construct\\(\\) of deprecated class RocketTheme\\\\Toolbox\\\\Event\\\\Event#'\n        - '#Call to deprecated method (stopPropagation|isPropagationStopped)\\(\\) of class Symfony\\\\Component\\\\EventDispatcher\\\\Event#'\n        - '#Call to an undefined method Grav\\\\Plugin\\\\ApartmentData\\\\Application\\\\Application::#'\n        - '#Parameter \\#1 \\$lineNumberStyle of method ScssPhp\\\\ScssPhp\\\\Compiler::setLineNumberStyle\\(\\) expects string, int given#'\n\n        # Deprecated event class\n        - '#has typehint with deprecated class RocketTheme\\\\Toolbox\\\\Event\\\\Event#'\n"
  },
  {
    "path": "tests/unit/Grav/Common/AssetsTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Assets;\n\n/**\n * Class AssetsTest\n */\nclass AssetsTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var Assets $assets */\n    protected $assets;\n\n    protected function _before(): void\n    {\n        $grav = Fixtures::get('grav');\n        $this->grav = $grav();\n        $this->assets = $this->grav['assets'];\n    }\n\n    protected function _after(): void\n    {\n    }\n\n    public function testAddingAssets(): void\n    {\n        //test add()\n        $this->assets->add('test.css');\n\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        $array = $this->assets->getCss();\n\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n           \"type\":\"css\",\n           \"elements\":{\n              \"asset\":\"\\/test.css\",\n              \"asset_type\":\"css\",\n              \"order\":0,\n              \"group\":\"head\",\n              \"position\":\"pipeline\",\n              \"priority\":10,\n              \"attributes\":{\n                 \"type\":\"text\\/css\",\n                 \"rel\":\"stylesheet\"\n              },\n              \"modified\":false,\n              \"query\":\"\"\n           }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        $this->assets->add('test.js');\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/test.js\"></script>' . PHP_EOL, $js);\n\n        $array = $this->assets->getJs();\n\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n           \"type\":\"js\",\n           \"elements\":{\n              \"asset\":\"\\/test.js\",\n              \"asset_type\":\"js\",\n              \"order\":0,\n              \"group\":\"head\",\n              \"position\":\"pipeline\",\n              \"priority\":10,\n              \"attributes\":[\n\n              ],\n              \"modified\":false,\n              \"query\":\"\"\n           }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        //test addCss(). Test adding asset to a separate group\n        $this->assets->reset();\n        $this->assets->addCSS('test.css');\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        $array = $this->assets->getCss();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n           \"type\":\"css\",\n           \"elements\":{\n              \"asset\":\"\\/test.css\",\n              \"asset_type\":\"css\",\n              \"order\":0,\n              \"group\":\"head\",\n              \"position\":\"pipeline\",\n              \"priority\":10,\n              \"attributes\":{\n                 \"type\":\"text\\/css\",\n                 \"rel\":\"stylesheet\"\n              },\n              \"modified\":false,\n              \"query\":\"\"\n           }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        //test addCss(). Testing with remote URL\n        $this->assets->reset();\n        $this->assets->addCSS('http://www.somesite.com/test.css');\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"http://www.somesite.com/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        $array = $this->assets->getCss();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n           \"type\":\"css\",\n           \"elements\":{\n              \"asset\":\"http:\\/\\/www.somesite.com\\/test.css\",\n              \"asset_type\":\"css\",\n              \"order\":0,\n              \"group\":\"head\",\n              \"position\":\"pipeline\",\n              \"priority\":10,\n              \"attributes\":{\n                 \"type\":\"text\\/css\",\n                 \"rel\":\"stylesheet\"\n              },\n              \"query\":\"\"\n           }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        //test addCss() adding asset to a separate group, and with an alternate rel attribute\n        $this->assets->reset();\n        $this->assets->addCSS('test.css', ['group' => 'alternate', 'rel' => 'alternate']);\n        $css = $this->assets->css('alternate');\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"alternate\">' . PHP_EOL, $css);\n\n        //test addJs()\n        $this->assets->reset();\n        $this->assets->addJs('test.js');\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/test.js\"></script>' . PHP_EOL, $js);\n\n        $array = $this->assets->getJs();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n           \"type\":\"js\",\n           \"elements\":{\n              \"asset\":\"\\/test.js\",\n              \"asset_type\":\"js\",\n              \"order\":0,\n              \"group\":\"head\",\n              \"position\":\"pipeline\",\n              \"priority\":10,\n              \"attributes\":[],\n              \"modified\":false,\n              \"query\":\"\"\n           }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        //Test CSS Groups\n        $this->assets->reset();\n        $this->assets->addCSS('test.css', ['group' => 'footer']);\n        $css = $this->assets->css();\n        self::assertEmpty($css);\n        $css = $this->assets->css('footer');\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        $array = $this->assets->getCss();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n          \"type\": \"css\",\n          \"elements\": {\n            \"asset\": \"/test.css\",\n            \"asset_type\": \"css\",\n            \"order\": 0,\n            \"group\": \"footer\",\n            \"position\": \"pipeline\",\n            \"priority\": 10,\n            \"attributes\": {\n              \"type\": \"text/css\",\n              \"rel\": \"stylesheet\"\n            },\n            \"modified\": false,\n            \"query\": \"\"\n          }\n        }\n        ';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        //Test JS Groups\n        $this->assets->reset();\n        $this->assets->addJs('test.js', ['group' => 'footer']);\n        $js = $this->assets->js();\n        self::assertEmpty($js);\n        $js = $this->assets->js('footer');\n        self::assertSame('<script src=\"/test.js\"></script>' . PHP_EOL, $js);\n\n        $array = $this->assets->getJs();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n          \"type\": \"js\",\n          \"elements\": {\n            \"asset\": \"/test.js\",\n            \"asset_type\": \"js\",\n            \"order\": 0,\n            \"group\": \"footer\",\n            \"position\": \"pipeline\",\n            \"priority\": 10,\n            \"attributes\": [],\n            \"modified\": false,\n            \"query\": \"\"\n          }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        //Test async / defer\n        $this->assets->reset();\n        $this->assets->addJs('test.js', ['loading' => 'async']);\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/test.js\" async></script>' . PHP_EOL, $js);\n\n        $array = $this->assets->getJs();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n          \"type\": \"js\",\n          \"elements\": {\n            \"asset\": \"/test.js\",\n            \"asset_type\": \"js\",\n            \"order\": 0,\n            \"group\": \"head\",\n            \"position\": \"pipeline\",\n            \"priority\": 10,\n            \"attributes\": {\n              \"loading\": \"async\"\n            },\n            \"modified\": false,\n            \"query\": \"\"\n          }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        $this->assets->reset();\n        $this->assets->addJs('test.js', ['loading' => 'defer']);\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/test.js\" defer></script>' . PHP_EOL, $js);\n\n        $array = $this->assets->getJs();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n          \"type\": \"js\",\n          \"elements\": {\n            \"asset\": \"/test.js\",\n            \"asset_type\": \"js\",\n            \"order\": 0,\n            \"group\": \"head\",\n            \"position\": \"pipeline\",\n            \"priority\": 10,\n            \"attributes\": {\n              \"loading\": \"defer\"\n            },\n            \"modified\": false,\n            \"query\": \"\"\n          }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        //Test inline\n        $this->assets->reset();\n        $this->assets->setJsPipeline(true);\n        $this->assets->addJs('/system/assets/jquery/jquery-3.x.min.js');\n        $js = $this->assets->js('head', ['loading' => 'inline']);\n        self::assertStringContainsString('\"jquery\",[],function()', $js);\n\n        $this->assets->reset();\n        $this->assets->setCssPipeline(true);\n        $this->assets->addCss('/system/assets/debugger/phpdebugbar.css');\n        $css = $this->assets->css('head', ['loading' => 'inline']);\n        self::assertStringContainsString('div.phpdebugbar', $css);\n\n        $this->assets->reset();\n        $this->assets->setCssPipeline(true);\n        $this->assets->addCss('https://fonts.googleapis.com/css?family=Roboto');\n        $css = $this->assets->css('head', ['loading' => 'inline']);\n        self::assertStringContainsString('font-family:\\'Roboto\\';', $css);\n\n        //Test adding media queries\n        $this->assets->reset();\n        $this->assets->add('test.css', ['media' => 'only screen and (min-width: 640px)']);\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\" media=\"only screen and (min-width: 640px)\">' . PHP_EOL, $css);\n    }\n\n    public function testAddingAssetPropertiesWithArray(): void\n    {\n        //Test adding assets with object to define properties\n        $this->assets->reset();\n        $this->assets->addJs('test.js', ['loading' => 'async']);\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/test.js\" async></script>' . PHP_EOL, $js);\n        $this->assets->reset();\n    }\n\n    public function testAddingJSAssetPropertiesWithArrayFromCollection(): void\n    {\n        //Test adding properties with array\n        $this->assets->reset();\n        $this->assets->addJs('jquery', ['loading' => 'async']);\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/system/assets/jquery/jquery-3.x.min.js\" async></script>' . PHP_EOL, $js);\n\n        //Test priority too\n        $this->assets->reset();\n        $this->assets->addJs('jquery', ['loading' => 'async', 'priority' => 1]);\n        $this->assets->addJs('test.js', ['loading' => 'async', 'priority' => 2]);\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/test.js\" async></script>' . PHP_EOL .\n            '<script src=\"/system/assets/jquery/jquery-3.x.min.js\" async></script>' . PHP_EOL, $js);\n\n        //Test multiple groups\n        $this->assets->reset();\n        $this->assets->addJs('jquery', ['loading' => 'async', 'priority' => 1, 'group' => 'footer']);\n        $this->assets->addJs('test.js', ['loading' => 'async', 'priority' => 2]);\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/test.js\" async></script>' . PHP_EOL, $js);\n        $js = $this->assets->js('footer');\n        self::assertSame('<script src=\"/system/assets/jquery/jquery-3.x.min.js\" async></script>' . PHP_EOL, $js);\n\n        //Test adding array of assets\n        //Test priority too\n        $this->assets->reset();\n        $this->assets->addJs(['jquery', 'test.js'], ['loading' => 'async']);\n        $js = $this->assets->js();\n\n        self::assertSame('<script src=\"/system/assets/jquery/jquery-3.x.min.js\" async></script>' . PHP_EOL .\n            '<script src=\"/test.js\" async></script>' . PHP_EOL, $js);\n    }\n\n    public function testAddingLegacyFormat(): void\n    {\n        // regular CSS add\n        //test addCss(). Test adding asset to a separate group\n        $this->assets->reset();\n        $this->assets->addCSS('test.css', 15, true, 'bottom', 'async');\n        $css = $this->assets->css('bottom');\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\" async>' . PHP_EOL, $css);\n\n        $array = $this->assets->getCss();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n           \"type\":\"css\",\n           \"elements\":{\n              \"asset\":\"\\/test.css\",\n              \"asset_type\":\"css\",\n              \"order\":0,\n              \"group\":\"bottom\",\n              \"position\":\"pipeline\",\n              \"priority\":15,\n              \"attributes\":{\n                 \"type\":\"text\\/css\",\n                 \"rel\":\"stylesheet\",\n                 \"loading\":\"async\"\n              },\n              \"modified\":false,\n              \"query\":\"\"\n           }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n        $this->assets->reset();\n        $this->assets->addJs('test.js', 15, false, 'defer', 'bottom');\n        $js = $this->assets->js('bottom');\n        self::assertSame('<script src=\"/test.js\" defer></script>' . PHP_EOL, $js);\n\n        $array = $this->assets->getJs();\n        /** @var Assets\\BaseAsset $item */\n        $item = reset($array);\n        $actual = json_encode($item);\n        $expected = '\n        {\n          \"type\": \"js\",\n          \"elements\": {\n            \"asset\": \"/test.js\",\n            \"asset_type\": \"js\",\n            \"order\": 0,\n            \"group\": \"bottom\",\n            \"position\": \"after\",\n            \"priority\": 15,\n            \"attributes\": {\n              \"loading\": \"defer\"\n            },\n            \"modified\": false,\n            \"query\": \"\"\n          }\n        }';\n        self::assertJsonStringEqualsJsonString($expected, $actual);\n\n\n        $this->assets->reset();\n        $this->assets->addInlineCss('body { color: black }', 15, 'bottom');\n        $css = $this->assets->css('bottom');\n        self::assertSame('<style>' . PHP_EOL . 'body { color: black }' . PHP_EOL . '</style>' . PHP_EOL, $css);\n\n        $this->assets->reset();\n        $this->assets->addInlineJs('alert(\"test\")', 15, 'bottom', ['id' => 'foo']);\n        $js = $this->assets->js('bottom');\n        self::assertSame('<script id=\"foo\">' . PHP_EOL . 'alert(\"test\")' . PHP_EOL . '</script>' . PHP_EOL, $js);\n    }\n\n    public function testAddingCSSAssetPropertiesWithArrayFromCollection(): void\n    {\n        $this->assets->registerCollection('test', ['/system/assets/whoops.css']);\n\n        //Test priority too\n        $this->assets->reset();\n        $this->assets->addCss('test', ['priority' => 1]);\n        $this->assets->addCss('test.css', ['priority' => 2]);\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL .\n            '<link href=\"/system/assets/whoops.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        //Test multiple groups\n        $this->assets->reset();\n        $this->assets->addCss('test', ['priority' => 1, 'group' => 'footer']);\n        $this->assets->addCss('test.css', ['priority' => 2]);\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n        $css = $this->assets->css('footer');\n        self::assertSame('<link href=\"/system/assets/whoops.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        //Test adding array of assets\n        //Test priority too\n        $this->assets->reset();\n        $this->assets->addCss(['test', 'test.css'], ['loading' => 'async']);\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/system/assets/whoops.css\" type=\"text/css\" rel=\"stylesheet\" async>' . PHP_EOL .\n            '<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\" async>' . PHP_EOL, $css);\n    }\n\n    public function testAddingAssetPropertiesWithArrayFromCollectionAndParameters(): void\n    {\n        $this->assets->registerCollection('collection_multi_params', [\n            'foo.js' => [ 'defer' => true ],\n            'bar.js' => [ 'integrity' => 'sha512-abc123' ],\n            'foobar.css' => [ 'defer' => null, 'loading' => null ]\n        ]);\n\n        // # Test adding properties with array\n        $this->assets->addJs('collection_multi_params', ['loading' => 'async']);\n        $js = $this->assets->js();\n\n        // expected output\n        $expected = [\n            '<script src=\"/foo.js\" async defer=\"1\"></script>',\n            '<script src=\"/bar.js\" async integrity=\"sha512-abc123\"></script>',\n            '<script src=\"/foobar.css\"></script>',\n        ];\n\n        self::assertCount(count($expected), array_filter(explode(\"\\n\", $js)));\n        self::assertSame(implode(\"\\n\", $expected) . PHP_EOL, $js);\n\n        // # Test priority as second argument + render JS should not have any css\n        $this->assets->reset();\n        $this->assets->add('low_priority.js', 1);\n        $this->assets->add('collection_multi_params', 2);\n        $js = $this->assets->js();\n\n        // expected output\n        $expected = [\n            '<script src=\"/foo.js\" defer=\"1\"></script>',\n            '<script src=\"/bar.js\" integrity=\"sha512-abc123\"></script>',\n            '<script src=\"/low_priority.js\"></script>',\n        ];\n\n        self::assertCount(3, array_filter(explode(\"\\n\", $js)));\n        self::assertSame(implode(\"\\n\", $expected) . PHP_EOL, $js);\n\n        // # Test rendering CSS, should not have any JS\n        $this->assets->reset();\n        $this->assets->add('collection_multi_params', [ 'class' => '__classname' ]);\n        $css = $this->assets->css();\n\n        // expected output\n        $expected = [\n            '<link href=\"/foobar.css\" type=\"text/css\" rel=\"stylesheet\" class=\"__classname\">',\n        ];\n\n\n        self::assertCount(1, array_filter(explode(\"\\n\", $css)));\n        self::assertSame(implode(\"\\n\", $expected) . PHP_EOL, $css);\n    }\n\n    public function testPriorityOfAssets(): void\n    {\n        $this->assets->reset();\n        $this->assets->add('test.css');\n        $this->assets->add('test-after.css');\n\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL .\n            '<link href=\"/test-after.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        //----------------\n        $this->assets->reset();\n        $this->assets->add('test-after.css', 1);\n        $this->assets->add('test.css', 2);\n\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL .\n            '<link href=\"/test-after.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        //----------------\n        $this->assets->reset();\n        $this->assets->add('test-after.css', 1);\n        $this->assets->add('test.css', 2);\n        $this->assets->add('test-before.css', 3);\n\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test-before.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL .\n            '<link href=\"/test.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL .\n            '<link href=\"/test-after.css\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n    }\n\n    public function testPipeline(): void\n    {\n        $this->assets->reset();\n\n        //File not existing. Pipeline searches for that file without reaching it. Output is empty.\n        $this->assets->add('test.css', null, true);\n        $this->assets->setCssPipeline(true);\n        $css = $this->assets->css();\n        self::assertRegExp('#<link href=\\\"\\/assets\\/(.*).css\\\" type=\\\"text\\/css\\\" rel=\\\"stylesheet\\\">#', $css);\n\n        //Add a core Grav CSS file, which is found. Pipeline will now return a file\n        $this->assets->add('/system/assets/debugger/phpdebugbar', null, true);\n        $css = $this->assets->css();\n        self::assertRegExp('#<link href=\\\"\\/assets\\/(.*).css\\\" type=\\\"text\\/css\\\" rel=\\\"stylesheet\\\">#', $css);\n    }\n\n    public function testPipelineWithTimestamp(): void\n    {\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->setCssPipeline(true);\n\n        //Add a core Grav CSS file, which is found. Pipeline will now return a file\n        $this->assets->add('/system/assets/debugger.css', null, true);\n        $css = $this->assets->css();\n        self::assertRegExp('#<link href=\\\"\\/assets\\/(.*).css\\?foo\\\" type=\\\"text\\/css\\\" rel=\\\"stylesheet\\\">#', $css);\n    }\n\n    public function testInline(): void\n    {\n        $this->assets->reset();\n\n        //File not existing. Pipeline searches for that file without reaching it. Output is empty.\n        $this->assets->add('test.css', ['loading' => 'inline']);\n        $css = $this->assets->css();\n        self::assertSame(\"<style>\\n\\n</style>\\n\", $css);\n\n        $this->assets->reset();\n        //Add a core Grav CSS file, which is found. Pipeline will now return its content.\n        $this->assets->addCss('https://fonts.googleapis.com/css?family=Roboto', ['loading' => 'inline']);\n        $this->assets->addCss('/system/assets/debugger/phpdebugbar.css', ['loading' => 'inline']);\n        $css = $this->assets->css();\n        self::assertStringContainsString('font-family: \\'Roboto\\';', $css);\n        self::assertStringContainsString('div.phpdebugbar-header', $css);\n    }\n\n    public function testInlinePipeline(): void\n    {\n        $this->assets->reset();\n        $this->assets->setCssPipeline(true);\n\n        //File not existing. Pipeline searches for that file without reaching it. Output is empty.\n        $this->assets->add('test.css');\n        $css = $this->assets->css('head', ['loading' => 'inline']);\n        self::assertSame(\"<style>\\n\\n</style>\\n\", $css);\n\n        //Add a core Grav CSS file, which is found. Pipeline will now return its content.\n        $this->assets->addCss('https://fonts.googleapis.com/css?family=Roboto', null, true);\n        $this->assets->add('/system/assets/debugger/phpdebugbar.css', null, true);\n        $css = $this->assets->css('head', ['loading' => 'inline']);\n        self::assertStringContainsString('font-family:\\'Roboto\\';', $css);\n        self::assertStringContainsString('div.phpdebugbar', $css);\n    }\n\n    public function testAddAsyncJs(): void\n    {\n        $this->assets->reset();\n        $this->assets->addAsyncJs('jquery');\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/system/assets/jquery/jquery-3.x.min.js\" async></script>' . PHP_EOL, $js);\n    }\n\n    public function testAddDeferJs(): void\n    {\n        $this->assets->reset();\n        $this->assets->addDeferJs('jquery');\n        $js = $this->assets->js();\n        self::assertSame('<script src=\"/system/assets/jquery/jquery-3.x.min.js\" defer></script>' . PHP_EOL, $js);\n    }\n\n    public function testTimestamps(): void\n    {\n        // local CSS nothing extra\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->addCSS('test.css');\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css?foo\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        // local CSS already with param\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->addCSS('test.css?bar');\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"/test.css?bar&foo\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        // external CSS already\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->addCSS('http://somesite.com/test.css');\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"http://somesite.com/test.css?foo\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        // external CSS already with param\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->addCSS('http://somesite.com/test.css?bar');\n        $css = $this->assets->css();\n        self::assertSame('<link href=\"http://somesite.com/test.css?bar&foo\" type=\"text/css\" rel=\"stylesheet\">' . PHP_EOL, $css);\n\n        // local JS nothing extra\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->addJs('test.js');\n        $css = $this->assets->js();\n        self::assertSame('<script src=\"/test.js?foo\"></script>' . PHP_EOL, $css);\n\n        // local JS already with param\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->addJs('test.js?bar');\n        $css = $this->assets->js();\n        self::assertSame('<script src=\"/test.js?bar&foo\"></script>' . PHP_EOL, $css);\n\n        // external JS already\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->addJs('http://somesite.com/test.js');\n        $css = $this->assets->js();\n        self::assertSame('<script src=\"http://somesite.com/test.js?foo\"></script>' . PHP_EOL, $css);\n\n        // external JS already with param\n        $this->assets->reset();\n        $this->assets->setTimestamp('foo');\n        $this->assets->addJs('http://somesite.com/test.js?bar');\n        $css = $this->assets->js();\n        self::assertSame('<script src=\"http://somesite.com/test.js?bar&foo\"></script>' . PHP_EOL, $css);\n    }\n\n    public function testAddInlineCss(): void\n    {\n        $this->assets->reset();\n        $this->assets->addInlineCss('body { color: black }');\n        $css = $this->assets->css();\n        self::assertSame('<style>' . PHP_EOL . 'body { color: black }' . PHP_EOL . '</style>' . PHP_EOL, $css);\n    }\n\n    public function testAddInlineJs(): void\n    {\n        $this->assets->reset();\n        $this->assets->addInlineJs('alert(\"test\")');\n        $js = $this->assets->js();\n        self::assertSame('<script>' . PHP_EOL . 'alert(\"test\")' . PHP_EOL . '</script>' . PHP_EOL, $js);\n    }\n\n    public function testGetCollections(): void\n    {\n        self::assertIsArray($this->assets->getCollections());\n        self::assertContains('jquery', array_keys($this->assets->getCollections()));\n        self::assertContains('system://assets/jquery/jquery-3.x.min.js', $this->assets->getCollections());\n    }\n\n    public function testExists(): void\n    {\n        self::assertTrue($this->assets->exists('jquery'));\n        self::assertFalse($this->assets->exists('another-unexisting-library'));\n    }\n\n    public function testRegisterCollection(): void\n    {\n        $this->assets->registerCollection('debugger', ['/system/assets/debugger.css']);\n        self::assertTrue($this->assets->exists('debugger'));\n        self::assertContains('debugger', array_keys($this->assets->getCollections()));\n    }\n\n    public function testRegisterCollectionWithParameters(): void\n    {\n        $this->assets->registerCollection('collection_multi_params', [\n            'foo.js' => [ 'defer' => true ],\n            'bar.js' => [ 'integrity' => 'sha512-abc123' ],\n            'foobar.css' => [ 'defer' => null ],\n        ]);\n\n        self::assertTrue($this->assets->exists('collection_multi_params'));\n\n        $collection = $this->assets->getCollections()['collection_multi_params'];\n        self::assertArrayHasKey('foo.js', $collection);\n        self::assertArrayHasKey('bar.js', $collection);\n        self::assertArrayHasKey('foobar.css', $collection);\n        self::assertArrayHasKey('defer', $collection['foo.js']);\n        self::assertArrayHasKey('defer', $collection['foobar.css']);\n\n        self::assertNull($collection['foobar.css']['defer']);\n        self::assertTrue($collection['foo.js']['defer']);\n    }\n\n    public function testReset(): void\n    {\n        $this->assets->addInlineJs('alert(\"test\")');\n        $this->assets->reset();\n        self::assertCount(0, (array) $this->assets->getJs());\n\n        $this->assets->addAsyncJs('jquery');\n        $this->assets->reset();\n        self::assertCount(0, (array) $this->assets->getJs());\n\n        $this->assets->addInlineCss('body { color: black }');\n        $this->assets->reset();\n        self::assertCount(0, (array) $this->assets->getCss());\n\n        $this->assets->add('/system/assets/debugger.css', null, true);\n        $this->assets->reset();\n        self::assertCount(0, (array) $this->assets->getCss());\n    }\n\n    public function testResetJs(): void\n    {\n        $this->assets->addInlineJs('alert(\"test\")');\n        $this->assets->resetJs();\n        self::assertCount(0, (array) $this->assets->getJs());\n\n        $this->assets->addAsyncJs('jquery');\n        $this->assets->resetJs();\n        self::assertCount(0, (array) $this->assets->getJs());\n    }\n\n    public function testResetCss(): void\n    {\n        $this->assets->addInlineCss('body { color: black }');\n        $this->assets->resetCss();\n        self::assertCount(0, (array) $this->assets->getCss());\n\n        $this->assets->add('/system/assets/debugger.css', null, true);\n        $this->assets->resetCss();\n        self::assertCount(0, (array) $this->assets->getCss());\n    }\n\n    public function testAddDirCss(): void\n    {\n        $this->assets->addDirCss('/system');\n\n        self::assertIsArray($this->assets->getCss());\n        self::assertGreaterThan(0, (array) $this->assets->getCss());\n        self::assertIsArray($this->assets->getJs());\n        self::assertCount(0, (array) $this->assets->getJs());\n\n        $this->assets->reset();\n        $this->assets->addDirCss('/system/assets');\n\n        self::assertIsArray($this->assets->getCss());\n        self::assertGreaterThan(0, (array) $this->assets->getCss());\n        self::assertIsArray($this->assets->getJs());\n        self::assertCount(0, (array) $this->assets->getJs());\n\n        $this->assets->reset();\n        $this->assets->addDirJs('/system');\n\n        self::assertIsArray($this->assets->getCss());\n        self::assertCount(0, (array) $this->assets->getCss());\n        self::assertIsArray($this->assets->getJs());\n        self::assertGreaterThan(0, (array) $this->assets->getJs());\n\n        $this->assets->reset();\n        $this->assets->addDirJs('/system/assets');\n\n        self::assertIsArray($this->assets->getCss());\n        self::assertCount(0, (array) $this->assets->getCss());\n        self::assertIsArray($this->assets->getJs());\n        self::assertGreaterThan(0, (array) $this->assets->getJs());\n\n        $this->assets->reset();\n        $this->assets->addDir('/system/assets');\n\n        self::assertIsArray($this->assets->getCss());\n        self::assertGreaterThan(0, (array) $this->assets->getCss());\n        self::assertIsArray($this->assets->getJs());\n        self::assertGreaterThan(0, (array) $this->assets->getJs());\n\n        //Use streams\n        $this->assets->reset();\n        $this->assets->addDir('system://assets');\n\n        self::assertIsArray($this->assets->getCss());\n        self::assertGreaterThan(0, (array) $this->assets->getCss());\n        self::assertIsArray($this->assets->getJs());\n        self::assertGreaterThan(0, (array) $this->assets->getJs());\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/BrowserTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\n\n/**\n * Class BrowserTest\n */\nclass BrowserTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    protected function _before(): void\n    {\n        $grav = Fixtures::get('grav');\n        $this->grav = $grav();\n    }\n\n    protected function _after(): void\n    {\n    }\n\n    public function testGetBrowser(): void\n    {\n /* Already covered by PhpUserAgent tests */\n    }\n\n    public function testGetPlatform(): void\n    {\n /* Already covered by PhpUserAgent tests */\n    }\n\n    public function testGetLongVersion(): void\n    {\n /* Already covered by PhpUserAgent tests */\n    }\n\n    public function testGetVersion(): void\n    {\n /* Already covered by PhpUserAgent tests */\n    }\n\n    public function testIsHuman(): void\n    {\n        //Already Partially covered by PhpUserAgent tests\n\n        //Make sure it recognizes the test as not human\n        self::assertFalse($this->grav['browser']->isHuman());\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/ComposerTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Composer;\n\nclass ComposerTest extends \\Codeception\\TestCase\\Test\n{\n    protected function _before(): void\n    {\n    }\n\n    protected function _after(): void\n    {\n    }\n\n    public function testGetComposerLocation(): void\n    {\n        $composerLocation = Composer::getComposerLocation();\n        self::assertIsString($composerLocation);\n        self::assertSame('/', $composerLocation[0]);\n    }\n\n    public function testGetComposerExecutor(): void\n    {\n        $composerExecutor = Composer::getComposerExecutor();\n        self::assertIsString($composerExecutor);\n        self::assertSame('/', $composerExecutor[0]);\n        self::assertNotNull(strstr($composerExecutor, 'php'));\n        self::assertNotNull(strstr($composerExecutor, 'composer'));\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/Data/BlueprintTest.php",
    "content": "<?php\n\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Data\\Blueprint;\nuse Grav\\Common\\Grav;\n\n/**\n * Class InstallCommandTest\n */\nclass BlueprintTest extends \\Codeception\\TestCase\\Test\n{\n    /**\n     */\n    public function testValidateStrict(): void\n    {\n        $blueprint = $this->loadBlueprint('strict');\n\n        $blueprint->validate(['test' => 'string']);\n    }\n\n    /**\n     * @depends testValidateStrict\n     */\n    public function testValidateStrictRequired(): void\n    {\n        $blueprint = $this->loadBlueprint('strict');\n\n        $this->expectException(\\Grav\\Common\\Data\\ValidationException::class);\n        $blueprint->validate([]);\n    }\n\n    /**\n     * @depends testValidateStrict\n     */\n    public function testValidateStrictExtra(): void\n    {\n        $blueprint = $this->loadBlueprint('strict');\n\n        $blueprint->validate(['test' => 'string', 'wrong' => 'field']);\n    }\n\n    /**\n     * @depends testValidateStrict\n     */\n    public function testValidateStrictExtraException(): void\n    {\n        $blueprint = $this->loadBlueprint('strict');\n\n        /** @var Config $config */\n        $config = Grav::instance()['config'];\n        $var = 'system.strict_mode.blueprint_strict_compat';\n        $config->set($var, false);\n\n        $this->expectException(\\Grav\\Common\\Data\\ValidationException::class);\n        $blueprint->validate(['test' => 'string', 'wrong' => 'field']);\n\n        $config->set($var, true);\n    }\n\n    /**\n     * @param string $filename\n     * @return Blueprint\n     */\n    protected function loadBlueprint($filename): Blueprint\n    {\n        $blueprint = new Blueprint('strict');\n        $blueprint->setContext(dirname(__DIR__, 3). '/data/blueprints');\n        $blueprint->load()->init();\n\n        return $blueprint;\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/GPM/GPMTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\GPM\\GPM;\n\ndefine('EXCEPTION_BAD_FORMAT', 1);\ndefine('EXCEPTION_INCOMPATIBLE_VERSIONS', 2);\n\n/**\n * Class GpmStub\n */\nclass GpmStub extends GPM\n{\n    /** @var array */\n    public $data;\n\n    /**\n     * @inheritdoc\n     */\n    public function findPackage($search, $ignore_exception = false)\n    {\n        return $this->data[$search] ?? false;\n    }\n\n    /**\n     * @inheritdoc\n     */\n    public function findPackages($searches = [])\n    {\n        return $this->data;\n    }\n}\n\n/**\n * Class InstallCommandTest\n */\nclass GpmTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var GpmStub */\n    protected $gpm;\n\n    protected function _before(): void\n    {\n        $this->grav = Fixtures::get('grav');\n        $this->gpm = new GpmStub();\n    }\n\n    protected function _after(): void\n    {\n    }\n\n    public function testCalculateMergedDependenciesOfPackages(): void\n    {\n        //////////////////////////////////////////////////////////////////////////////////////////\n        // First working example\n        //////////////////////////////////////////////////////////////////////////////////////////\n        $this->gpm->data = [\n            'admin' => (object)[\n                'dependencies' => [\n                    ['name' => 'grav', 'version' => '>=1.0.10'],\n                    ['name' => 'form', 'version' => '~2.0'],\n                    ['name' => 'login', 'version' => '>=2.0'],\n                    ['name' => 'errors', 'version' => '*'],\n                    ['name' => 'problems'],\n                ]\n            ],\n            'test' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '>=1.0']\n                ]\n            ],\n            'grav',\n            'form' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '>=3.2']\n                ]\n            ]\n\n\n        ];\n\n        $packages = ['admin', 'test'];\n\n        $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages);\n\n        self::assertIsArray($dependencies);\n        self::assertCount(5, $dependencies);\n\n        self::assertSame('>=1.0.10', $dependencies['grav']);\n        self::assertArrayHasKey('errors', $dependencies);\n        self::assertArrayHasKey('problems', $dependencies);\n\n        //////////////////////////////////////////////////////////////////////////////////////////\n        // Second working example\n        //////////////////////////////////////////////////////////////////////////////////////////\n        $packages = ['admin', 'form'];\n\n        $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages);\n        self::assertIsArray($dependencies);\n        self::assertCount(5, $dependencies);\n        self::assertSame('>=3.2', $dependencies['errors']);\n\n        //////////////////////////////////////////////////////////////////////////////////////////\n        // Third working example\n        //////////////////////////////////////////////////////////////////////////////////////////\n        $this->gpm->data = [\n\n            'admin' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '>=4.0'],\n                ]\n            ],\n            'test' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '>=1.0']\n                ]\n            ],\n            'another' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '>=3.2']\n                ]\n            ]\n\n        ];\n\n        $packages = ['admin', 'test', 'another'];\n\n\n        $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages);\n        self::assertIsArray($dependencies);\n        self::assertCount(1, $dependencies);\n        self::assertSame('>=4.0', $dependencies['errors']);\n\n\n\n        //////////////////////////////////////////////////////////////////////////////////////////\n        // Test alpha / beta / rc\n        //////////////////////////////////////////////////////////////////////////////////////////\n        $this->gpm->data = [\n            'admin' => (object)[\n                'dependencies' => [\n                    ['name' => 'package1', 'version' => '>=4.0.0-rc1'],\n                    ['name' => 'package4', 'version' => '>=3.2.0'],\n                ]\n            ],\n            'test' => (object)[\n                'dependencies' => [\n                    ['name' => 'package1', 'version' => '>=4.0.0-rc2'],\n                    ['name' => 'package2', 'version' => '>=3.2.0-alpha'],\n                    ['name' => 'package3', 'version' => '>=3.2.0-alpha.2'],\n                    ['name' => 'package4', 'version' => '>=3.2.0-alpha'],\n                ]\n            ],\n            'another' => (object)[\n                'dependencies' => [\n                    ['name' => 'package2', 'version' => '>=3.2.0-beta.11'],\n                    ['name' => 'package3', 'version' => '>=3.2.0-alpha.1'],\n                    ['name' => 'package4', 'version' => '>=3.2.0-beta'],\n                ]\n            ]\n        ];\n\n        $packages = ['admin', 'test', 'another'];\n\n\n        $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages);\n        self::assertSame('>=4.0.0-rc2', $dependencies['package1']);\n        self::assertSame('>=3.2.0-beta.11', $dependencies['package2']);\n        self::assertSame('>=3.2.0-alpha.2', $dependencies['package3']);\n        self::assertSame('>=3.2.0', $dependencies['package4']);\n\n\n        //////////////////////////////////////////////////////////////////////////////////////////\n        // Raise exception if no version is specified\n        //////////////////////////////////////////////////////////////////////////////////////////\n        $this->gpm->data = [\n\n            'admin' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '>=4.0'],\n                ]\n            ],\n            'test' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '>=']\n                ]\n            ],\n\n        ];\n\n        $packages = ['admin', 'test'];\n\n        try {\n            $this->gpm->calculateMergedDependenciesOfPackages($packages);\n            self::fail('Expected Exception not thrown');\n        } catch (Exception $e) {\n            self::assertEquals(EXCEPTION_BAD_FORMAT, $e->getCode());\n            self::assertStringStartsWith('Bad format for version of dependency', $e->getMessage());\n        }\n\n        //////////////////////////////////////////////////////////////////////////////////////////\n        // Raise exception if incompatible versions are specified\n        //////////////////////////////////////////////////////////////////////////////////////////\n        $this->gpm->data = [\n            'admin' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '~4.0'],\n                ]\n            ],\n            'test' => (object)[\n                'dependencies' => [\n                    ['name' => 'errors', 'version' => '~3.0']\n                ]\n            ],\n        ];\n\n        $packages = ['admin', 'test'];\n\n        try {\n            $this->gpm->calculateMergedDependenciesOfPackages($packages);\n            self::fail('Expected Exception not thrown');\n        } catch (Exception $e) {\n            self::assertEquals(EXCEPTION_INCOMPATIBLE_VERSIONS, $e->getCode());\n            self::assertStringEndsWith('required in two incompatible versions', $e->getMessage());\n        }\n\n        //////////////////////////////////////////////////////////////////////////////////////////\n        // Test dependencies of dependencies\n        //////////////////////////////////////////////////////////////////////////////////////////\n        $this->gpm->data = [\n            'admin' => (object)[\n                'dependencies' => [\n                    ['name' => 'grav', 'version' => '>=1.0.10'],\n                    ['name' => 'form', 'version' => '~2.0'],\n                    ['name' => 'login', 'version' => '>=2.0'],\n                    ['name' => 'errors', 'version' => '*'],\n                    ['name' => 'problems'],\n                ]\n            ],\n            'login' => (object)[\n                'dependencies' => [\n                    ['name' => 'antimatter', 'version' => '>=1.0']\n                ]\n            ],\n            'grav',\n            'antimatter' => (object)[\n                'dependencies' => [\n                    ['name' => 'something', 'version' => '>=3.2']\n                ]\n            ]\n\n\n        ];\n\n        $packages = ['admin'];\n\n        $dependencies = $this->gpm->calculateMergedDependenciesOfPackages($packages);\n\n        self::assertIsArray($dependencies);\n        self::assertCount(7, $dependencies);\n\n        self::assertSame('>=1.0.10', $dependencies['grav']);\n        self::assertArrayHasKey('errors', $dependencies);\n        self::assertArrayHasKey('problems', $dependencies);\n        self::assertArrayHasKey('antimatter', $dependencies);\n        self::assertArrayHasKey('something', $dependencies);\n        self::assertSame('>=3.2', $dependencies['something']);\n    }\n\n    public function testVersionFormatIsNextSignificantRelease(): void\n    {\n        self::assertFalse($this->gpm->versionFormatIsNextSignificantRelease('>=1.0'));\n        self::assertFalse($this->gpm->versionFormatIsNextSignificantRelease('>=2.3.4'));\n        self::assertFalse($this->gpm->versionFormatIsNextSignificantRelease('>=2.3.x'));\n        self::assertFalse($this->gpm->versionFormatIsNextSignificantRelease('1.0'));\n        self::assertTrue($this->gpm->versionFormatIsNextSignificantRelease('~2.3.x'));\n        self::assertTrue($this->gpm->versionFormatIsNextSignificantRelease('~2.0'));\n    }\n\n    public function testVersionFormatIsEqualOrHigher(): void\n    {\n        self::assertTrue($this->gpm->versionFormatIsEqualOrHigher('>=1.0'));\n        self::assertTrue($this->gpm->versionFormatIsEqualOrHigher('>=2.3.4'));\n        self::assertTrue($this->gpm->versionFormatIsEqualOrHigher('>=2.3.x'));\n        self::assertFalse($this->gpm->versionFormatIsEqualOrHigher('~2.3.x'));\n        self::assertFalse($this->gpm->versionFormatIsEqualOrHigher('1.0'));\n    }\n\n    public function testCheckNextSignificantReleasesAreCompatible(): void\n    {\n        /*\n         * ~1.0     is equivalent to >=1.0 < 2.0.0\n         * ~1.2     is equivalent to >=1.2 <2.0.0\n         * ~1.2.3   is equivalent to >=1.2.3 <1.3.0\n         */\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '1.2'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.2', '1.0'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '1.0.10'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.1', '1.1.10'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('30.0', '30.10'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '1.1.10'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '1.8'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('1.0.1', '1.1'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('2.0.0-beta', '2.0'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('2.0.0-rc.1', '2.0'));\n        self::assertTrue($this->gpm->checkNextSignificantReleasesAreCompatible('2.0', '2.0.0-alpha'));\n\n        self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('1.0', '2.2'));\n        self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('1.0.0-beta.1', '2.0'));\n        self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.0'));\n        self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.10'));\n        self::assertFalse($this->gpm->checkNextSignificantReleasesAreCompatible('0.9.99', '1.0.10.2'));\n    }\n\n    public function testCalculateVersionNumberFromDependencyVersion(): void\n    {\n        self::assertSame('2.0', $this->gpm->calculateVersionNumberFromDependencyVersion('>=2.0'));\n        self::assertSame('2.0.2', $this->gpm->calculateVersionNumberFromDependencyVersion('>=2.0.2'));\n        self::assertSame('2.0.2', $this->gpm->calculateVersionNumberFromDependencyVersion('~2.0.2'));\n        self::assertSame('1', $this->gpm->calculateVersionNumberFromDependencyVersion('~1'));\n        self::assertNull($this->gpm->calculateVersionNumberFromDependencyVersion(''));\n        self::assertNull($this->gpm->calculateVersionNumberFromDependencyVersion('*'));\n        self::assertSame('2.0.2', $this->gpm->calculateVersionNumberFromDependencyVersion('2.0.2'));\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/Helpers/ExcerptsTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Helpers\\Excerpts;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Language\\Language;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n/**\n * Class ExcerptsTest\n */\nclass ExcerptsTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Parsedown $parsedown */\n    protected $parsedown;\n\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var PageInterface $page */\n    protected $page;\n\n    /** @var Pages $pages */\n    protected $pages;\n\n    /** @var Config $config */\n    protected $config;\n\n    /** @var  Uri $uri */\n    protected $uri;\n\n    /** @var  Language $language */\n    protected $language;\n\n    protected $old_home;\n\n    protected function _before(): void\n    {\n        $grav = Fixtures::get('grav');\n        $this->grav = $grav();\n        $this->pages = $this->grav['pages'];\n        $this->config = $this->grav['config'];\n        $this->uri = $this->grav['uri'];\n        $this->language = $this->grav['language'];\n        $this->old_home = $this->config->get('system.home.alias');\n        $this->config->set('system.home.alias', '/item1');\n        $this->config->set('system.absolute_urls', false);\n        $this->config->set('system.languages.supported', []);\n\n        unset($this->grav['language']);\n        $this->grav['language'] = new Language($this->grav);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $locator->addPath('page', '', 'tests/fake/nested-site/user/pages', false);\n        $this->pages->init();\n\n        $defaults = [\n            'extra'            => false,\n            'auto_line_breaks' => false,\n            'auto_url_links'   => false,\n            'escape_markup'    => false,\n            'special_chars'    => ['>' => 'gt', '<' => 'lt'],\n        ];\n        $this->page = $this->pages->find('/item2/item2-2');\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n    }\n\n    protected function _after(): void\n    {\n        $this->config->set('system.home.alias', $this->old_home);\n    }\n\n\n    public function testProcessImageHtml(): void\n    {\n        self::assertRegexp(\n            '|<img alt=\"Sample Image\" src=\"\\/images\\/.*-sample-image.jpe?g\\\" data-src=\"sample-image\\.jpg\\?cropZoom=300,300\" \\/>|',\n            Excerpts::processImageHtml('<img src=\"sample-image.jpg?cropZoom=300,300\" alt=\"Sample Image\" />', $this->page)\n        );\n        self::assertRegexp(\n            '|<img alt=\"Sample Image\" class=\"foo\" src=\"\\/images\\/.*-sample-image.jpe?g\\\" data-src=\"sample-image\\.jpg\\?classes=foo\" \\/>|',\n            Excerpts::processImageHtml('<img src=\"sample-image.jpg?classes=foo\" alt=\"Sample Image\" />', $this->page)\n        );\n    }\n\n    public function testNoProcess(): void\n    {\n        self::assertStringStartsWith(\n            '<a href=\"https://play.google.com/store/apps/details?hl=de\" id=\"org.jitsi.meet\" target=\"_blank\"',\n            Excerpts::processLinkHtml('<a href=\"https://play.google.com/store/apps/details?id=org.jitsi.meet&hl=de&target=_blank\">regular process</a>')\n        );\n\n        self::assertStringStartsWith(\n            '<a href=\"https://play.google.com/store/apps/details?id=org.jitsi.meet&hl=de&target=_blank\"',\n            Excerpts::processLinkHtml('<a href=\"https://play.google.com/store/apps/details?id=org.jitsi.meet&hl=de&target=_blank&noprocess\">noprocess</a>')\n        );\n\n        self::assertStringStartsWith(\n            '<a href=\"https://play.google.com/store/apps/details?id=org.jitsi.meet&hl=de\" target=\"_blank\"',\n            Excerpts::processLinkHtml('<a href=\"https://play.google.com/store/apps/details?id=org.jitsi.meet&hl=de&target=_blank&noprocess=id\">noprocess=id</a>')\n        );\n    }\n\n    public function testTarget(): void\n    {\n        self::assertStringStartsWith(\n            '<a href=\"https://play.google.com/store/apps/details\" target=\"_blank\"',\n            Excerpts::processLinkHtml('<a href=\"https://play.google.com/store/apps/details?target=_blank\">only target</a>')\n        );\n        self::assertStringStartsWith(\n            '<a href=\"https://meet.weikamp.biz/Support\" rel=\"nofollow\" target=\"_blank\"',\n            Excerpts::processLinkHtml('<a href=\"https://meet.weikamp.biz/Support?rel=nofollow&target=_blank\">target and rel</a>')\n        );\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/InflectorTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Inflector;\nuse Grav\\Common\\Utils;\n\n/**\n * Class InflectorTest\n */\nclass InflectorTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var Inflector $uri */\n    protected $inflector;\n\n    protected function _before(): void\n    {\n        $grav = Fixtures::get('grav');\n        $this->grav = $grav();\n        $this->inflector = $this->grav['inflector'];\n    }\n\n    protected function _after(): void\n    {\n    }\n\n    public function testPluralize(): void\n    {\n        self::assertSame('words', $this->inflector->pluralize('word'));\n        self::assertSame('kisses', $this->inflector->pluralize('kiss'));\n        self::assertSame('volcanoes', $this->inflector->pluralize('volcanoe'));\n        self::assertSame('cherries', $this->inflector->pluralize('cherry'));\n        self::assertSame('days', $this->inflector->pluralize('day'));\n        self::assertSame('knives', $this->inflector->pluralize('knife'));\n    }\n\n    public function testSingularize(): void\n    {\n        self::assertSame('word', $this->inflector->singularize('words'));\n        self::assertSame('kiss', $this->inflector->singularize('kisses'));\n        self::assertSame('volcanoe', $this->inflector->singularize('volcanoe'));\n        self::assertSame('cherry', $this->inflector->singularize('cherries'));\n        self::assertSame('day', $this->inflector->singularize('days'));\n        self::assertSame('knife', $this->inflector->singularize('knives'));\n    }\n\n    public function testTitleize(): void\n    {\n        self::assertSame('This String Is Titleized', $this->inflector->titleize('ThisStringIsTitleized'));\n        self::assertSame('This String Is Titleized', $this->inflector->titleize('this string is titleized'));\n        self::assertSame('This String Is Titleized', $this->inflector->titleize('this_string_is_titleized'));\n        self::assertSame('This String Is Titleized', $this->inflector->titleize('this-string-is-titleized'));\n        self::assertSame('Échelle Synoptique', $this->inflector->titleize('échelle synoptique'));\n\n        self::assertSame('This string is titleized', $this->inflector->titleize('ThisStringIsTitleized', 'first'));\n        self::assertSame('This string is titleized', $this->inflector->titleize('this string is titleized', 'first'));\n        self::assertSame('This string is titleized', $this->inflector->titleize('this_string_is_titleized', 'first'));\n        self::assertSame('This string is titleized', $this->inflector->titleize('this-string-is-titleized', 'first'));\n        self::assertSame('Échelle synoptique', $this->inflector->titleize('échelle synoptique', 'first'));\n    }\n\n    public function testCamelize(): void\n    {\n        self::assertSame('ThisStringIsCamelized', $this->inflector->camelize('This String Is Camelized'));\n        self::assertSame('ThisStringIsCamelized', $this->inflector->camelize('thisStringIsCamelized'));\n        self::assertSame('ThisStringIsCamelized', $this->inflector->camelize('This_String_Is_Camelized'));\n        self::assertSame('ThisStringIsCamelized', $this->inflector->camelize('this string is camelized'));\n        self::assertSame('GravSPrettyCoolMy1', $this->inflector->camelize(\"Grav's Pretty Cool. My #1!\"));\n    }\n\n    public function testUnderscorize(): void\n    {\n        self::assertSame('this_string_is_underscorized', $this->inflector->underscorize('This String Is Underscorized'));\n        self::assertSame('this_string_is_underscorized', $this->inflector->underscorize('ThisStringIsUnderscorized'));\n        self::assertSame('this_string_is_underscorized', $this->inflector->underscorize('This_String_Is_Underscorized'));\n        self::assertSame('this_string_is_underscorized', $this->inflector->underscorize('This-String-Is-Underscorized'));\n    }\n\n    public function testHyphenize(): void\n    {\n        self::assertSame('this-string-is-hyphenized', $this->inflector->hyphenize('This String Is Hyphenized'));\n        self::assertSame('this-string-is-hyphenized', $this->inflector->hyphenize('ThisStringIsHyphenized'));\n        self::assertSame('this-string-is-hyphenized', $this->inflector->hyphenize('This-String-Is-Hyphenized'));\n        self::assertSame('this-string-is-hyphenized', $this->inflector->hyphenize('This_String_Is_Hyphenized'));\n    }\n\n    public function testHumanize(): void\n    {\n        //self::assertSame('This string is humanized',   $this->inflector->humanize('ThisStringIsHumanized'));\n        self::assertSame('This string is humanized', $this->inflector->humanize('this_string_is_humanized'));\n        //self::assertSame('This string is humanized',   $this->inflector->humanize('this-string-is-humanized'));\n\n        self::assertSame('This String Is Humanized', $this->inflector->humanize('this_string_is_humanized', 'all'));\n        //self::assertSame('This String Is Humanized',   $this->inflector->humanize('this-string-is-humanized'), 'all');\n    }\n\n    public function testVariablize(): void\n    {\n        self::assertSame('thisStringIsVariablized', $this->inflector->variablize('This String Is Variablized'));\n        self::assertSame('thisStringIsVariablized', $this->inflector->variablize('ThisStringIsVariablized'));\n        self::assertSame('thisStringIsVariablized', $this->inflector->variablize('This_String_Is_Variablized'));\n        self::assertSame('thisStringIsVariablized', $this->inflector->variablize('this string is variablized'));\n        self::assertSame('gravSPrettyCoolMy1', $this->inflector->variablize(\"Grav's Pretty Cool. My #1!\"));\n    }\n\n    public function testTableize(): void\n    {\n        self::assertSame('people', $this->inflector->tableize('Person'));\n        self::assertSame('pages', $this->inflector->tableize('Page'));\n        self::assertSame('blog_pages', $this->inflector->tableize('BlogPage'));\n        self::assertSame('admin_dependencies', $this->inflector->tableize('adminDependency'));\n        self::assertSame('admin_dependencies', $this->inflector->tableize('admin-dependency'));\n        self::assertSame('admin_dependencies', $this->inflector->tableize('admin_dependency'));\n    }\n\n    public function testClassify(): void\n    {\n        self::assertSame('Person', $this->inflector->classify('people'));\n        self::assertSame('Page', $this->inflector->classify('pages'));\n        self::assertSame('BlogPage', $this->inflector->classify('blog_pages'));\n        self::assertSame('AdminDependency', $this->inflector->classify('admin_dependencies'));\n    }\n\n    public function testOrdinalize(): void\n    {\n        self::assertSame('1st', $this->inflector->ordinalize(1));\n        self::assertSame('2nd', $this->inflector->ordinalize(2));\n        self::assertSame('3rd', $this->inflector->ordinalize(3));\n        self::assertSame('4th', $this->inflector->ordinalize(4));\n        self::assertSame('5th', $this->inflector->ordinalize(5));\n        self::assertSame('16th', $this->inflector->ordinalize(16));\n        self::assertSame('51st', $this->inflector->ordinalize(51));\n        self::assertSame('111th', $this->inflector->ordinalize(111));\n        self::assertSame('123rd', $this->inflector->ordinalize(123));\n    }\n\n    public function testMonthize(): void\n    {\n        self::assertSame(0, $this->inflector->monthize(10));\n        self::assertSame(1, $this->inflector->monthize(33));\n        self::assertSame(1, $this->inflector->monthize(41));\n        self::assertSame(11, $this->inflector->monthize(364));\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/Language/LanguageCodesTest.php",
    "content": "<?php\n\nuse Grav\\Common\\Language\\LanguageCodes;\n\n/**\n * Class ParsedownTest\n */\nclass LanguageCodesTest extends \\Codeception\\TestCase\\Test\n{\n    public function testRtl(): void\n    {\n        self::assertSame(\n            'ltr',\n            LanguageCodes::getOrientation('en')\n        );\n        self::assertSame(\n            'rtl',\n            LanguageCodes::getOrientation('ar')\n        );\n        self::assertSame(\n            'rtl',\n            LanguageCodes::getOrientation('he')\n        );\n        self::assertTrue(LanguageCodes::isRtl('ar'));\n        self::assertFalse(LanguageCodes::isRtl('fr'));\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/Markdown/ParsedownTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Markdown\\Excerpts;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Markdown\\Parsedown;\nuse Grav\\Common\\Language\\Language;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n/**\n * Class ParsedownTest\n */\nclass ParsedownTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Parsedown $parsedown */\n    protected $parsedown;\n\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var Pages $pages */\n    protected $pages;\n\n    /** @var Config $config */\n    protected $config;\n\n    /** @var  Uri $uri */\n    protected $uri;\n\n    /** @var  Language $language */\n    protected $language;\n\n    protected $old_home;\n\n    protected function _before(): void\n    {\n        $grav = Fixtures::get('grav');\n        $this->grav = $grav();\n        $this->pages = $this->grav['pages'];\n        $this->config = $this->grav['config'];\n        $this->uri = $this->grav['uri'];\n        $this->language = $this->grav['language'];\n        $this->old_home = $this->config->get('system.home.alias');\n        $this->config->set('system.home.alias', '/item1');\n        $this->config->set('system.absolute_urls', false);\n        $this->config->set('system.languages.supported', []);\n\n        unset($this->grav['language']);\n        $this->grav['language'] = new Language($this->grav);\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $locator->addPath('page', '', 'tests/fake/nested-site/user/pages', false);\n        $this->pages->init();\n\n        $defaults = [\n            'markdown' => [\n                'extra'            => false,\n                'auto_line_breaks' => false,\n                'auto_url_links'   => false,\n                'escape_markup'    => false,\n                'special_chars'    => ['>' => 'gt', '<' => 'lt'],\n            ],\n            'images' => $this->config->get('system.images', [])\n        ];\n        $page = $this->pages->find('/item2/item2-2');\n\n        $excerpts = new Excerpts($page, $defaults);\n        $this->parsedown = new Parsedown($excerpts);\n    }\n\n    protected function _after(): void\n    {\n        $this->config->set('system.home.alias', $this->old_home);\n    }\n\n    public function testImages(): void\n    {\n        $this->config->set('system.languages.supported', ['fr','en']);\n        unset($this->grav['language']);\n        $this->grav['language'] = new Language($this->grav);\n        $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"\\/images\\/.*-cache-image.jpe?g\\?foo=1\" \\/><\\/p>|',\n            $this->parsedown->text('![](cache-image.jpg?cropResize=200,200&foo)')\n        );\n\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"\\/images\\/.*-cache-image.jpe?g\\?foo=1\" \\/><\\/p>|',\n            $this->parsedown->text('![](cache-image.jpg?cropResize=200,200&foo)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"\\/images\\/.*-home-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](/home-cache-image.jpg?cache)')\n        );\n        self::assertSame(\n            '<p><img src=\"/item2/item2-2/missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](missing-image.jpg)')\n        );\n        self::assertSame(\n            '<p><img src=\"/home-missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](/home-missing-image.jpg)')\n        );\n        self::assertSame(\n            '<p><img src=\"/home-missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](/home-missing-image.jpg)')\n        );\n        self::assertSame(\n            '<p><img src=\"https://getgrav-grav.netdna-ssl.com/user/pages/media/grav-logo.svg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](https://getgrav-grav.netdna-ssl.com/user/pages/media/grav-logo.svg)')\n        );\n    }\n\n    public function testImagesSubDir(): void\n    {\n        $this->config->set('system.images.cache_all', false);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"\\/subdir\\/images\\/.*-home-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](/home-cache-image.jpg?cache)')\n        );\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/subdir/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"\\/subdir\\/images\\/.*-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](cache-image.jpg?cache)')\n        );\n        self::assertSame(\n            '<p><img src=\"/subdir/item2/item2-2/missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](missing-image.jpg)')\n        );\n        self::assertSame(\n            '<p><img src=\"/subdir/home-missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](/home-missing-image.jpg)')\n        );\n    }\n\n    public function testImagesAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"http://testing.dev/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"http:\\/\\/testing.dev\\/images\\/.*-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](cache-image.jpg?cache)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"http:\\/\\/testing.dev\\/images\\/.*-home-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](/home-cache-image.jpg?cache)')\n        );\n        self::assertSame(\n            '<p><img src=\"http://testing.dev/item2/item2-2/missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](missing-image.jpg)')\n        );\n        self::assertSame(\n            '<p><img src=\"http://testing.dev/home-missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](/home-missing-image.jpg)')\n        );\n    }\n\n    public function testImagesSubDirAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"http://testing.dev/subdir/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"http:\\/\\/testing.dev\\/subdir\\/images\\/.*-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](cache-image.jpg?cache)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"http:\\/\\/testing.dev\\/subdir\\/images\\/.*-home-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](/home-cache-image.jpg?cropResize=200,200)')\n        );\n        self::assertSame(\n            '<p><img src=\"http://testing.dev/subdir/item2/item2-2/missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](missing-image.jpg)')\n        );\n        self::assertSame(\n            '<p><img src=\"http://testing.dev/subdir/home-missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](/home-missing-image.jpg)')\n        );\n    }\n\n    public function testImagesAttributes(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><img title=\"My Title\" alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg \"My Title\")')\n        );\n        self::assertSame(\n            '<p><img alt=\"\" class=\"foo\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?classes=foo)')\n        );\n        self::assertSame(\n            '<p><img alt=\"\" class=\"foo bar\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?classes=foo,bar)')\n        );\n        self::assertSame(\n            '<p><img alt=\"\" id=\"foo\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?id=foo)')\n        );\n        self::assertSame(\n            '<p><img alt=\"Alt Text\" id=\"foo\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![Alt Text](sample-image.jpg?id=foo)')\n        );\n        self::assertSame(\n            '<p><img alt=\"Alt Text\" class=\"bar\" id=\"foo\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![Alt Text](sample-image.jpg?class=bar&id=foo)')\n        );\n        self::assertSame(\n            '<p><img title=\"My Title\" alt=\"Alt Text\" class=\"bar\" id=\"foo\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![Alt Text](sample-image.jpg?class=bar&id=foo \"My Title\")')\n        );\n    }\n\n    public function testImagesDefaults(): void\n    {\n        /**\n         * Testing default 'loading'\n        */\n\n        $this->setImagesDefaults(['loading' => 'auto']);\n\n\n        // loading should NOT be added to image by default\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n\n        // loading=\"lazy\" should be added when default is overridden by ?loading=lazy\n        self::assertSame(\n            '<p><img loading=\"lazy\" alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?loading=lazy)')\n        );\n\n        $this->setImagesDefaults(['loading' => 'lazy']);\n\n        // loading=\"lazy\" should be added by default\n        self::assertSame(\n            '<p><img loading=\"lazy\" alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n\n        // loading should not be added when default is overridden by ?loading=auto\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?loading=auto)')\n        );\n\n        // loading=\"eager\" should be added when default is overridden by ?loading=eager\n        self::assertSame(\n            '<p><img loading=\"eager\" alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?loading=eager)')\n        );\n\n    }\n\n    public function testCLSAutoSizes(): void\n    {\n        $this->config->set('system.images.cls.auto_sizes', false);\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n\n        self::assertSame(\n            '<p><img height=\"1\" width=\"1\" alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?height=1&width=1)')\n        );\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" width=\"1024\" height=\"768\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?autoSizes=true)')\n        );\n\n        $this->config->set('system.images.cls.auto_sizes', true);\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" width=\"1024\" height=\"768\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?reset)')\n        );\n\n        self::assertSame(\n            '<p><img height=\"1\" width=\"1\" alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?height=1&width=1)')\n        );\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg?autoSizes=false)')\n        );\n\n        self::assertRegExp(\n            '/width=\"400\" height=\"200\"/',\n            $this->parsedown->text('![](sample-image.jpg?reset&resize=400,200)')\n        );\n\n        $this->config->set('system.images.cls.retina_scale', 2);\n\n\n        self::assertRegExp(\n            '/width=\"400\" height=\"200\"/',\n            $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400)')\n        );\n\n        $this->config->set('system.images.cls.retina_scale', 4);\n\n        self::assertRegExp(\n            '/width=\"200\" height=\"100\"/',\n            $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400)')\n        );\n\n        self::assertRegExp(\n            '/width=\"266\" height=\"133\"/',\n            $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400&retinaScale=3)')\n        );\n\n        $this->config->set('system.images.cls.aspect_ratio', true);\n\n        self::assertRegExp(\n            '/style=\"--aspect-ratio: 800\\/400;\"/',\n            $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400)')\n        );\n\n        $this->config->set('system.images.cls.aspect_ratio', false);\n\n        self::assertRegExp(\n            '/style=\"--aspect-ratio: 800\\/400;\"/',\n            $this->parsedown->text('![](sample-image.jpg?reset&resize=800,400&aspectRatio=true)')\n        );\n\n    }\n\n    public function testRootImages(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/')->init();\n\n        $defaults = [\n            'markdown' => [\n                'extra'            => false,\n                'auto_line_breaks' => false,\n                'auto_url_links'   => false,\n                'escape_markup'    => false,\n                'special_chars'    => ['>' => 'gt', '<' => 'lt'],\n            ],\n            'images' => $this->config->get('system.images', [])\n        ];\n        $page = $this->pages->find('/');\n        $excerpts = new Excerpts($page, $defaults);\n        $this->parsedown = new Parsedown($excerpts);\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/01.item1/home-sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](home-sample-image.jpg)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"\\/images\\/.*-home-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](home-cache-image.jpg?cache)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"\\/images\\/.*-home-cache-image.jpe?g\\?foo=1\" \\/><\\/p>|',\n            $this->parsedown->text('![](home-cache-image.jpg?cropResize=200,200&foo)')\n        );\n        self::assertSame(\n            '<p><img src=\"/home-missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](/home-missing-image.jpg)')\n        );\n\n        $this->config->set('system.languages.supported', ['fr','en']);\n        unset($this->grav['language']);\n        $this->grav['language'] = new Language($this->grav);\n        $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"/tests/fake/nested-site/user/pages/01.item1/home-sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](home-sample-image.jpg)')\n        );\n    }\n\n    public function testRootImagesSubDirAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><img alt=\"\" src=\"http://testing.dev/subdir/tests/fake/nested-site/user/pages/02.item2/02.item2-2/sample-image.jpg\" /></p>',\n            $this->parsedown->text('![](sample-image.jpg)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"http:\\/\\/testing.dev\\/subdir\\/images\\/.*-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](cache-image.jpg?cache)')\n        );\n        self::assertRegexp(\n            '|<p><img alt=\"\" src=\"http:\\/\\/testing.dev\\/subdir\\/images\\/.*-home-cache-image.jpe?g\" \\/><\\/p>|',\n            $this->parsedown->text('![](/home-cache-image.jpg?cropResize=200,200)')\n        );\n        self::assertSame(\n            '<p><img src=\"http://testing.dev/subdir/item2/item2-2/missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](missing-image.jpg)')\n        );\n        self::assertSame(\n            '<p><img src=\"http://testing.dev/subdir/home-missing-image.jpg\" alt=\"\" /></p>',\n            $this->parsedown->text('![](/home-missing-image.jpg)')\n        );\n    }\n\n    public function testRootAbsoluteLinks(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/')->init();\n\n        $defaults = [\n            'markdown' => [\n                'extra'            => false,\n                'auto_line_breaks' => false,\n                'auto_url_links'   => false,\n                'escape_markup'    => false,\n                'special_chars'    => ['>' => 'gt', '<' => 'lt'],\n            ],\n            'images' => $this->config->get('system.images', [])\n        ];\n        $page = $this->pages->find('/');\n        $excerpts = new Excerpts($page, $defaults);\n        $this->parsedown = new Parsedown($excerpts);\n\n        self::assertSame(\n            '<p><a href=\"/item1/item1-3\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](item1-3)')\n        );\n\n        self::assertSame(\n            '<p><a href=\"/item2\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](../item2)')\n        );\n\n        self::assertSame(\n            '<p><a href=\"/?foo=bar\">With Query</a></p>',\n            $this->parsedown->text('[With Query](?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/foo:bar\">With Param</a></p>',\n            $this->parsedown->text('[With Param](/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"#foo\">With Anchor</a></p>',\n            $this->parsedown->text('[With Anchor](#foo)')\n        );\n\n        $this->config->set('system.languages.supported', ['fr','en']);\n        unset($this->grav['language']);\n        $this->grav['language'] = new Language($this->grav);\n        $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"/fr/item2\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](../item2)')\n        );\n        self::assertSame(\n            '<p><a href=\"/fr/item1/item1-3\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](item1-3)')\n        );\n        self::assertSame(\n            '<p><a href=\"/fr/?foo=bar\">With Query</a></p>',\n            $this->parsedown->text('[With Query](?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/fr/foo:bar\">With Param</a></p>',\n            $this->parsedown->text('[With Param](/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"#foo\">With Anchor</a></p>',\n            $this->parsedown->text('[With Anchor](#foo)')\n        );\n    }\n\n\n    public function testAnchorLinksLangRelativeUrls(): void\n    {\n        $this->config->set('system.languages.supported', ['fr','en']);\n        unset($this->grav['language']);\n        $this->grav['language'] = new Language($this->grav);\n        $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"#foo\">Current Anchor</a></p>',\n            $this->parsedown->text('[Current Anchor](#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"/fr/#foo\">Root Anchor</a></p>',\n            $this->parsedown->text('[Root Anchor](/#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"/fr/item2/item2-1#foo\">Peer Anchor</a></p>',\n            $this->parsedown->text('[Peer Anchor](../item2-1#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"/fr/item2/item2-1#foo\">Peer Anchor 2</a></p>',\n            $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)')\n        );\n    }\n\n    public function testAnchorLinksLangAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->config->set('system.languages.supported', ['fr','en']);\n        unset($this->grav['language']);\n        $this->grav['language'] = new Language($this->grav);\n        $this->uri->initializeWithURL('http://testing.dev/fr/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"#foo\">Current Anchor</a></p>',\n            $this->parsedown->text('[Current Anchor](#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/fr/item2/item2-1#foo\">Peer Anchor</a></p>',\n            $this->parsedown->text('[Peer Anchor](../item2-1#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/fr/item2/item2-1#foo\">Peer Anchor 2</a></p>',\n            $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/fr/#foo\">Root Anchor</a></p>',\n            $this->parsedown->text('[Root Anchor](/#foo)')\n        );\n    }\n\n\n    public function testExternalLinks(): void\n    {\n        self::assertSame(\n            '<p><a href=\"http://www.cnn.com\">cnn.com</a></p>',\n            $this->parsedown->text('[cnn.com](http://www.cnn.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"https://www.google.com\">google.com</a></p>',\n            $this->parsedown->text('[google.com](https://www.google.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"https://github.com/getgrav/grav/issues/new?title=%5Badd-resource%5D%20New%20Plugin%2FTheme&amp;body=Hello%20%2A%2AThere%2A%2A\">complex url</a></p>',\n            $this->parsedown->text('[complex url](https://github.com/getgrav/grav/issues/new?title=[add-resource]%20New%20Plugin/Theme&body=Hello%20**There**)')\n        );\n    }\n\n    public function testExternalLinksSubDir(): void\n    {\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"http://www.cnn.com\">cnn.com</a></p>',\n            $this->parsedown->text('[cnn.com](http://www.cnn.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"https://www.google.com\">google.com</a></p>',\n            $this->parsedown->text('[google.com](https://www.google.com)')\n        );\n    }\n\n    public function testExternalLinksSubDirAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"http://www.cnn.com\">cnn.com</a></p>',\n            $this->parsedown->text('[cnn.com](http://www.cnn.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"https://www.google.com\">google.com</a></p>',\n            $this->parsedown->text('[google.com](https://www.google.com)')\n        );\n    }\n\n    public function testAnchorLinksRelativeUrls(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"#foo\">Current Anchor</a></p>',\n            $this->parsedown->text('[Current Anchor](#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"/#foo\">Root Anchor</a></p>',\n            $this->parsedown->text('[Root Anchor](/#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-1#foo\">Peer Anchor</a></p>',\n            $this->parsedown->text('[Peer Anchor](../item2-1#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-1#foo\">Peer Anchor 2</a></p>',\n            $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)')\n        );\n    }\n\n    public function testAnchorLinksAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"#foo\">Current Anchor</a></p>',\n            $this->parsedown->text('[Current Anchor](#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item2/item2-1#foo\">Peer Anchor</a></p>',\n            $this->parsedown->text('[Peer Anchor](../item2-1#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item2/item2-1#foo\">Peer Anchor 2</a></p>',\n            $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/#foo\">Root Anchor</a></p>',\n            $this->parsedown->text('[Root Anchor](/#foo)')\n        );\n    }\n\n    public function testAnchorLinksWithPortAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithURL('http://testing.dev:8080/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"http://testing.dev:8080/item2/item2-1#foo\">Peer Anchor</a></p>',\n            $this->parsedown->text('[Peer Anchor](../item2-1#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev:8080/item2/item2-1#foo\">Peer Anchor 2</a></p>',\n            $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"#foo\">Current Anchor</a></p>',\n            $this->parsedown->text('[Current Anchor](#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev:8080/#foo\">Root Anchor</a></p>',\n            $this->parsedown->text('[Root Anchor](/#foo)')\n        );\n    }\n\n    public function testAnchorLinksSubDirRelativeUrls(): void\n    {\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-1#foo\">Peer Anchor</a></p>',\n            $this->parsedown->text('[Peer Anchor](../item2-1#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-1#foo\">Peer Anchor 2</a></p>',\n            $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"#foo\">Current Anchor</a></p>',\n            $this->parsedown->text('[Current Anchor](#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/#foo\">Root Anchor</a></p>',\n            $this->parsedown->text('[Root Anchor](/#foo)')\n        );\n    }\n\n    public function testAnchorLinksSubDirAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-1#foo\">Peer Anchor</a></p>',\n            $this->parsedown->text('[Peer Anchor](../item2-1#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-1#foo\">Peer Anchor 2</a></p>',\n            $this->parsedown->text('[Peer Anchor 2](../item2-1/#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"#foo\">Current Anchor</a></p>',\n            $this->parsedown->text('[Current Anchor](#foo)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/#foo\">Root Anchor</a></p>',\n            $this->parsedown->text('[Root Anchor](/#foo)')\n        );\n    }\n\n    public function testSlugRelativeLinks(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"/\">Up to Root Level</a></p>',\n            $this->parsedown->text('[Up to Root Level](../..)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-1\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](../item2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-2/item2-2-1\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](item2-2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2\">Up a Level</a></p>',\n            $this->parsedown->text('[Up a Level](..)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item3/item3-3\">Up and Down</a></p>',\n            $this->parsedown->text('[Up and Down](../../item3/item3-3)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-2/item2-2-1?foo=bar\">Down a Level with Query</a></p>',\n            $this->parsedown->text('[Down a Level with Query](item2-2-1?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2?foo=bar\">Up a Level with Query</a></p>',\n            $this->parsedown->text('[Up a Level with Query](../?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item3/item3-3?foo=bar\">Up and Down with Query</a></p>',\n            $this->parsedown->text('[Up and Down with Query](../../item3/item3-3?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item3/item3-3/foo:bar\">Up and Down with Param</a></p>',\n            $this->parsedown->text('[Up and Down with Param](../../item3/item3-3/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item3/item3-3#foo\">Up and Down with Anchor</a></p>',\n            $this->parsedown->text('[Up and Down with Anchor](../../item3/item3-3#foo)')\n        );\n    }\n\n    public function testSlugRelativeLinksAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item2/item2-1\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](../item2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item2/item2-2/item2-2-1\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](item2-2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item2\">Up a Level</a></p>',\n            $this->parsedown->text('[Up a Level](..)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/\">Up to Root Level</a></p>',\n            $this->parsedown->text('[Up to Root Level](../..)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item3/item3-3\">Up and Down</a></p>',\n            $this->parsedown->text('[Up and Down](../../item3/item3-3)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item2/item2-2/item2-2-1?foo=bar\">Down a Level with Query</a></p>',\n            $this->parsedown->text('[Down a Level with Query](item2-2-1?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item2?foo=bar\">Up a Level with Query</a></p>',\n            $this->parsedown->text('[Up a Level with Query](../?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item3/item3-3?foo=bar\">Up and Down with Query</a></p>',\n            $this->parsedown->text('[Up and Down with Query](../../item3/item3-3?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item3/item3-3/foo:bar\">Up and Down with Param</a></p>',\n            $this->parsedown->text('[Up and Down with Param](../../item3/item3-3/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/item3/item3-3#foo\">Up and Down with Anchor</a></p>',\n            $this->parsedown->text('[Up and Down with Anchor](../../item3/item3-3#foo)')\n        );\n    }\n\n    public function testSlugRelativeLinksSubDir(): void\n    {\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-1\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](../item2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-2/item2-2-1\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](item2-2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2\">Up a Level</a></p>',\n            $this->parsedown->text('[Up a Level](..)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir\">Up to Root Level</a></p>',\n            $this->parsedown->text('[Up to Root Level](../..)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item3/item3-3\">Up and Down</a></p>',\n            $this->parsedown->text('[Up and Down](../../item3/item3-3)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-2/item2-2-1?foo=bar\">Down a Level with Query</a></p>',\n            $this->parsedown->text('[Down a Level with Query](item2-2-1?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2?foo=bar\">Up a Level with Query</a></p>',\n            $this->parsedown->text('[Up a Level with Query](../?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item3/item3-3?foo=bar\">Up and Down with Query</a></p>',\n            $this->parsedown->text('[Up and Down with Query](../../item3/item3-3?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item3/item3-3/foo:bar\">Up and Down with Param</a></p>',\n            $this->parsedown->text('[Up and Down with Param](../../item3/item3-3/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item3/item3-3#foo\">Up and Down with Anchor</a></p>',\n            $this->parsedown->text('[Up and Down with Anchor](../../item3/item3-3#foo)')\n        );\n    }\n\n    public function testSlugRelativeLinksSubDirAbsoluteUrls(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-1\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](../item2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-2/item2-2-1\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](item2-2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2\">Up a Level</a></p>',\n            $this->parsedown->text('[Up a Level](..)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir\">Up to Root Level</a></p>',\n            $this->parsedown->text('[Up to Root Level](../..)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item3/item3-3\">Up and Down</a></p>',\n            $this->parsedown->text('[Up and Down](../../item3/item3-3)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-2/item2-2-1?foo=bar\">Down a Level with Query</a></p>',\n            $this->parsedown->text('[Down a Level with Query](item2-2-1?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2?foo=bar\">Up a Level with Query</a></p>',\n            $this->parsedown->text('[Up a Level with Query](../?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item3/item3-3?foo=bar\">Up and Down with Query</a></p>',\n            $this->parsedown->text('[Up and Down with Query](../../item3/item3-3?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item3/item3-3/foo:bar\">Up and Down with Param</a></p>',\n            $this->parsedown->text('[Up and Down with Param](../../item3/item3-3/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item3/item3-3#foo\">Up and Down with Anchor</a></p>',\n            $this->parsedown->text('[Up and Down with Anchor](../../item3/item3-3#foo)')\n        );\n    }\n\n\n    public function testDirectoryRelativeLinks(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"/item3/item3-3/foo:bar\">Up and Down with Param</a></p>',\n            $this->parsedown->text('[Up and Down with Param](../../03.item3/03.item3-3/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-1\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](../01.item2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-2/item2-2-1\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](01.item2-2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item3/item3-3\">Up and Down</a></p>',\n            $this->parsedown->text('[Up and Down](../../03.item3/03.item3-3)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-2/item2-2-1?foo=bar\">Down a Level with Query</a></p>',\n            $this->parsedown->text('[Down a Level with Query](01.item2-2-1?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item3/item3-3?foo=bar\">Up and Down with Query</a></p>',\n            $this->parsedown->text('[Up and Down with Query](../../03.item3/03.item3-3?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item3/item3-3#foo\">Up and Down with Anchor</a></p>',\n            $this->parsedown->text('[Up and Down with Anchor](../../03.item3/03.item3-3#foo)')\n        );\n    }\n\n\n    public function testAbsoluteLinks(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"/\">Root</a></p>',\n            $this->parsedown->text('[Root](/)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-1\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](/item2/item2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-2/item2-2-1\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](/item2/item2-2/item2-2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2\">Up a Level</a></p>',\n            $this->parsedown->text('[Up a Level](/item2)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2?foo=bar\">With Query</a></p>',\n            $this->parsedown->text('[With Query](/item2?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/foo:bar\">With Param</a></p>',\n            $this->parsedown->text('[With Param](/item2/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2#foo\">With Anchor</a></p>',\n            $this->parsedown->text('[With Anchor](/item2#foo)')\n        );\n    }\n\n    public function testDirectoryAbsoluteLinksSubDir(): void\n    {\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"/subdir/\">Root</a></p>',\n            $this->parsedown->text('[Root](/)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-1\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](/item2/item2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-2/item2-2-1\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](/item2/item2-2/item2-2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2\">Up a Level</a></p>',\n            $this->parsedown->text('[Up a Level](/item2)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2?foo=bar\">With Query</a></p>',\n            $this->parsedown->text('[With Query](/item2?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/foo:bar\">With Param</a></p>',\n            $this->parsedown->text('[With Param](/item2/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2#foo\">With Anchor</a></p>',\n            $this->parsedown->text('[With Anchor](/item2#foo)')\n        );\n    }\n\n    public function testDirectoryAbsoluteLinksSubDirAbsoluteUrl(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/\">Root</a></p>',\n            $this->parsedown->text('[Root](/)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-1\">Peer Page</a></p>',\n            $this->parsedown->text('[Peer Page](/item2/item2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-2/item2-2-1\">Down a Level</a></p>',\n            $this->parsedown->text('[Down a Level](/item2/item2-2/item2-2-1)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2\">Up a Level</a></p>',\n            $this->parsedown->text('[Up a Level](/item2)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2?foo=bar\">With Query</a></p>',\n            $this->parsedown->text('[With Query](/item2?foo=bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/foo:bar\">With Param</a></p>',\n            $this->parsedown->text('[With Param](/item2/foo:bar)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2#foo\">With Anchor</a></p>',\n            $this->parsedown->text('[With Anchor](/item2#foo)')\n        );\n    }\n\n    public function testSpecialProtocols(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"mailto:user@domain.com\">mailto</a></p>',\n            $this->parsedown->text('[mailto](mailto:user@domain.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"xmpp:xyx@domain.com\">xmpp</a></p>',\n            $this->parsedown->text('[xmpp](xmpp:xyx@domain.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"tel:123-555-12345\">tel</a></p>',\n            $this->parsedown->text('[tel](tel:123-555-12345)')\n        );\n        self::assertSame(\n            '<p><a href=\"sms:123-555-12345\">sms</a></p>',\n            $this->parsedown->text('[sms](sms:123-555-12345)')\n        );\n        self::assertSame(\n            '<p><a href=\"rdp://ts.example.com\">ts.example.com</a></p>',\n            $this->parsedown->text('[ts.example.com](rdp://ts.example.com)')\n        );\n    }\n\n    public function testSpecialProtocolsSubDir(): void\n    {\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"mailto:user@domain.com\">mailto</a></p>',\n            $this->parsedown->text('[mailto](mailto:user@domain.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"xmpp:xyx@domain.com\">xmpp</a></p>',\n            $this->parsedown->text('[xmpp](xmpp:xyx@domain.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"tel:123-555-12345\">tel</a></p>',\n            $this->parsedown->text('[tel](tel:123-555-12345)')\n        );\n        self::assertSame(\n            '<p><a href=\"sms:123-555-12345\">sms</a></p>',\n            $this->parsedown->text('[sms](sms:123-555-12345)')\n        );\n        self::assertSame(\n            '<p><a href=\"rdp://ts.example.com\">ts.example.com</a></p>',\n            $this->parsedown->text('[ts.example.com](rdp://ts.example.com)')\n        );\n    }\n\n    public function testSpecialProtocolsSubDirAbsoluteUrl(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"mailto:user@domain.com\">mailto</a></p>',\n            $this->parsedown->text('[mailto](mailto:user@domain.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"xmpp:xyx@domain.com\">xmpp</a></p>',\n            $this->parsedown->text('[xmpp](xmpp:xyx@domain.com)')\n        );\n        self::assertSame(\n            '<p><a href=\"tel:123-555-12345\">tel</a></p>',\n            $this->parsedown->text('[tel](tel:123-555-12345)')\n        );\n        self::assertSame(\n            '<p><a href=\"sms:123-555-12345\">sms</a></p>',\n            $this->parsedown->text('[sms](sms:123-555-12345)')\n        );\n        self::assertSame(\n            '<p><a href=\"rdp://ts.example.com\">ts.example.com</a></p>',\n            $this->parsedown->text('[ts.example.com](rdp://ts.example.com)')\n        );\n    }\n\n    public function testReferenceLinks(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        $sample = '[relative link][r_relative]\n                   [r_relative]: ../item2-3#blah';\n        self::assertSame(\n            '<p><a href=\"/item2/item2-3#blah\">relative link</a></p>',\n            $this->parsedown->text($sample)\n        );\n\n        $sample = '[absolute link][r_absolute]\n                   [r_absolute]: /item3#blah';\n        self::assertSame(\n            '<p><a href=\"/item3#blah\">absolute link</a></p>',\n            $this->parsedown->text($sample)\n        );\n\n        $sample = '[external link][r_external]\n                   [r_external]: http://www.cnn.com';\n        self::assertSame(\n            '<p><a href=\"http://www.cnn.com\">external link</a></p>',\n            $this->parsedown->text($sample)\n        );\n    }\n\n    public function testAttributeLinks(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"#something\" class=\"button\">Anchor Class</a></p>',\n            $this->parsedown->text('[Anchor Class](?classes=button#something)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-3\" class=\"button\">Relative Class</a></p>',\n            $this->parsedown->text('[Relative Class](../item2-3?classes=button)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-3\" id=\"unique\">Relative ID</a></p>',\n            $this->parsedown->text('[Relative ID](../item2-3?id=unique)')\n        );\n        self::assertSame(\n            '<p><a href=\"https://github.com/getgrav/grav\" class=\"button big\">External</a></p>',\n            $this->parsedown->text('[External](https://github.com/getgrav/grav?classes=button,big)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-3?id=unique\">Relative Noprocess</a></p>',\n            $this->parsedown->text('[Relative Noprocess](../item2-3?id=unique&noprocess)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-3\" target=\"_blank\">Relative Target</a></p>',\n            $this->parsedown->text('[Relative Target](../item2-3?target=_blank)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-3\" rel=\"nofollow\">Relative Rel</a></p>',\n            $this->parsedown->text('[Relative Rel](../item2-3?rel=nofollow)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-3?foo=bar&amp;baz=qux\" rel=\"nofollow\" class=\"button\">Relative Mixed</a></p>',\n            $this->parsedown->text('[Relative Mixed](../item2-3?foo=bar&baz=qux&rel=nofollow&class=button)')\n        );\n    }\n\n    public function testInvalidLinks(): void\n    {\n        $this->uri->initializeWithURL('http://testing.dev/item2/item2-2')->init();\n\n        self::assertSame(\n            '<p><a href=\"/item2/item2-2/no-page\">Non Existent Page</a></p>',\n            $this->parsedown->text('[Non Existent Page](no-page)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-2/existing-file.zip\">Existent File</a></p>',\n            $this->parsedown->text('[Existent File](existing-file.zip)')\n        );\n        self::assertSame(\n            '<p><a href=\"/item2/item2-2/missing-file.zip\">Non Existent File</a></p>',\n            $this->parsedown->text('[Non Existent File](missing-file.zip)')\n        );\n    }\n\n    public function testInvalidLinksSubDir(): void\n    {\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-2/no-page\">Non Existent Page</a></p>',\n            $this->parsedown->text('[Non Existent Page](no-page)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-2/existing-file.zip\">Existent File</a></p>',\n            $this->parsedown->text('[Existent File](existing-file.zip)')\n        );\n        self::assertSame(\n            '<p><a href=\"/subdir/item2/item2-2/missing-file.zip\">Non Existent File</a></p>',\n            $this->parsedown->text('[Non Existent File](missing-file.zip)')\n        );\n    }\n\n    public function testInvalidLinksSubDirAbsoluteUrl(): void\n    {\n        $this->config->set('system.absolute_urls', true);\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/item2/item2-2', '/subdir')->init();\n\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-2/no-page\">Non Existent Page</a></p>',\n            $this->parsedown->text('[Non Existent Page](no-page)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-2/existing-file.zip\">Existent File</a></p>',\n            $this->parsedown->text('[Existent File](existing-file.zip)')\n        );\n        self::assertSame(\n            '<p><a href=\"http://testing.dev/subdir/item2/item2-2/missing-file.zip\">Non Existent File</a></p>',\n            $this->parsedown->text('[Non Existent File](missing-file.zip)')\n        );\n    }\n\n\n    /**\n     * @param $string\n     *\n     * @return mixed\n     */\n    private function stripLeadingWhitespace($string)\n    {\n        return preg_replace('/^\\s*(.*)/', '', $string);\n    }\n\n    private function setImagesDefaults($defaults) {\n        $defaults = [\n            'images' => [\n                'defaults' => $defaults\n            ],\n        ];\n        $page = $this->pages->find('/item2/item2-2');\n        $excerpts = new Excerpts($page, $defaults);\n        $this->parsedown = new Parsedown($excerpts);\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/Page/PagesTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Page\\Pages;\nuse Grav\\Common\\Page\\Page;\nuse Grav\\Common\\Page\\Interfaces\\PageInterface;\nuse RocketTheme\\Toolbox\\ResourceLocator\\UniformResourceLocator;\n\n/**\n * Class PagesTest\n */\nclass PagesTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var Pages $pages */\n    protected $pages;\n\n    /** @var PageInterface $root_page */\n    protected $root_page;\n\n    protected function _before(): void\n    {\n        $grav = Fixtures::get('grav');\n        $this->grav = $grav();\n        $this->pages = $this->grav['pages'];\n        $this->grav['config']->set('system.home.alias', '/home');\n\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n\n        $locator->addPath('page', '', 'tests/fake/simple-site/user/pages', false);\n        $this->pages->init();\n    }\n\n    public function testBase(): void\n    {\n        self::assertSame('', $this->pages->base());\n        $this->pages->base('/test');\n        self::assertSame('/test', $this->pages->base());\n        $this->pages->base('');\n        self::assertSame($this->pages->base(), '');\n    }\n\n    public function testLastModified(): void\n    {\n        self::assertNull($this->pages->lastModified());\n        $this->pages->lastModified('test');\n        self::assertSame('test', $this->pages->lastModified());\n    }\n\n    public function testInstances(): void\n    {\n        self::assertIsArray($this->pages->instances());\n        foreach ($this->pages->instances() as $instance) {\n            self::assertInstanceOf(PageInterface::class, $instance);\n        }\n    }\n\n    public function testRoutes(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $folder = $locator->findResource('tests://');\n\n        self::assertIsArray($this->pages->routes());\n        self::assertSame($folder . '/fake/simple-site/user/pages/01.home', $this->pages->routes()['/']);\n        self::assertSame($folder . '/fake/simple-site/user/pages/01.home', $this->pages->routes()['/home']);\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog', $this->pages->routes()['/blog']);\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', $this->pages->routes()['/blog/post-one']);\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', $this->pages->routes()['/blog/post-two']);\n        self::assertSame($folder . '/fake/simple-site/user/pages/03.about', $this->pages->routes()['/about']);\n    }\n\n    public function testAddPage(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $folder = $locator->findResource('tests://');\n\n        $path = $folder . '/fake/single-pages/01.simple-page/default.md';\n        $aPage = new Page();\n        $aPage->init(new \\SplFileInfo($path));\n\n        $this->pages->addPage($aPage, '/new-page');\n\n        self::assertContains('/new-page', array_keys($this->pages->routes()));\n        self::assertSame($folder . '/fake/single-pages/01.simple-page', $this->pages->routes()['/new-page']);\n    }\n\n    public function testSort(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $folder = $locator->findResource('tests://');\n\n        $aPage = $this->pages->find('/blog');\n        $subPagesSorted = $this->pages->sort($aPage);\n\n        self::assertIsArray($subPagesSorted);\n        self::assertCount(2, $subPagesSorted);\n\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)[0]);\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)[1]);\n\n        self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted));\n        self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted));\n\n        self::assertSame(['slug' => 'post-one'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-one']);\n        self::assertSame(['slug' => 'post-two'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-two']);\n\n        $subPagesSorted = $this->pages->sort($aPage, null, 'desc');\n\n        self::assertIsArray($subPagesSorted);\n        self::assertCount(2, $subPagesSorted);\n\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)[0]);\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)[1]);\n\n        self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted));\n        self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted));\n\n        self::assertSame(['slug' => 'post-one'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-one']);\n        self::assertSame(['slug' => 'post-two'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-two']);\n    }\n\n    public function testSortCollection(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $folder = $locator->findResource('tests://');\n\n        $aPage = $this->pages->find('/blog');\n        $subPagesSorted = $this->pages->sortCollection($aPage->children(), $aPage->orderBy());\n\n        self::assertIsArray($subPagesSorted);\n        self::assertCount(2, $subPagesSorted);\n\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)[0]);\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)[1]);\n\n        self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted));\n        self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted));\n\n        self::assertSame(['slug' => 'post-one'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-one']);\n        self::assertSame(['slug' => 'post-two'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-two']);\n\n        $subPagesSorted = $this->pages->sortCollection($aPage->children(), $aPage->orderBy(), 'desc');\n\n        self::assertIsArray($subPagesSorted);\n        self::assertCount(2, $subPagesSorted);\n\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted)[0]);\n        self::assertSame($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted)[1]);\n\n        self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-one', array_keys($subPagesSorted));\n        self::assertContains($folder . '/fake/simple-site/user/pages/02.blog/post-two', array_keys($subPagesSorted));\n\n        self::assertSame(['slug' => 'post-one'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-one']);\n        self::assertSame(['slug' => 'post-two'], $subPagesSorted[$folder . '/fake/simple-site/user/pages/02.blog/post-two']);\n    }\n\n    public function testGet(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $folder = $locator->findResource('tests://');\n\n        //Page existing\n        $aPage = $this->pages->get($folder . '/fake/simple-site/user/pages/03.about');\n        self::assertInstanceOf(PageInterface::class, $aPage);\n\n        //Page not existing\n        $anotherPage = $this->pages->get($folder . '/fake/simple-site/user/pages/03.non-existing');\n        self::assertNull($anotherPage);\n    }\n\n    public function testChildren(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $folder = $locator->findResource('tests://');\n\n        //Page existing\n        $children = $this->pages->children($folder . '/fake/simple-site/user/pages/02.blog');\n        self::assertInstanceOf('Grav\\Common\\Page\\Collection', $children);\n\n        //Page not existing\n        $children = $this->pages->children($folder . '/fake/whatever/non-existing');\n        self::assertSame([], $children->toArray());\n    }\n\n    public function testDispatch(): void\n    {\n        $aPage = $this->pages->dispatch('/blog');\n        self::assertInstanceOf(PageInterface::class, $aPage);\n\n        $aPage = $this->pages->dispatch('/about');\n        self::assertInstanceOf(PageInterface::class, $aPage);\n\n        $aPage = $this->pages->dispatch('/blog/post-one');\n        self::assertInstanceOf(PageInterface::class, $aPage);\n\n        //Page not existing\n        $aPage = $this->pages->dispatch('/non-existing');\n        self::assertNull($aPage);\n    }\n\n    public function testRoot(): void\n    {\n        $root = $this->pages->root();\n        self::assertInstanceOf(PageInterface::class, $root);\n        self::assertSame('pages', $root->folder());\n    }\n\n    public function testBlueprints(): void\n    {\n    }\n\n    public function testAll()\n    {\n        self::assertIsObject($this->pages->all());\n        self::assertIsArray($this->pages->all()->toArray());\n        foreach ($this->pages->all() as $page) {\n            self::assertInstanceOf(PageInterface::class, $page);\n        }\n    }\n\n    public function testGetList(): void\n    {\n        $list = $this->pages->getList();\n        self::assertIsArray($list);\n        self::assertSame('&mdash;-&rtrif; Home', $list['/']);\n        self::assertSame('&mdash;-&rtrif; Blog', $list['/blog']);\n    }\n\n    public function testTranslatedLanguages(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $folder = $locator->findResource('tests://');\n\n        $page = $this->pages->get($folder . '/fake/simple-site/user/pages/04.page-translated');\n        $this->assertInstanceOf(PageInterface::class, $page);\n        $translatedLanguages = $page->translatedLanguages();\n        $this->assertIsArray($translatedLanguages);\n        $this->assertSame([\"en\" => \"/page-translated\", \"fr\" => \"/page-translated\"], $translatedLanguages);\n    }\n\n    public function testLongPathTranslatedLanguages(): void\n    {\n        /** @var UniformResourceLocator $locator */\n        $locator = $this->grav['locator'];\n        $folder = $locator->findResource('tests://');\n        $page = $this->pages->get($folder . '/fake/simple-site/user/pages/05.translatedlong/part2');\n        $this->assertInstanceOf(PageInterface::class, $page);\n        $translatedLanguages = $page->translatedLanguages();\n        $this->assertIsArray($translatedLanguages);\n        $this->assertSame([\"en\" => \"/translatedlong/part2\", \"fr\" => \"/translatedlong/part2\"], $translatedLanguages);\n    }\n\n    public function testGetTypes(): void\n    {\n    }\n\n    public function testTypes(): void\n    {\n    }\n\n    public function testModularTypes(): void\n    {\n    }\n\n    public function testPageTypes(): void\n    {\n    }\n\n    public function testAccessLevels(): void\n    {\n    }\n\n    public function testParents(): void\n    {\n    }\n\n    public function testParentsRawRoutes(): void\n    {\n    }\n\n    public function testGetHomeRoute(): void\n    {\n    }\n\n    public function testResetPages(): void\n    {\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/Twig/Extensions/GravExtensionTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Twig\\Extension\\GravExtension;\n\n/**\n * Class GravExtensionTest\n */\nclass GravExtensionTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var  GravExtension $twig_ext */\n    protected $twig_ext;\n\n    protected function _before(): void\n    {\n        $this->grav = Fixtures::get('grav');\n        $this->twig_ext = new GravExtension();\n    }\n\n    public function testInflectorFilter(): void\n    {\n        self::assertSame('people', $this->twig_ext->inflectorFilter('plural', 'person'));\n        self::assertSame('shoe', $this->twig_ext->inflectorFilter('singular', 'shoes'));\n        self::assertSame('Welcome Page', $this->twig_ext->inflectorFilter('title', 'welcome page'));\n        self::assertSame('SendEmail', $this->twig_ext->inflectorFilter('camel', 'send_email'));\n        self::assertSame('camel_cased', $this->twig_ext->inflectorFilter('underscor', 'CamelCased'));\n        self::assertSame('something-text', $this->twig_ext->inflectorFilter('hyphen', 'Something Text'));\n        self::assertSame('Something text to read', $this->twig_ext->inflectorFilter('human', 'something_text_to_read'));\n        self::assertSame(5, $this->twig_ext->inflectorFilter('month', '175'));\n        self::assertSame('10th', $this->twig_ext->inflectorFilter('ordinal', '10'));\n    }\n\n    public function testMd5Filter(): void\n    {\n        self::assertSame(md5('grav'), $this->twig_ext->md5Filter('grav'));\n        self::assertSame(md5('devs@getgrav.org'), $this->twig_ext->md5Filter('devs@getgrav.org'));\n    }\n\n    public function testKsortFilter(): void\n    {\n        $object = array(\"name\"=>\"Bob\",\"age\"=>8,\"colour\"=>\"red\");\n        self::assertSame(array(\"age\"=>8,\"colour\"=>\"red\",\"name\"=>\"Bob\"), $this->twig_ext->ksortFilter($object));\n    }\n\n    public function testContainsFilter(): void\n    {\n        self::assertTrue($this->twig_ext->containsFilter('grav', 'grav'));\n        self::assertTrue($this->twig_ext->containsFilter('So, I found this new cms, called grav, and it\\'s pretty awesome guys', 'grav'));\n    }\n\n    public function testNicetimeFilter(): void\n    {\n        $now = time();\n        $threeMinutes = time() - (60*3);\n        $threeHours   = time() - (60*60*3);\n        $threeDays    = time() - (60*60*24*3);\n        $threeMonths  = time() - (60*60*24*30*3);\n        $threeYears   = time() - (60*60*24*365*3);\n        $measures = ['minutes','hours','days','months','years'];\n\n        self::assertSame('No date provided', $this->twig_ext->nicetimeFunc(null));\n\n        for ($i=0; $i<count($measures); $i++) {\n            $time = 'three' . ucfirst($measures[$i]);\n            self::assertSame('3 ' . $measures[$i] . ' ago', $this->twig_ext->nicetimeFunc($$time));\n        }\n    }\n\n    public function testRandomizeFilter(): void\n    {\n        $array = [1,2,3,4,5];\n        self::assertContains(2, $this->twig_ext->randomizeFilter($array));\n        self::assertSame($array, $this->twig_ext->randomizeFilter($array, 5));\n        self::assertSame($array[0], $this->twig_ext->randomizeFilter($array, 1)[0]);\n        self::assertSame($array[3], $this->twig_ext->randomizeFilter($array, 4)[3]);\n        self::assertSame($array[1], $this->twig_ext->randomizeFilter($array, 4)[1]);\n    }\n\n    public function testModulusFilter(): void\n    {\n        self::assertSame(3, $this->twig_ext->modulusFilter(3, 4));\n        self::assertSame(1, $this->twig_ext->modulusFilter(11, 2));\n        self::assertSame(0, $this->twig_ext->modulusFilter(10, 2));\n        self::assertSame(2, $this->twig_ext->modulusFilter(10, 4));\n    }\n\n    public function testAbsoluteUrlFilter(): void\n    {\n    }\n\n    public function testMarkdownFilter(): void\n    {\n    }\n\n    public function testStartsWithFilter(): void\n    {\n    }\n\n    public function testEndsWithFilter(): void\n    {\n    }\n\n    public function testDefinedDefaultFilter(): void\n    {\n    }\n\n    public function testRtrimFilter(): void\n    {\n    }\n\n    public function testLtrimFilter(): void\n    {\n    }\n\n    public function testRepeatFunc(): void\n    {\n    }\n\n    public function testRegexReplace(): void\n    {\n    }\n\n    public function testUrlFunc(): void\n    {\n    }\n\n    public function testEvaluateFunc(): void\n    {\n    }\n\n    public function testDump(): void\n    {\n    }\n\n    public function testGistFunc(): void\n    {\n    }\n\n    public function testRandomStringFunc(): void\n    {\n    }\n\n    public function testPadFilter(): void\n    {\n    }\n\n    public function testArrayFunc(): void\n    {\n        self::assertSame(\n            'this is my text',\n            $this->twig_ext->regexReplace('<p>this is my text</p>', '(<\\/?p>)', '')\n        );\n        self::assertSame(\n            '<i>this is my text</i>',\n            $this->twig_ext->regexReplace('<p>this is my text</p>', ['(<p>)','(<\\/p>)'], ['<i>','</i>'])\n        );\n    }\n\n    public function testArrayKeyValue(): void\n    {\n        self::assertSame(\n            ['meat' => 'steak'],\n            $this->twig_ext->arrayKeyValueFunc('meat', 'steak')\n        );\n        self::assertSame(\n            ['fruit' => 'apple', 'meat' => 'steak'],\n            $this->twig_ext->arrayKeyValueFunc('meat', 'steak', ['fruit' => 'apple'])\n        );\n    }\n\n    public function stringFunc(): void\n    {\n    }\n\n    public function testRangeFunc(): void\n    {\n        $hundred = [];\n        for ($i = 0; $i <= 100; $i++) {\n            $hundred[] = $i;\n        }\n\n\n        self::assertSame([0], $this->twig_ext->rangeFunc(0, 0));\n        self::assertSame([0, 1, 2], $this->twig_ext->rangeFunc(0, 2));\n\n        self::assertSame([0, 5, 10, 15], $this->twig_ext->rangeFunc(0, 16, 5));\n\n        // default (min 0, max 100, step 1)\n        self::assertSame($hundred, $this->twig_ext->rangeFunc());\n\n        // 95 items, starting from 5, (min 5, max 100, step 1)\n        self::assertSame(array_slice($hundred, 5), $this->twig_ext->rangeFunc(5));\n\n        // reversed range\n        self::assertSame(array_reverse($hundred), $this->twig_ext->rangeFunc(100, 0));\n        self::assertSame([4, 2, 0], $this->twig_ext->rangeFunc(4, 0, 2));\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/UriTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Config\\Config;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\n\n/**\n * Class UriTest\n */\nclass UriTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var Uri $uri */\n    protected $uri;\n\n    /** @var Config $config */\n    protected $config;\n\n    protected $tests = [\n        '/path' => [\n            'scheme' => '',\n            'user' => null,\n            'password' => null,\n            'host' => null,\n            'port' => null,\n            'path' => '/path',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/path',\n            'paths' => ['path'],\n            'params' => null,\n            'url' => '/path',\n            'environment' => 'unknown',\n            'basename' => 'path',\n            'base' => '',\n            'currentPage' => 1,\n            'rootUrl' => '',\n            'extension' => null,\n            'addNonce' => '/path/nonce:{{nonce}}',\n        ],\n        '//localhost/' => [\n            'scheme' => '//',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => null,\n            'path' => '/',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/',\n            'paths' => [],\n            'params' => null,\n            'url' => '/',\n            'environment' => 'localhost',\n            'basename' => '',\n            'base' => '//localhost',\n            'currentPage' => 1,\n            'rootUrl' => '//localhost',\n            'extension' => null,\n            'addNonce' => '//localhost/nonce:{{nonce}}',\n        ],\n        'http://localhost/' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/',\n            'paths' => [],\n            'params' => null,\n            'url' => '/',\n            'environment' => 'localhost',\n            'basename' => '',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            'addNonce' => 'http://localhost/nonce:{{nonce}}',\n        ],\n        'http://127.0.0.1/' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => '127.0.0.1',\n            'port' => 80,\n            'path' => '/',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/',\n            'paths' => [],\n            'params' => null,\n            'url' => '/',\n            'environment' => 'localhost',\n            'basename' => '',\n            'base' => 'http://127.0.0.1',\n            'currentPage' => 1,\n            'rootUrl' => 'http://127.0.0.1',\n            'extension' => null,\n            'addNonce' => 'http://127.0.0.1/nonce:{{nonce}}',\n        ],\n        'https://localhost/' => [\n            'scheme' => 'https://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 443,\n            'path' => '/',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/',\n            'paths' => [],\n            'params' => null,\n            'url' => '/',\n            'environment' => 'localhost',\n            'basename' => '',\n            'base' => 'https://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'https://localhost',\n            'extension' => null,\n            'addNonce' => 'https://localhost/nonce:{{nonce}}',\n        ],\n        'http://localhost:8080/grav/it/ueper' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/grav/it/ueper',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/grav/it/ueper',\n            'paths' => ['grav', 'it', 'ueper'],\n            'params' => null,\n            'url' => '/grav/it/ueper',\n            'environment' => 'localhost',\n            'basename' => 'ueper',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}',\n        ],\n        'http://localhost:8080/grav/it/ueper:xxx' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/grav/it',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/grav/it',\n            'paths' => ['grav', 'it'],\n            'params' => '/ueper:xxx',\n            'url' => '/grav/it',\n            'environment' => 'localhost',\n            'basename' => 'it',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/grav/it/ueper:xxx/nonce:{{nonce}}',\n        ],\n        'http://localhost:8080/grav/it/ueper:xxx/page:/test:yyy' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/grav/it',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/grav/it',\n            'paths' => ['grav', 'it'],\n            'params' => '/ueper:xxx/page:/test:yyy',\n            'url' => '/grav/it',\n            'environment' => 'localhost',\n            'basename' => 'it',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/grav/it/ueper:xxx/page:/test:yyy/nonce:{{nonce}}',\n        ],\n        'http://localhost:8080/grav/it/ueper?test=x' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/grav/it/ueper',\n            'query' => 'test=x',\n            'fragment' => null,\n\n            'route' => '/grav/it/ueper',\n            'paths' => ['grav', 'it', 'ueper'],\n            'params' => null,\n            'url' => '/grav/it/ueper',\n            'environment' => 'localhost',\n            'basename' => 'ueper',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}?test=x',\n        ],\n        'http://localhost:80/grav/it/ueper?test=x' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/grav/it/ueper',\n            'query' => 'test=x',\n            'fragment' => null,\n\n            'route' => '/grav/it/ueper',\n            'paths' => ['grav', 'it', 'ueper'],\n            'params' => null,\n            'url' => '/grav/it/ueper',\n            'environment' => 'localhost',\n            'basename' => 'ueper',\n            'base' => 'http://localhost:80',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:80',\n            'extension' => null,\n            'addNonce' => 'http://localhost:80/grav/it/ueper/nonce:{{nonce}}?test=x',\n        ],\n        'http://localhost/grav/it/ueper?test=x' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/grav/it/ueper',\n            'query' => 'test=x',\n            'fragment' => null,\n\n            'route' => '/grav/it/ueper',\n            'paths' => ['grav', 'it', 'ueper'],\n            'params' => null,\n            'url' => '/grav/it/ueper',\n            'environment' => 'localhost',\n            'basename' => 'ueper',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            'addNonce' => 'http://localhost/grav/it/ueper/nonce:{{nonce}}?test=x',\n        ],\n        'http://grav/grav/it/ueper' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'grav',\n            'port' => 80,\n            'path' => '/grav/it/ueper',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/grav/it/ueper',\n            'paths' => ['grav', 'it', 'ueper'],\n            'params' => null,\n            'url' => '/grav/it/ueper',\n            'environment' => 'grav',\n            'basename' => 'ueper',\n            'base' => 'http://grav',\n            'currentPage' => 1,\n            'rootUrl' => 'http://grav',\n            'extension' => null,\n            'addNonce' => 'http://grav/grav/it/ueper/nonce:{{nonce}}',\n        ],\n        'https://username:password@api.getgrav.com:4040/v1/post/128/page:x/?all=1' => [\n            'scheme' => 'https://',\n            'user' => 'username',\n            'password' => 'password',\n            'host' => 'api.getgrav.com',\n            'port' => 4040,\n            'path' => '/v1/post/128/', // FIXME <-\n            'query' => 'all=1',\n            'fragment' => null,\n\n            'route' => '/v1/post/128',\n            'paths' => ['v1', 'post', '128'],\n            'params' => '/page:x',\n            'url' => '/v1/post/128',\n            'environment' => 'api.getgrav.com',\n            'basename' => '128',\n            'base' => 'https://api.getgrav.com:4040',\n            'currentPage' => 1,\n            'rootUrl' => 'https://api.getgrav.com:4040',\n            'extension' => null,\n            'addNonce' => 'https://username:password@api.getgrav.com:4040/v1/post/128/page:x/nonce:{{nonce}}?all=1',\n            'toOriginalString' => 'https://username:password@api.getgrav.com:4040/v1/post/128/page:x?all=1'\n        ],\n        'https://google.com:443/' => [\n            'scheme' => 'https://',\n            'user' => null,\n            'password' => null,\n            'host' => 'google.com',\n            'port' => 443,\n            'path' => '/',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/',\n            'paths' => [],\n            'params' => null,\n            'url' => '/',\n            'environment' => 'google.com',\n            'basename' => '',\n            'base' => 'https://google.com:443',\n            'currentPage' => 1,\n            'rootUrl' => 'https://google.com:443',\n            'extension' => null,\n            'addNonce' => 'https://google.com:443/nonce:{{nonce}}',\n        ],\n        // Path tests.\n        'http://localhost:8080/a/b/c/d' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/a/b/c/d',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/a/b/c/d',\n            'paths' => ['a', 'b', 'c', 'd'],\n            'params' => null,\n            'url' => '/a/b/c/d',\n            'environment' => 'localhost',\n            'basename' => 'd',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/a/b/c/d/nonce:{{nonce}}',\n        ],\n        'http://localhost:8080/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f',\n            'paths' => ['a', 'b', 'c', 'd', 'e', 'f', 'a', 'b', 'c', 'd', 'e', 'f', 'a', 'b', 'c', 'd', 'e', 'f'],\n            'params' => null,\n            'url' => '/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f',\n            'environment' => 'localhost',\n            'basename' => 'f',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f/nonce:{{nonce}}',\n        ],\n        'http://localhost/this is the path/my page' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/this%20is%20the%20path/my%20page',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/this%20is%20the%20path/my%20page',\n            'paths' => ['this%20is%20the%20path', 'my%20page'],\n            'params' => null,\n            'url' => '/this%20is%20the%20path/my%20page',\n            'environment' => 'localhost',\n            'basename' => 'my%20page',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            'addNonce' => 'http://localhost/this%20is%20the%20path/my%20page/nonce:{{nonce}}',\n            'toOriginalString' => 'http://localhost/this%20is%20the%20path/my%20page'\n        ],\n        'http://localhost/pölöpölö/päläpälä' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4',\n            'paths' => ['p%C3%B6l%C3%B6p%C3%B6l%C3%B6', 'p%C3%A4l%C3%A4p%C3%A4l%C3%A4'],\n            'params' => null,\n            'url' => '/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4',\n            'environment' => 'localhost',\n            'basename' => 'p%C3%A4l%C3%A4p%C3%A4l%C3%A4',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            'addNonce' => 'http://localhost/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4/nonce:{{nonce}}',\n            'toOriginalString' => 'http://localhost/p%C3%B6l%C3%B6p%C3%B6l%C3%B6/p%C3%A4l%C3%A4p%C3%A4l%C3%A4'\n        ],\n        // Query params tests.\n        'http://localhost:8080/grav/it/ueper?test=x&test2=y' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/grav/it/ueper',\n            'query' => 'test=x&test2=y',\n            'fragment' => null,\n\n            'route' => '/grav/it/ueper',\n            'paths' => ['grav', 'it', 'ueper'],\n            'params' => null,\n            'url' => '/grav/it/ueper',\n            'environment' => 'localhost',\n            'basename' => 'ueper',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}?test=x&test2=y',\n        ],\n        'http://localhost:8080/grav/it/ueper?test=x&test2=y&test3=x&test4=y' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/grav/it/ueper',\n            'query' => 'test=x&test2=y&test3=x&test4=y',\n            'fragment' => null,\n\n            'route' => '/grav/it/ueper',\n            'paths' => ['grav', 'it', 'ueper'],\n            'params' => null,\n            'url' => '/grav/it/ueper',\n            'environment' => 'localhost',\n            'basename' => 'ueper',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}?test=x&test2=y&test3=x&test4=y',\n        ],\n        'http://localhost:8080/grav/it/ueper?test=x&test2=y&test3=x&test4=y/test' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/grav/it/ueper',\n            'query' => 'test=x&test2=y&test3=x&test4=y%2Ftest',\n            'fragment' => null,\n\n            'route' => '/grav/it/ueper',\n            'paths' => ['grav', 'it', 'ueper'],\n            'params' => null,\n            'url' => '/grav/it/ueper',\n            'environment' => 'localhost',\n            'basename' => 'ueper',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/grav/it/ueper/nonce:{{nonce}}?test=x&test2=y&test3=x&test4=y/test',\n        ],\n        // Port tests.\n        'http://localhost/a-page' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/a-page',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/a-page',\n            'paths' => ['a-page'],\n            'params' => null,\n            'url' => '/a-page',\n            'environment' => 'localhost',\n            'basename' => 'a-page',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            'addNonce' => 'http://localhost/a-page/nonce:{{nonce}}',\n        ],\n        'http://localhost:8080/a-page' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/a-page',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/a-page',\n            'paths' => ['a-page'],\n            'params' => null,\n            'url' => '/a-page',\n            'environment' => 'localhost',\n            'basename' => 'a-page',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/a-page/nonce:{{nonce}}',\n        ],\n        'http://localhost:443/a-page' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 443,\n            'path' => '/a-page',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/a-page',\n            'paths' => ['a-page'],\n            'params' => null,\n            'url' => '/a-page',\n            'environment' => 'localhost',\n            'basename' => 'a-page',\n            'base' => 'http://localhost:443',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:443',\n            'extension' => null,\n            'addNonce' => 'http://localhost:443/a-page/nonce:{{nonce}}',\n        ],\n        // Extension tests.\n        'http://localhost/a-page.html' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/a-page',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/a-page',\n            'paths' => ['a-page'],\n            'params' => null,\n            'url' => '/a-page',\n            'environment' => 'localhost',\n            'basename' => 'a-page.html',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => 'html',\n            'addNonce' => 'http://localhost/a-page.html/nonce:{{nonce}}',\n            'toOriginalString' => 'http://localhost/a-page.html',\n        ],\n        'http://localhost/a-page.json' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/a-page',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/a-page',\n            'paths' => ['a-page'],\n            'params' => null,\n            'url' => '/a-page',\n            'environment' => 'localhost',\n            'basename' => 'a-page.json',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => 'json',\n            'addNonce' => 'http://localhost/a-page.json/nonce:{{nonce}}',\n            'toOriginalString' => 'http://localhost/a-page.json',\n        ],\n        'http://localhost/admin/ajax.json/task:getnewsfeed' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/admin/ajax',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/admin/ajax',\n            'paths' => ['admin', 'ajax'],\n            'params' => '/task:getnewsfeed',\n            'url' => '/admin/ajax',\n            'environment' => 'localhost',\n            'basename' => 'ajax.json',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => 'json',\n            'addNonce' => 'http://localhost/admin/ajax.json/task:getnewsfeed/nonce:{{nonce}}',\n            'toOriginalString' => 'http://localhost/admin/ajax.json/task:getnewsfeed',\n        ],\n        'http://localhost/grav/admin/media.json/route:L1VzZXJzL3JodWsvd29ya3NwYWNlL2dyYXYtZGVtby1zYW1wbGVyL3VzZXIvYXNzZXRzL3FRMXB4Vk1ERTNJZzh5Ni5qcGc=/task:removeFileFromBlueprint/proute:/blueprint:Y29uZmlnL2RldGFpbHM=/type:config/field:deep.nested.custom_file/path:dXNlci9hc3NldHMvcVExcHhWTURFM0lnOHk2LmpwZw==' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/grav/admin/media',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/grav/admin/media',\n            'paths' => ['grav','admin','media'],\n            'params' => '/route:L1VzZXJzL3JodWsvd29ya3NwYWNlL2dyYXYtZGVtby1zYW1wbGVyL3VzZXIvYXNzZXRzL3FRMXB4Vk1ERTNJZzh5Ni5qcGc=/task:removeFileFromBlueprint/proute:/blueprint:Y29uZmlnL2RldGFpbHM=/type:config/field:deep.nested.custom_file/path:dXNlci9hc3NldHMvcVExcHhWTURFM0lnOHk2LmpwZw==',\n            'url' => '/grav/admin/media',\n            'environment' => 'localhost',\n            'basename' => 'media.json',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => 'json',\n            'addNonce' => 'http://localhost/grav/admin/media.json/route:L1VzZXJzL3JodWsvd29ya3NwYWNlL2dyYXYtZGVtby1zYW1wbGVyL3VzZXIvYXNzZXRzL3FRMXB4Vk1ERTNJZzh5Ni5qcGc=/task:removeFileFromBlueprint/proute:/blueprint:Y29uZmlnL2RldGFpbHM=/type:config/field:deep.nested.custom_file/path:dXNlci9hc3NldHMvcVExcHhWTURFM0lnOHk2LmpwZw==/nonce:{{nonce}}',\n            'toOriginalString' => 'http://localhost/grav/admin/media.json/route:L1VzZXJzL3JodWsvd29ya3NwYWNlL2dyYXYtZGVtby1zYW1wbGVyL3VzZXIvYXNzZXRzL3FRMXB4Vk1ERTNJZzh5Ni5qcGc=/task:removeFileFromBlueprint/proute:/blueprint:Y29uZmlnL2RldGFpbHM=/type:config/field:deep.nested.custom_file/path:dXNlci9hc3NldHMvcVExcHhWTURFM0lnOHk2LmpwZw==',\n        ],\n        'http://localhost/a-page.foo' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/a-page.foo',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/a-page.foo',\n            'paths' => ['a-page.foo'],\n            'params' => null,\n            'url' => '/a-page.foo',\n            'environment' => 'localhost',\n            'basename' => 'a-page.foo',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => 'foo',\n            'addNonce' => 'http://localhost/a-page.foo/nonce:{{nonce}}',\n            'toOriginalString' => 'http://localhost/a-page.foo'\n        ],\n        // Fragment tests.\n        'http://localhost:8080/a/b/c#my-fragment' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 8080,\n            'path' => '/a/b/c',\n            'query' => '',\n            'fragment' => 'my-fragment',\n\n            'route' => '/a/b/c',\n            'paths' => ['a', 'b', 'c'],\n            'params' => null,\n            'url' => '/a/b/c',\n            'environment' => 'localhost',\n            'basename' => 'c',\n            'base' => 'http://localhost:8080',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost:8080',\n            'extension' => null,\n            'addNonce' => 'http://localhost:8080/a/b/c/nonce:{{nonce}}#my-fragment',\n        ],\n        // Attacks.\n        '\"><script>alert</script>://localhost' => [\n            'scheme' => '',\n            'user' => null,\n            'password' => null,\n            'host' => null,\n            'port' => null,\n            'path' => '/localhost',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/localhost',\n            'paths' => ['localhost'],\n            'params' => '/script%3E:',\n            'url' => '/localhost',\n            'environment' => 'unknown',\n            'basename' => 'localhost',\n            'base' => '',\n            'currentPage' => 1,\n            'rootUrl' => '',\n            'extension' => null,\n            //'addNonce' => '%22%3E%3Cscript%3Ealert%3C/localhost/script%3E:/nonce:{{nonce}}', // FIXME <-\n            'toOriginalString' => '/localhost/script%3E:' // FIXME <-\n        ],\n        'http://\"><script>alert</script>' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'unknown',\n            'port' => 80,\n            'path' => '/script%3E',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/script%3E',\n            'paths' => ['script%3E'],\n            'params' => null,\n            'url' => '/script%3E',\n            'environment' => 'unknown',\n            'basename' => 'script%3E',\n            'base' => 'http://unknown',\n            'currentPage' => 1,\n            'rootUrl' => 'http://unknown',\n            'extension' => null,\n            'addNonce' => 'http://unknown/script%3E/nonce:{{nonce}}',\n            'toOriginalString' => 'http://unknown/script%3E'\n        ],\n        'http://localhost/\"><script>alert</script>' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/%22%3E%3Cscript%3Ealert%3C/script%3E',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/%22%3E%3Cscript%3Ealert%3C/script%3E',\n            'paths' => ['%22%3E%3Cscript%3Ealert%3C', 'script%3E'],\n            'params' => null,\n            'url' => '/%22%3E%3Cscript%3Ealert%3C/script%3E',\n            'environment' => 'localhost',\n            'basename' => 'script%3E',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            'addNonce' => 'http://localhost/%22%3E%3Cscript%3Ealert%3C/script%3E/nonce:{{nonce}}',\n            'toOriginalString' => 'http://localhost/%22%3E%3Cscript%3Ealert%3C/script%3E'\n        ],\n        'http://localhost/something/p1:foo/p2:\"><script>alert</script>' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/something/script%3E',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/something/script%3E',\n            'paths' => ['something', 'script%3E'],\n            'params' => '/p1:foo/p2:%22%3E%3Cscript%3Ealert%3C',\n            'url' => '/something/script%3E',\n            'environment' => 'localhost',\n            'basename' => 'script%3E',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            //'addNonce' => 'http://localhost/something/script%3E/p1:foo/p2:%22%3E%3Cscript%3Ealert%3C/nonce:{{nonce}}', // FIXME <-\n            'toOriginalString' => 'http://localhost/something/script%3E/p1:foo/p2:%22%3E%3Cscript%3Ealert%3C'\n        ],\n        'http://localhost/something?p=\"><script>alert</script>' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/something',\n            'query' => 'p=%22%3E%3Cscript%3Ealert%3C%2Fscript%3E',\n            'fragment' => null,\n\n            'route' => '/something',\n            'paths' => ['something'],\n            'params' => null,\n            'url' => '/something',\n            'environment' => 'localhost',\n            'basename' => 'something',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            'addNonce' => 'http://localhost/something/nonce:{{nonce}}?p=%22%3E%3Cscript%3Ealert%3C/script%3E',\n            'toOriginalString' => 'http://localhost/something?p=%22%3E%3Cscript%3Ealert%3C/script%3E'\n        ],\n        'http://localhost/something#\"><script>alert</script>' => [\n            'scheme' => 'http://',\n            'user' => null,\n            'password' => null,\n            'host' => 'localhost',\n            'port' => 80,\n            'path' => '/something',\n            'query' => '',\n            'fragment' => '%22%3E%3Cscript%3Ealert%3C/script%3E',\n\n            'route' => '/something',\n            'paths' => ['something'],\n            'params' => null,\n            'url' => '/something',\n            'environment' => 'localhost',\n            'basename' => 'something',\n            'base' => 'http://localhost',\n            'currentPage' => 1,\n            'rootUrl' => 'http://localhost',\n            'extension' => null,\n            'addNonce' => 'http://localhost/something/nonce:{{nonce}}#%22%3E%3Cscript%3Ealert%3C/script%3E',\n            'toOriginalString' => 'http://localhost/something#%22%3E%3Cscript%3Ealert%3C/script%3E'\n        ],\n        'https://www.getgrav.org/something/\"><script>eval(atob(\"aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==\"))</script><' => [\n            'scheme' => 'https://',\n            'user' => null,\n            'password' => null,\n            'host' => 'www.getgrav.org',\n            'port' => 443,\n            'path' => '/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C',\n            'query' => '',\n            'fragment' => null,\n\n            'route' => '/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C',\n            'paths' => ['something', '%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C', 'script%3E%3C'],\n            'params' => null,\n            'url' => '/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C',\n            'environment' => 'www.getgrav.org',\n            'basename' => 'script%3E%3C',\n            'base' => 'https://www.getgrav.org',\n            'currentPage' => 1,\n            'rootUrl' => 'https://www.getgrav.org',\n            'extension' => null,\n            'addNonce' => 'https://www.getgrav.org/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C/nonce:{{nonce}}',\n            'toOriginalString' => 'https://www.getgrav.org/something/%22%3E%3Cscript%3Eeval%28atob%28%22aGlzdG9yeS5wdXNoU3RhdGUoJycsJycsJy8nKTskKCdoZWFkLGJvZHknKS5odG1sKCcnKS5sb2FkKCcvJyk7JC5wb3N0KCcvYWRtaW4nLGZ1bmN0aW9uKGRhdGEpeyQucG9zdCgkKGRhdGEpLmZpbmQoJ1tpZD1hZG1pbi11c2VyLWRldGFpbHNdIGEnKS5hdHRyKCdocmVmJykseydhZG1pbi1ub25jZSc6JChkYXRhKS5maW5kKCdbZGF0YS1jbGVhci1jYWNoZV0nKS5hdHRyKCdkYXRhLWNsZWFyLWNhY2hlJykuc3BsaXQoJzonKS5wb3AoKS50cmltKCksJ2RhdGFbcGFzc3dvcmRdJzonSW0zdjFsaDR4eDByJywndGFzayc6J3NhdmUnfSl9KQ==%22%29%29%3C/script%3E%3C'\n        ],\n    ];\n\n    protected function _before(): void\n    {\n        $grav = Fixtures::get('grav');\n        $this->grav = $grav();\n        $this->uri = $this->grav['uri'];\n        $this->config = $this->grav['config'];\n    }\n\n    protected function _after(): void\n    {\n    }\n\n    protected function runTestSet(array $tests, $method, $params = []): void\n    {\n        foreach ($tests as $url => $candidates) {\n            if (!array_key_exists($method, $candidates) && $method !== 'toOriginalString') {\n                continue;\n            }\n            if ($method === 'addNonce') {\n                $nonce = Utils::getNonce('test-action');\n                $expected = str_replace('{{nonce}}', $nonce, $candidates[$method]);\n\n                self::assertSame($expected, Uri::addNonce($url, 'test-action'));\n\n                continue;\n            }\n\n            $this->uri->initializeWithURL($url)->init();\n            if ($method === 'toOriginalString' && !isset($candidates[$method])) {\n                $expected = $url;\n            } else {\n                $expected = $candidates[$method];\n            }\n\n            if ($params) {\n                $result = call_user_func_array([$this->uri, $method], $params);\n            } else {\n                $result = $this->uri->{$method}();\n            }\n\n            self::assertSame($expected, $result, \"Test \\$url->{$method}() for {$url}\");\n            // Deal with $url->query($key)\n            if ($method === 'query') {\n                parse_str($expected, $queryParams);\n                foreach ($queryParams as $key => $value) {\n                    self::assertSame($value, $this->uri->{$method}($key), \"Test \\$url->{$method}('{$key}') for {$url}\");\n                }\n                self::assertNull($this->uri->{$method}('non-existing'), \"Test \\$url->{$method}('non-existing') for {$url}\");\n            }\n        }\n    }\n\n    public function testValidatingHostname(): void\n    {\n        self::assertTrue($this->uri->validateHostname('localhost'));\n        self::assertTrue($this->uri->validateHostname('google.com'));\n        self::assertTrue($this->uri->validateHostname('google.it'));\n        self::assertTrue($this->uri->validateHostname('goog.le'));\n        self::assertTrue($this->uri->validateHostname('goog.wine'));\n        self::assertTrue($this->uri->validateHostname('goog.localhost'));\n\n        self::assertFalse($this->uri->validateHostname('localhost:80'));\n        self::assertFalse($this->uri->validateHostname('http://localhost'));\n        self::assertFalse($this->uri->validateHostname('localhost!'));\n    }\n\n    public function testToString(): void\n    {\n        $this->runTestSet($this->tests, 'toOriginalString');\n    }\n\n    public function testScheme(): void\n    {\n        $this->runTestSet($this->tests, 'scheme');\n    }\n\n    public function testUser(): void\n    {\n        $this->runTestSet($this->tests, 'user');\n    }\n\n    public function testPassword(): void\n    {\n        $this->runTestSet($this->tests, 'password');\n    }\n\n    public function testHost(): void\n    {\n        $this->runTestSet($this->tests, 'host');\n    }\n\n    public function testPort(): void\n    {\n        $this->runTestSet($this->tests, 'port');\n    }\n\n    public function testPath(): void\n    {\n        $this->runTestSet($this->tests, 'path');\n    }\n\n    public function testQuery(): void\n    {\n        $this->runTestSet($this->tests, 'query');\n    }\n\n    public function testFragment(): void\n    {\n        $this->runTestSet($this->tests, 'fragment');\n\n        $this->uri->fragment('something-new');\n        self::assertSame('something-new', $this->uri->fragment());\n    }\n\n    public function testPaths(): void\n    {\n        $this->runTestSet($this->tests, 'paths');\n    }\n\n    public function testRoute(): void\n    {\n        $this->runTestSet($this->tests, 'route');\n    }\n\n    public function testParams(): void\n    {\n        $this->runTestSet($this->tests, 'params');\n\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx')->init();\n        self::assertSame('/ueper:xxx', $this->uri->params('ueper'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx/test:yyy')->init();\n        self::assertSame('/ueper:xxx', $this->uri->params('ueper'));\n        self::assertSame('/test:yyy', $this->uri->params('test'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx++/test:yyy')->init();\n        self::assertSame('/ueper:xxx++/test:yyy', $this->uri->params());\n        self::assertSame('/ueper:xxx++', $this->uri->params('ueper'));\n        self::assertSame('/test:yyy', $this->uri->params('test'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx++/test:yyy#something')->init();\n        self::assertSame('/ueper:xxx++/test:yyy', $this->uri->params());\n        self::assertSame('/ueper:xxx++', $this->uri->params('ueper'));\n        self::assertSame('/test:yyy', $this->uri->params('test'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx++/test:yyy?foo=bar')->init();\n        self::assertSame('/ueper:xxx++/test:yyy', $this->uri->params());\n        self::assertSame('/ueper:xxx++', $this->uri->params('ueper'));\n        self::assertSame('/test:yyy', $this->uri->params('test'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper?test=x')->init();\n        self::assertNull($this->uri->params());\n        self::assertNull($this->uri->params('ueper'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper?test=x&test2=y')->init();\n        self::assertNull($this->uri->params());\n        self::assertNull($this->uri->params('ueper'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper?test=x&test2=y&test3=x&test4=y')->init();\n        self::assertNull($this->uri->params());\n        self::assertNull($this->uri->params('ueper'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper?test=x&test2=y&test3=x&test4=y/test')->init();\n        self::assertNull($this->uri->params());\n        self::assertNull($this->uri->params('ueper'));\n        $this->uri->initializeWithURL('http://localhost:8080/a/b/c/d')->init();\n        self::assertNull($this->uri->params());\n        self::assertNull($this->uri->params('ueper'));\n        $this->uri->initializeWithURL('http://localhost:8080/a/b/c/d/e/f/a/b/c/d/e/f/a/b/c/d/e/f')->init();\n        self::assertNull($this->uri->params());\n        self::assertNull($this->uri->params('ueper'));\n    }\n\n    public function testParam(): void\n    {\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx')->init();\n        self::assertSame('xxx', $this->uri->param('ueper'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx/test:yyy')->init();\n        self::assertSame('xxx', $this->uri->param('ueper'));\n        self::assertSame('yyy', $this->uri->param('test'));\n        $this->uri->initializeWithURL('http://localhost:8080/grav/it/ueper:xxx++/test:yy%20y/foo:bar_baz-bank')->init();\n        self::assertSame('xxx++', $this->uri->param('ueper'));\n        self::assertSame('yy y', $this->uri->param('test'));\n        self::assertSame('bar_baz-bank', $this->uri->param('foo'));\n    }\n\n    public function testUrl(): void\n    {\n        $this->runTestSet($this->tests, 'url');\n    }\n\n    public function testExtension(): void\n    {\n        $this->runTestSet($this->tests, 'extension');\n\n        $this->uri->initializeWithURL('http://localhost/a-page')->init();\n        self::assertSame('x', $this->uri->extension('x'));\n    }\n\n    public function testEnvironment(): void\n    {\n        $this->runTestSet($this->tests, 'environment');\n    }\n\n    public function testBasename(): void\n    {\n        $this->runTestSet($this->tests, 'basename');\n    }\n\n    public function testBase(): void\n    {\n        $this->runTestSet($this->tests, 'base');\n    }\n\n    public function testRootUrl(): void\n    {\n        $this->runTestSet($this->tests, 'rootUrl', [true]);\n\n        $this->uri->initializeWithUrlAndRootPath('https://localhost/grav/page-foo', '/grav')->init();\n        self::assertSame('/grav', $this->uri->rootUrl());\n        self::assertSame('https://localhost/grav', $this->uri->rootUrl(true));\n    }\n\n    public function testCurrentPage(): void\n    {\n        $this->runTestSet($this->tests, 'currentPage');\n\n        $this->uri->initializeWithURL('http://localhost:8080/a-page/page:2')->init();\n        self::assertSame(2, $this->uri->currentPage());\n    }\n\n    public function testReferrer(): void\n    {\n        $this->uri->initializeWithURL('http://localhost/foo/page:test')->init();\n        self::assertSame('/foo', $this->uri->referrer());\n        $this->uri->initializeWithURL('http://localhost/foo/bar/page:test')->init();\n        self::assertSame('/foo/bar', $this->uri->referrer());\n    }\n\n    public function testIp(): void\n    {\n        $this->uri->initializeWithURL('http://localhost/foo/page:test')->init();\n        self::assertSame('UNKNOWN', Uri::ip());\n    }\n\n    public function testIsExternal(): void\n    {\n        $this->uri->initializeWithURL('http://localhost/')->init();\n        self::assertFalse(Uri::isExternal('/test'));\n        self::assertFalse(Uri::isExternal('/foo/bar'));\n        self::assertTrue(Uri::isExternal('http://localhost/test'));\n        self::assertTrue(Uri::isExternal('http://google.it/test'));\n    }\n\n    public function testBuildUrl(): void\n    {\n        $parsed_url = [\n            'scheme' => 'http',\n            'host'   => 'localhost',\n            'port'   => 8080,\n        ];\n\n        self::assertSame('http://localhost:8080', Uri::buildUrl($parsed_url));\n\n        $parsed_url = [\n            'scheme'   => 'http',\n            'host'     => 'localhost',\n            'port'     => 8080,\n            'user'     => 'foo',\n            'pass'     => 'bar',\n            'path'     => '/test',\n            'query'    => 'x=2',\n            'fragment' => 'xxx',\n        ];\n\n        self::assertSame('http://foo:bar@localhost:8080/test?x=2#xxx', Uri::buildUrl($parsed_url));\n\n        /** @var Uri $uri */\n        $uri = Grav::instance()['uri'];\n\n        $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.html', '/subdir')->init();\n        self::assertSame('https://testing.dev/subdir/path1/path2/file.html', Uri::buildUrl($uri->toArray(true)));\n\n        $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.foo', '/subdir')->init();\n        self::assertSame('https://testing.dev/subdir/path1/path2/file.foo', Uri::buildUrl($uri->toArray(true)));\n\n        $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.html', '/subdir/path1')->init();\n        self::assertSame('https://testing.dev/subdir/path1/path2/file.html', Uri::buildUrl($uri->toArray(true)));\n\n        $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.html/foo:blah/bang:boom', '/subdir')->init();\n        self::assertSame('https://testing.dev/subdir/path1/path2/file.html/foo:blah/bang:boom', Uri::buildUrl($uri->toArray(true)));\n\n        $uri->initializeWithUrlAndRootPath('https://testing.dev/subdir/path1/path2/file.html/foo:blah/bang:boom?fig=something', '/subdir')->init();\n        self::assertSame('https://testing.dev/subdir/path1/path2/file.html/foo:blah/bang:boom?fig=something', Uri::buildUrl($uri->toArray(true)));\n    }\n\n    public function testConvertUrl(): void\n    {\n    }\n\n    public function testAddNonce(): void\n    {\n        $this->runTestSet($this->tests, 'addNonce');\n    }\n\n    public function testCustomBase(): void\n    {\n        $current_base = $this->config->get('system.custom_base_url');\n        $this->config->set('system.custom_base_url', '/test');\n        $this->uri->initializeWithURL('https://mydomain.example.com:8090/test/korteles/kodai%20something?test=true#some-fragment')->init();\n\n        $this->assertSame([\n          \"scheme\" => \"https\",\n          \"host\" => \"mydomain.example.com\",\n          \"port\" => 8090,\n          \"user\" => null,\n          \"pass\" => null,\n          \"path\" => \"/korteles/kodai%20something\",\n          \"params\" => [],\n          \"query\" => \"test=true\",\n          \"fragment\" => \"some-fragment\",\n        ], $this->uri->toArray());\n\n        $this->config->set('system.custom_base_url', $current_base);\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Common/UtilsTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Common\\Uri;\nuse Grav\\Common\\Utils;\n\n/**\n * Class UtilsTest\n */\nclass UtilsTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var Uri $uri */\n    protected $uri;\n\n    protected function _before(): void\n    {\n        $grav = Fixtures::get('grav');\n        $this->grav = $grav();\n        $this->uri = $this->grav['uri'];\n    }\n\n    protected function _after(): void\n    {\n    }\n\n    public function testStartsWith(): void\n    {\n        self::assertTrue(Utils::startsWith('english', 'en'));\n        self::assertTrue(Utils::startsWith('English', 'En'));\n        self::assertTrue(Utils::startsWith('ENGLISH', 'EN'));\n        self::assertTrue(Utils::startsWith(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'EN'\n        ));\n\n        self::assertFalse(Utils::startsWith('english', 'En'));\n        self::assertFalse(Utils::startsWith('English', 'EN'));\n        self::assertFalse(Utils::startsWith('ENGLISH', 'en'));\n        self::assertFalse(Utils::startsWith(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'e'\n        ));\n\n        self::assertTrue(Utils::startsWith('english', 'En', false));\n        self::assertTrue(Utils::startsWith('English', 'EN', false));\n        self::assertTrue(Utils::startsWith('ENGLISH', 'en', false));\n        self::assertTrue(Utils::startsWith(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'e',\n            false\n        ));\n    }\n\n    public function testEndsWith(): void\n    {\n        self::assertTrue(Utils::endsWith('english', 'sh'));\n        self::assertTrue(Utils::endsWith('EngliSh', 'Sh'));\n        self::assertTrue(Utils::endsWith('ENGLISH', 'SH'));\n        self::assertTrue(Utils::endsWith(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'ENGLISH'\n        ));\n\n        self::assertFalse(Utils::endsWith('english', 'de'));\n        self::assertFalse(Utils::endsWith('EngliSh', 'sh'));\n        self::assertFalse(Utils::endsWith('ENGLISH', 'Sh'));\n        self::assertFalse(Utils::endsWith(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'DEUSTCH'\n        ));\n\n        self::assertTrue(Utils::endsWith('english', 'SH', false));\n        self::assertTrue(Utils::endsWith('EngliSh', 'sH', false));\n        self::assertTrue(Utils::endsWith('ENGLISH', 'sh', false));\n        self::assertTrue(Utils::endsWith(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'english',\n            false\n        ));\n    }\n\n    public function testContains(): void\n    {\n        self::assertTrue(Utils::contains('english', 'nglis'));\n        self::assertTrue(Utils::contains('EngliSh', 'gliSh'));\n        self::assertTrue(Utils::contains('ENGLISH', 'ENGLI'));\n        self::assertTrue(Utils::contains(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'ENGLISH'\n        ));\n\n        self::assertFalse(Utils::contains('EngliSh', 'GLI'));\n        self::assertFalse(Utils::contains('EngliSh', 'English'));\n        self::assertFalse(Utils::contains('ENGLISH', 'SCH'));\n        self::assertFalse(Utils::contains(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'DEUSTCH'\n        ));\n\n        self::assertTrue(Utils::contains('EngliSh', 'GLI', false));\n        self::assertTrue(Utils::contains('EngliSh', 'ENGLISH', false));\n        self::assertTrue(Utils::contains('ENGLISH', 'ish', false));\n        self::assertTrue(Utils::contains(\n            'ENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISHENGLISH',\n            'english',\n            false\n        ));\n    }\n\n    public function testSubstrToString(): void\n    {\n        self::assertEquals('en', Utils::substrToString('english', 'glish'));\n        self::assertEquals('english', Utils::substrToString('english', 'test'));\n        self::assertNotEquals('en', Utils::substrToString('english', 'lish'));\n\n        self::assertEquals('en', Utils::substrToString('english', 'GLISH', false));\n        self::assertEquals('english', Utils::substrToString('english', 'TEST', false));\n        self::assertNotEquals('en', Utils::substrToString('english', 'LISH', false));\n    }\n\n    public function testMergeObjects(): void\n    {\n        $obj1 = new stdClass();\n        $obj1->test1 = 'x';\n        $obj2 = new stdClass();\n        $obj2->test2 = 'y';\n\n        $objMerged = Utils::mergeObjects($obj1, $obj2);\n\n        self::arrayHasKey('test1', (array) $objMerged);\n        self::arrayHasKey('test2', (array) $objMerged);\n    }\n\n    public function testDateFormats(): void\n    {\n        $dateFormats = Utils::dateFormats();\n        self::assertIsArray($dateFormats);\n        self::assertContainsOnly('string', $dateFormats);\n\n        $default_format = $this->grav['config']->get('system.pages.dateformat.default');\n\n        if ($default_format !== null) {\n            self::assertArrayHasKey($default_format, $dateFormats);\n        }\n    }\n\n    public function testTruncate(): void\n    {\n        self::assertEquals('engli' . '&hellip;', Utils::truncate('english', 5));\n        self::assertEquals('english', Utils::truncate('english'));\n        self::assertEquals('This is a string to truncate', Utils::truncate('This is a string to truncate'));\n        self::assertEquals('Th' . '&hellip;', Utils::truncate('This is a string to truncate', 2));\n        self::assertEquals('engli' . '...', Utils::truncate('english', 5, true, \" \", \"...\"));\n        self::assertEquals('english', Utils::truncate('english'));\n        self::assertEquals('This is a string to truncate', Utils::truncate('This is a string to truncate'));\n        self::assertEquals('This' . '&hellip;', Utils::truncate('This is a string to truncate', 3, true));\n        self::assertEquals('<input' . '&hellip;', Utils::truncate('<input type=\"file\" id=\"file\" multiple />', 6, true));\n    }\n\n    public function testSafeTruncate(): void\n    {\n        self::assertEquals('This' . '&hellip;', Utils::safeTruncate('This is a string to truncate', 1));\n        self::assertEquals('This' . '&hellip;', Utils::safeTruncate('This is a string to truncate', 4));\n        self::assertEquals('This is' . '&hellip;', Utils::safeTruncate('This is a string to truncate', 5));\n    }\n\n    public function testTruncateHtml(): void\n    {\n        self::assertEquals('T...', Utils::truncateHtml('This is a string to truncate', 1));\n        self::assertEquals('This is...', Utils::truncateHtml('This is a string to truncate', 7));\n        self::assertEquals('<p>T...</p>', Utils::truncateHtml('<p>This is a string to truncate</p>', 1));\n        self::assertEquals('<p>This...</p>', Utils::truncateHtml('<p>This is a string to truncate</p>', 4));\n        self::assertEquals('<p>This is a...</p>', Utils::truncateHtml('<p>This is a string to truncate</p>', 10));\n        self::assertEquals('<p>This is a string to truncate</p>', Utils::truncateHtml('<p>This is a string to truncate</p>', 100));\n        self::assertEquals('<input type=\"file\" id=\"file\" multiple />', Utils::truncateHtml('<input type=\"file\" id=\"file\" multiple />', 6));\n        self::assertEquals('<ol><li>item 1 <i>so...</i></li></ol>', Utils::truncateHtml('<ol><li>item 1 <i>something</i></li><li>item 2 <strong>bold</strong></li></ol>', 10));\n        self::assertEquals(\"<p>This is a string.</p>\\n<p>It splits two lines.</p>\", Utils::truncateHtml(\"<p>This is a string.</p>\\n<p>It splits two lines.</p>\", 100));\n    }\n\n    public function testSafeTruncateHtml(): void\n    {\n        self::assertEquals('This...', Utils::safeTruncateHtml('This is a string to truncate', 1));\n        self::assertEquals('This is a...', Utils::safeTruncateHtml('This is a string to truncate', 3));\n        self::assertEquals('<p>This...</p>', Utils::safeTruncateHtml('<p>This is a string to truncate</p>', 1));\n        self::assertEquals('<p>This is...</p>', Utils::safeTruncateHtml('<p>This is a string to truncate</p>', 2));\n        self::assertEquals('<p>This is a string to...</p>', Utils::safeTruncateHtml('<p>This is a string to truncate</p>', 5));\n        self::assertEquals('<p>This is a string to truncate</p>', Utils::safeTruncateHtml('<p>This is a string to truncate</p>', 20));\n        self::assertEquals('<input type=\"file\" id=\"file\" multiple />', Utils::safeTruncateHtml('<input type=\"file\" id=\"file\" multiple />', 6));\n        self::assertEquals('<ol><li>item 1 <i>something</i></li><li>item 2...</li></ol>', Utils::safeTruncateHtml('<ol><li>item 1 <i>something</i></li><li>item 2 <strong>bold</strong></li></ol>', 5));\n    }\n\n    public function testGenerateRandomString(): void\n    {\n        self::assertNotEquals(Utils::generateRandomString(), Utils::generateRandomString());\n        self::assertNotEquals(Utils::generateRandomString(20), Utils::generateRandomString(20));\n    }\n\n    public function download(): void\n    {\n    }\n\n    public function testGetMimeByExtension(): void\n    {\n        self::assertEquals('application/octet-stream', Utils::getMimeByExtension(''));\n        self::assertEquals('text/html', Utils::getMimeByExtension('html'));\n        self::assertEquals('application/json', Utils::getMimeByExtension('json'));\n        self::assertEquals('application/atom+xml', Utils::getMimeByExtension('atom'));\n        self::assertEquals('application/rss+xml', Utils::getMimeByExtension('rss'));\n        self::assertEquals('image/jpeg', Utils::getMimeByExtension('jpg'));\n        self::assertEquals('image/png', Utils::getMimeByExtension('png'));\n        self::assertEquals('text/plain', Utils::getMimeByExtension('txt'));\n        self::assertEquals('application/msword', Utils::getMimeByExtension('doc'));\n        self::assertEquals('application/octet-stream', Utils::getMimeByExtension('foo'));\n        self::assertEquals('foo/bar', Utils::getMimeByExtension('foo', 'foo/bar'));\n        self::assertEquals('text/html', Utils::getMimeByExtension('foo', 'text/html'));\n    }\n\n    public function testGetExtensionByMime(): void\n    {\n        self::assertEquals('html', Utils::getExtensionByMime('*/*'));\n        self::assertEquals('html', Utils::getExtensionByMime('text/*'));\n        self::assertEquals('html', Utils::getExtensionByMime('text/html'));\n        self::assertEquals('json', Utils::getExtensionByMime('application/json'));\n        self::assertEquals('atom', Utils::getExtensionByMime('application/atom+xml'));\n        self::assertEquals('rss', Utils::getExtensionByMime('application/rss+xml'));\n        self::assertEquals('jpg', Utils::getExtensionByMime('image/jpeg'));\n        self::assertEquals('png', Utils::getExtensionByMime('image/png'));\n        self::assertEquals('txt', Utils::getExtensionByMime('text/plain'));\n        self::assertEquals('doc', Utils::getExtensionByMime('application/msword'));\n        self::assertEquals('html', Utils::getExtensionByMime('foo/bar'));\n        self::assertEquals('baz', Utils::getExtensionByMime('foo/bar', 'baz'));\n    }\n\n    public function testNormalizePath(): void\n    {\n        self::assertEquals('/test', Utils::normalizePath('/test'));\n        self::assertEquals('test', Utils::normalizePath('test'));\n        self::assertEquals('test', Utils::normalizePath('../test'));\n        self::assertEquals('/test', Utils::normalizePath('/../test'));\n        self::assertEquals('/test2', Utils::normalizePath('/test/../test2'));\n        self::assertEquals('/test3', Utils::normalizePath('/test/../test2/../test3'));\n\n        self::assertEquals('//cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css', Utils::normalizePath('//cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css'));\n        self::assertEquals('//use.fontawesome.com/releases/v5.8.1/css/all.css', Utils::normalizePath('//use.fontawesome.com/releases/v5.8.1/css/all.css'));\n        self::assertEquals('//use.fontawesome.com/releases/v5.8.1/webfonts/fa-brands-400.eot', Utils::normalizePath('//use.fontawesome.com/releases/v5.8.1/css/../webfonts/fa-brands-400.eot'));\n\n        self::assertEquals('http://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css', Utils::normalizePath('http://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css'));\n        self::assertEquals('http://use.fontawesome.com/releases/v5.8.1/css/all.css', Utils::normalizePath('http://use.fontawesome.com/releases/v5.8.1/css/all.css'));\n        self::assertEquals('http://use.fontawesome.com/releases/v5.8.1/webfonts/fa-brands-400.eot', Utils::normalizePath('http://use.fontawesome.com/releases/v5.8.1/css/../webfonts/fa-brands-400.eot'));\n\n        self::assertEquals('https://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css', Utils::normalizePath('https://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css'));\n        self::assertEquals('https://use.fontawesome.com/releases/v5.8.1/css/all.css', Utils::normalizePath('https://use.fontawesome.com/releases/v5.8.1/css/all.css'));\n        self::assertEquals('https://use.fontawesome.com/releases/v5.8.1/webfonts/fa-brands-400.eot', Utils::normalizePath('https://use.fontawesome.com/releases/v5.8.1/css/../webfonts/fa-brands-400.eot'));\n    }\n\n    public function testIsFunctionDisabled(): void\n    {\n        $disabledFunctions = explode(',', ini_get('disable_functions'));\n\n        if ($disabledFunctions[0]) {\n            self::assertEquals(Utils::isFunctionDisabled($disabledFunctions[0]), true);\n        }\n    }\n\n    public function testTimezones(): void\n    {\n        $timezones = Utils::timezones();\n\n        self::assertIsArray($timezones);\n        self::assertContainsOnly('string', $timezones);\n    }\n\n    public function testArrayFilterRecursive(): void\n    {\n        $array = [\n            'test'  => '',\n            'test2' => 'test2'\n        ];\n\n        $array = Utils::arrayFilterRecursive($array, function ($k, $v) {\n            return !(is_null($v) || $v === '');\n        });\n\n        self::assertContainsOnly('string', $array);\n        self::assertArrayNotHasKey('test', $array);\n        self::assertArrayHasKey('test2', $array);\n        self::assertEquals('test2', $array['test2']);\n    }\n\n    public function testPathPrefixedByLangCode(): void\n    {\n        $languagesEnabled = $this->grav['config']->get('system.languages.supported', []);\n        $arrayOfLanguages = ['en', 'de', 'it', 'es', 'dk', 'el'];\n        $languagesNotEnabled = array_diff($arrayOfLanguages, $languagesEnabled);\n        $oneLanguageNotEnabled = reset($languagesNotEnabled);\n\n        if (count($languagesEnabled)) {\n            $languageCodePathPrefix = Utils::pathPrefixedByLangCode('/' . $languagesEnabled[0] . '/test');\n            $this->assertIsString($languageCodePathPrefix);\n            $this->assertTrue(in_array($languageCodePathPrefix, $languagesEnabled));\n        }\n\n        self::assertFalse(Utils::pathPrefixedByLangCode('/' . $oneLanguageNotEnabled . '/test'));\n        self::assertFalse(Utils::pathPrefixedByLangCode('/test'));\n        self::assertFalse(Utils::pathPrefixedByLangCode('/xx'));\n        self::assertFalse(Utils::pathPrefixedByLangCode('/xx/'));\n        self::assertFalse(Utils::pathPrefixedByLangCode('/'));\n    }\n\n    public function testDate2timestamp(): void\n    {\n        $timestamp = strtotime('10 September 2000');\n        self::assertSame($timestamp, Utils::date2timestamp('10 September 2000'));\n        self::assertSame($timestamp, Utils::date2timestamp('2000-09-10 00:00:00'));\n    }\n\n    public function testResolve(): void\n    {\n        $array = [\n            'test' => [\n                'test2' => 'test2Value'\n            ]\n        ];\n\n        self::assertEquals('test2Value', Utils::resolve($array, 'test.test2'));\n    }\n\n    public function testGetDotNotation(): void\n    {\n        $array = [\n            'test' => [\n                'test2' => 'test2Value',\n                'test3' => [\n                    'test4' => 'test4Value'\n                ]\n            ]\n        ];\n\n        self::assertEquals('test2Value', Utils::getDotNotation($array, 'test.test2'));\n        self::assertEquals('test4Value', Utils::getDotNotation($array, 'test.test3.test4'));\n        self::assertEquals('defaultValue', Utils::getDotNotation($array, 'test.non_existent', 'defaultValue'));\n    }\n\n    public function testSetDotNotation(): void\n    {\n        $array = [\n            'test' => [\n                'test2' => 'test2Value',\n                'test3' => [\n                    'test4' => 'test4Value'\n                ]\n            ]\n        ];\n\n        $new = [\n            'test1' => 'test1Value'\n        ];\n\n        Utils::setDotNotation($array, 'test.test3.test4', $new);\n        self::assertEquals('test1Value', $array['test']['test3']['test4']['test1']);\n    }\n\n    public function testIsPositive(): void\n    {\n        self::assertTrue(Utils::isPositive(true));\n        self::assertTrue(Utils::isPositive(1));\n        self::assertTrue(Utils::isPositive('1'));\n        self::assertTrue(Utils::isPositive('yes'));\n        self::assertTrue(Utils::isPositive('on'));\n        self::assertTrue(Utils::isPositive('true'));\n        self::assertFalse(Utils::isPositive(false));\n        self::assertFalse(Utils::isPositive(0));\n        self::assertFalse(Utils::isPositive('0'));\n        self::assertFalse(Utils::isPositive('no'));\n        self::assertFalse(Utils::isPositive('off'));\n        self::assertFalse(Utils::isPositive('false'));\n        self::assertFalse(Utils::isPositive('some'));\n        self::assertFalse(Utils::isPositive(2));\n    }\n\n    public function testGetNonce(): void\n    {\n        self::assertIsString(Utils::getNonce('test-action'));\n        self::assertIsString(Utils::getNonce('test-action', true));\n        self::assertSame(Utils::getNonce('test-action'), Utils::getNonce('test-action'));\n        self::assertNotSame(Utils::getNonce('test-action'), Utils::getNonce('test-action2'));\n    }\n\n    public function testVerifyNonce(): void\n    {\n        self::assertTrue(Utils::verifyNonce(Utils::getNonce('test-action'), 'test-action'));\n    }\n\n    public function testGetPagePathFromToken(): void\n    {\n        self::assertEquals('', Utils::getPagePathFromToken(''));\n        self::assertEquals('/test/path', Utils::getPagePathFromToken('/test/path'));\n    }\n\n    public function testUrl(): void\n    {\n        $this->uri->initializeWithUrl('http://testing.dev/path1/path2')->init();\n\n        // Fail hard\n        self::assertSame(false, Utils::url('', true));\n        self::assertSame(false, Utils::url(''));\n        self::assertSame(false, Utils::url(new stdClass()));\n        self::assertSame(false, Utils::url(['foo','bar','baz']));\n        self::assertSame(false, Utils::url('user://does/not/exist'));\n\n        // Fail Gracefully\n        self::assertSame('/', Utils::url('/', false, true));\n        self::assertSame('/', Utils::url('', false, true));\n        self::assertSame('/', Utils::url(new stdClass(), false, true));\n        self::assertSame('/', Utils::url(['foo','bar','baz'], false, true));\n        self::assertSame('/user/does/not/exist', Utils::url('user://does/not/exist', false, true));\n\n        // Simple paths\n        self::assertSame('/', Utils::url('/'));\n        self::assertSame('/path1', Utils::url('/path1'));\n        self::assertSame('/path1/path2', Utils::url('/path1/path2'));\n        self::assertSame('/random/path1/path2', Utils::url('/random/path1/path2'));\n        self::assertSame('/foobar.jpg', Utils::url('/foobar.jpg'));\n        self::assertSame('/path1/foobar.jpg', Utils::url('/path1/foobar.jpg'));\n        self::assertSame('/path1/path2/foobar.jpg', Utils::url('/path1/path2/foobar.jpg'));\n        self::assertSame('/random/path1/path2/foobar.jpg', Utils::url('/random/path1/path2/foobar.jpg'));\n\n        // Simple paths with domain\n        self::assertSame('http://testing.dev/', Utils::url('/', true));\n        self::assertSame('http://testing.dev/path1', Utils::url('/path1', true));\n        self::assertSame('http://testing.dev/path1/path2', Utils::url('/path1/path2', true));\n        self::assertSame('http://testing.dev/random/path1/path2', Utils::url('/random/path1/path2', true));\n        self::assertSame('http://testing.dev/foobar.jpg', Utils::url('/foobar.jpg', true));\n        self::assertSame('http://testing.dev/path1/foobar.jpg', Utils::url('/path1/foobar.jpg', true));\n        self::assertSame('http://testing.dev/path1/path2/foobar.jpg', Utils::url('/path1/path2/foobar.jpg', true));\n        self::assertSame('http://testing.dev/random/path1/path2/foobar.jpg', Utils::url('/random/path1/path2/foobar.jpg', true));\n\n        // Relative paths from Grav root.\n        self::assertSame('/subdir', Utils::url('subdir'));\n        self::assertSame('/subdir/path1', Utils::url('subdir/path1'));\n        self::assertSame('/subdir/path1/path2', Utils::url('subdir/path1/path2'));\n        self::assertSame('/path1', Utils::url('path1'));\n        self::assertSame('/path1/path2', Utils::url('path1/path2'));\n        self::assertSame('/foobar.jpg', Utils::url('foobar.jpg'));\n        self::assertSame('http://testing.dev/foobar.jpg', Utils::url('foobar.jpg', true));\n\n        // Relative paths from Grav root with domain.\n        self::assertSame('http://testing.dev/foobar.jpg', Utils::url('foobar.jpg', true));\n        self::assertSame('http://testing.dev/foobar.jpg', Utils::url('/foobar.jpg', true));\n        self::assertSame('http://testing.dev/path1/foobar.jpg', Utils::url('/path1/foobar.jpg', true));\n\n        // All Non-existing streams should be treated as external URI / protocol.\n        self::assertSame('http://domain.com/path', Utils::url('http://domain.com/path'));\n        self::assertSame('ftp://domain.com/path', Utils::url('ftp://domain.com/path'));\n        self::assertSame('sftp://domain.com/path', Utils::url('sftp://domain.com/path'));\n        self::assertSame('ssh://domain.com', Utils::url('ssh://domain.com'));\n        self::assertSame('pop://domain.com', Utils::url('pop://domain.com'));\n        self::assertSame('foo://bar/baz', Utils::url('foo://bar/baz'));\n        self::assertSame('foo://bar/baz', Utils::url('foo://bar/baz', true));\n        self::assertSame('mailto:joe@domain.com', Utils::url('mailto:joe@domain.com', true)); // FIXME <-\n    }\n\n    public function testUrlWithRoot(): void\n    {\n        $this->uri->initializeWithUrlAndRootPath('http://testing.dev/subdir/path1/path2', '/subdir')->init();\n\n        // Fail hard\n        self::assertSame(false, Utils::url('', true));\n        self::assertSame(false, Utils::url(''));\n        self::assertSame(false, Utils::url(new stdClass()));\n        self::assertSame(false, Utils::url(['foo','bar','baz']));\n        self::assertSame(false, Utils::url('user://does/not/exist'));\n\n        // Fail Gracefully\n        self::assertSame('/subdir/', Utils::url('/', false, true));\n        self::assertSame('/subdir/', Utils::url('', false, true));\n        self::assertSame('/subdir/', Utils::url(new stdClass(), false, true));\n        self::assertSame('/subdir/', Utils::url(['foo','bar','baz'], false, true));\n        self::assertSame('/subdir/user/does/not/exist', Utils::url('user://does/not/exist', false, true));\n\n        // Simple paths\n        self::assertSame('/subdir/', Utils::url('/'));\n        self::assertSame('/subdir/path1', Utils::url('/path1'));\n        self::assertSame('/subdir/path1/path2', Utils::url('/path1/path2'));\n        self::assertSame('/subdir/random/path1/path2', Utils::url('/random/path1/path2'));\n        self::assertSame('/subdir/foobar.jpg', Utils::url('/foobar.jpg'));\n        self::assertSame('/subdir/path1/foobar.jpg', Utils::url('/path1/foobar.jpg'));\n        self::assertSame('/subdir/path1/path2/foobar.jpg', Utils::url('/path1/path2/foobar.jpg'));\n        self::assertSame('/subdir/random/path1/path2/foobar.jpg', Utils::url('/random/path1/path2/foobar.jpg'));\n\n        // Simple paths with domain\n        self::assertSame('http://testing.dev/subdir/', Utils::url('/', true));\n        self::assertSame('http://testing.dev/subdir/path1', Utils::url('/path1', true));\n        self::assertSame('http://testing.dev/subdir/path1/path2', Utils::url('/path1/path2', true));\n        self::assertSame('http://testing.dev/subdir/random/path1/path2', Utils::url('/random/path1/path2', true));\n        self::assertSame('http://testing.dev/subdir/foobar.jpg', Utils::url('/foobar.jpg', true));\n        self::assertSame('http://testing.dev/subdir/path1/foobar.jpg', Utils::url('/path1/foobar.jpg', true));\n        self::assertSame('http://testing.dev/subdir/path1/path2/foobar.jpg', Utils::url('/path1/path2/foobar.jpg', true));\n        self::assertSame('http://testing.dev/subdir/random/path1/path2/foobar.jpg', Utils::url('/random/path1/path2/foobar.jpg', true));\n\n        // Absolute Paths including the grav base.\n        self::assertSame('/subdir/', Utils::url('/subdir'));\n        self::assertSame('/subdir/', Utils::url('/subdir/'));\n        self::assertSame('/subdir/path1', Utils::url('/subdir/path1'));\n        self::assertSame('/subdir/path1/path2', Utils::url('/subdir/path1/path2'));\n        self::assertSame('/subdir/foobar.jpg', Utils::url('/subdir/foobar.jpg'));\n        self::assertSame('/subdir/path1/foobar.jpg', Utils::url('/subdir/path1/foobar.jpg'));\n\n        // Absolute paths from Grav root with domain.\n        self::assertSame('http://testing.dev/subdir/', Utils::url('/subdir', true));\n        self::assertSame('http://testing.dev/subdir/', Utils::url('/subdir/', true));\n        self::assertSame('http://testing.dev/subdir/path1', Utils::url('/subdir/path1', true));\n        self::assertSame('http://testing.dev/subdir/path1/path2', Utils::url('/subdir/path1/path2', true));\n        self::assertSame('http://testing.dev/subdir/foobar.jpg', Utils::url('/subdir/foobar.jpg', true));\n        self::assertSame('http://testing.dev/subdir/path1/foobar.jpg', Utils::url('/subdir/path1/foobar.jpg', true));\n\n        // Relative paths from Grav root.\n        self::assertSame('/subdir/sub', Utils::url('/sub'));\n        self::assertSame('/subdir/subdir', Utils::url('subdir'));\n        self::assertSame('/subdir/subdir2/sub', Utils::url('/subdir2/sub'));\n        self::assertSame('/subdir/subdir/path1', Utils::url('subdir/path1'));\n        self::assertSame('/subdir/subdir/path1/path2', Utils::url('subdir/path1/path2'));\n        self::assertSame('/subdir/path1', Utils::url('path1'));\n        self::assertSame('/subdir/path1/path2', Utils::url('path1/path2'));\n        self::assertSame('/subdir/foobar.jpg', Utils::url('foobar.jpg'));\n        self::assertSame('http://testing.dev/subdir/foobar.jpg', Utils::url('foobar.jpg', true));\n\n        // All Non-existing streams should be treated as external URI / protocol.\n        self::assertSame('http://domain.com/path', Utils::url('http://domain.com/path'));\n        self::assertSame('ftp://domain.com/path', Utils::url('ftp://domain.com/path'));\n        self::assertSame('sftp://domain.com/path', Utils::url('sftp://domain.com/path'));\n        self::assertSame('ssh://domain.com', Utils::url('ssh://domain.com'));\n        self::assertSame('pop://domain.com', Utils::url('pop://domain.com'));\n        self::assertSame('foo://bar/baz', Utils::url('foo://bar/baz'));\n        self::assertSame('foo://bar/baz', Utils::url('foo://bar/baz', true));\n        // self::assertSame('mailto:joe@domain.com', Utils::url('mailto:joe@domain.com', true)); // FIXME <-\n    }\n\n    public function testUrlWithStreams(): void\n    {\n    }\n\n    public function testUrlwithExternals(): void\n    {\n        $this->uri->initializeWithUrl('http://testing.dev/path1/path2')->init();\n        self::assertSame('http://foo.com', Utils::url('http://foo.com'));\n        self::assertSame('https://foo.com', Utils::url('https://foo.com'));\n        self::assertSame('//foo.com', Utils::url('//foo.com'));\n        self::assertSame('//foo.com?param=x', Utils::url('//foo.com?param=x'));\n    }\n\n    public function testCheckFilename(): void\n    {\n        // configure extension for consistent results\n        /** @var \\Grav\\Common\\Config\\Config $config */\n        $config = $this->grav['config'];\n        $config->set('security.uploads_dangerous_extensions', ['php', 'html', 'htm', 'exe', 'js']);\n\n        self::assertFalse(Utils::checkFilename('foo.php'));\n        self::assertFalse(Utils::checkFilename('foo.PHP'));\n        self::assertFalse(Utils::checkFilename('bar.js'));\n\n        self::assertTrue(Utils::checkFilename('foo.json'));\n        self::assertTrue(Utils::checkFilename('foo.xml'));\n        self::assertTrue(Utils::checkFilename('foo.yaml'));\n        self::assertTrue(Utils::checkFilename('foo.yml'));\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Console/Gpm/InstallCommandTest.php",
    "content": "<?php\n\nuse Codeception\\Util\\Fixtures;\nuse Grav\\Common\\Grav;\nuse Grav\\Console\\Gpm\\InstallCommand;\n\n/**\n * Class InstallCommandTest\n */\nclass InstallCommandTest extends \\Codeception\\TestCase\\Test\n{\n    /** @var Grav $grav */\n    protected $grav;\n\n    /** @var InstallCommand */\n    protected $installCommand;\n\n\n    protected function _before(): void\n    {\n        $this->grav = Fixtures::get('grav');\n        $this->installCommand = new InstallCommand();\n    }\n\n    protected function _after(): void\n    {\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Framework/File/Formatter/CsvFormatterTest.php",
    "content": "<?php\n\nuse Grav\\Framework\\File\\Formatter\\CsvFormatter;\n\n/**\n * Class CsvFormatterTest\n */\nclass CsvFormatterTest extends \\Codeception\\TestCase\\Test\n{\n    public function testEncodeWithAssocColumns(): void\n    {\n        $data = [\n            ['col1' => 1, 'col2' => 2, 'col3' => 3],\n            ['col1' => 'aaa', 'col2' => 'bbb', 'col3' => 'ccc'],\n        ];\n\n        $encoded = (new CsvFormatter())->encode($data);\n\n        $lines = array_filter(explode(PHP_EOL, $encoded));\n\n        self::assertCount(3, $lines);\n        self::assertEquals('col1,col2,col3', $lines[0]);\n    }\n\n    /**\n     * TBD - If indexes are all numeric, what's the purpose\n     * of displaying header\n     */\n    public function testEncodeWithIndexColumns(): void\n    {\n        $data = [\n            [0 => 1, 1 => 2, 2 => 3],\n        ];\n\n        $encoded = (new CsvFormatter())->encode($data);\n\n        $lines = array_filter(explode(PHP_EOL, $encoded));\n\n        self::assertCount(2, $lines);\n        self::assertEquals('0,1,2', $lines[0]);\n    }\n\n    public function testEncodeEmptyData(): void\n    {\n        $encoded = (new CsvFormatter())->encode([]);\n        self::assertEquals('', $encoded);\n    }\n}\n"
  },
  {
    "path": "tests/unit/Grav/Framework/Filesystem/FilesystemTest.php",
    "content": "<?php\n\nuse Grav\\Framework\\Filesystem\\Filesystem;\n\n/**\n * Class FilesystemTest\n */\nclass FilesystemTest extends \\Codeception\\TestCase\\Test\n{\n    protected $class;\n\n    protected $tests = [\n        '' => [\n            'parent' => '',\n            'normalize' => '',\n            'dirname' => '',\n            'pathinfo' => [\n                'basename' => '',\n                'filename' => '',\n            ]\n        ],\n        '.' => [\n            'parent' => '',\n            'normalize' => '',\n            'dirname' => '.',\n            'pathinfo' => [\n                'dirname' => '.',\n                'basename' => '.',\n                'extension' => '',\n                'filename' => '',\n            ]\n        ],\n        './' => [\n            'parent' => '',\n            'normalize' => '',\n            'dirname' => '.',\n            'pathinfo' => [\n                'dirname' => '.',\n                'basename' => '.',\n                'extension' => '',\n                'filename' => '',\n            ]\n        ],\n        '././.' => [\n            'parent' => '',\n            'normalize' => '',\n            'dirname' => './.',\n            'pathinfo' => [\n                'dirname' => './.',\n                'basename' => '.',\n                'extension' => '',\n                'filename' => '',\n            ]\n        ],\n        '.file' => [\n            'parent' => '.',\n            'normalize' => '.file',\n            'dirname' => '.',\n            'pathinfo' => [\n                'dirname' => '.',\n                'basename' => '.file',\n                'extension' => 'file',\n                'filename' => '',\n            ]\n        ],\n        '/' => [\n            'parent' => '',\n            'normalize' => '/',\n            'dirname' => '/',\n            'pathinfo' => [\n                'dirname' => '/',\n                'basename' => '',\n                'filename' => '',\n            ]\n        ],\n        '/absolute' => [\n            'parent' => '/',\n            'normalize' => '/absolute',\n            'dirname' => '/',\n            'pathinfo' => [\n                'dirname' => '/',\n                'basename' => 'absolute',\n                'filename' => 'absolute',\n            ]\n        ],\n        '/absolute/' => [\n            'parent' => '/',\n            'normalize' => '/absolute',\n            'dirname' => '/',\n            'pathinfo' => [\n                'dirname' => '/',\n                'basename' => 'absolute',\n                'filename' => 'absolute',\n            ]\n        ],\n        '/very/long/absolute/path' => [\n            'parent' => '/very/long/absolute',\n            'normalize' => '/very/long/absolute/path',\n            'dirname' => '/very/long/absolute',\n            'pathinfo' => [\n                'dirname' => '/very/long/absolute',\n                'basename' => 'path',\n                'filename' => 'path',\n            ]\n        ],\n        '/very/long/absolute/../path' => [\n            'parent' => '/very/long',\n            'normalize' => '/very/long/path',\n            'dirname' => '/very/long/absolute/..',\n            'pathinfo' => [\n                'dirname' => '/very/long/absolute/..',\n                'basename' => 'path',\n                'filename' => 'path',\n            ]\n        ],\n        'relative' => [\n            'parent' => '.',\n            'normalize' => 'relative',\n            'dirname' => '.',\n            'pathinfo' => [\n                'dirname' => '.',\n                'basename' => 'relative',\n                'filename' => 'relative',\n            ]\n        ],\n        'very/long/relative/path' => [\n            'parent' => 'very/long/relative',\n            'normalize' => 'very/long/relative/path',\n            'dirname' => 'very/long/relative',\n            'pathinfo' => [\n                'dirname' => 'very/long/relative',\n                'basename' => 'path',\n                'filename' => 'path',\n            ]\n        ],\n        'path/to/file.jpg' => [\n            'parent' => 'path/to',\n            'normalize' => 'path/to/file.jpg',\n            'dirname' => 'path/to',\n            'pathinfo' => [\n                'dirname' => 'path/to',\n                'basename' => 'file.jpg',\n                'extension' => 'jpg',\n                'filename' => 'file',\n            ]\n        ],\n        'user://' => [\n            'parent' => '',\n            'normalize' => 'user://',\n            'dirname' => 'user://',\n            'pathinfo' => [\n                'dirname' => 'user://',\n                'basename' => '',\n                'filename' => '',\n                'scheme' => 'user',\n            ]\n        ],\n        'user://.' => [\n            'parent' => '',\n            'normalize' => 'user://',\n            'dirname' => 'user://',\n            'pathinfo' => [\n                'dirname' => 'user://',\n                'basename' => '',\n                'filename' => '',\n                'scheme' => 'user',\n            ]\n        ],\n        'user://././.' => [\n            'parent' => '',\n            'normalize' => 'user://',\n            'dirname' => 'user://',\n            'pathinfo' => [\n                'dirname' => 'user://',\n                'basename' => '',\n                'filename' => '',\n                'scheme' => 'user',\n            ]\n        ],\n        'user://./././file' => [\n            'parent' => 'user://',\n            'normalize' => 'user://file',\n            'dirname' => 'user://',\n            'pathinfo' => [\n                'dirname' => 'user://',\n                'basename' => 'file',\n                'filename' => 'file',\n                'scheme' => 'user',\n            ]\n        ],\n        'user://./././folder/file' => [\n            'parent' => 'user://folder',\n            'normalize' => 'user://folder/file',\n            'dirname' => 'user://folder',\n            'pathinfo' => [\n                'dirname' => 'user://folder',\n                'basename' => 'file',\n                'filename' => 'file',\n                'scheme' => 'user',\n            ]\n        ],\n        'user://.file' => [\n            'parent' => 'user://',\n            'normalize' => 'user://.file',\n            'dirname' => 'user://',\n            'pathinfo' => [\n                'dirname' => 'user://',\n                'basename' => '.file',\n                'extension' => 'file',\n                'filename' => '',\n                'scheme' => 'user',\n            ]\n        ],\n        'user:///' => [\n            'parent' => '',\n            'normalize' => 'user:///',\n            'dirname' => 'user:///',\n            'pathinfo' => [\n                'dirname' => 'user:///',\n                'basename' => '',\n                'filename' => '',\n                'scheme' => 'user',\n            ]\n        ],\n        'user:///absolute' => [\n            'parent' => 'user:///',\n            'normalize' => 'user:///absolute',\n            'dirname' => 'user:///',\n            'pathinfo' => [\n                'dirname' => 'user:///',\n                'basename' => 'absolute',\n                'filename' => 'absolute',\n                'scheme' => 'user',\n            ]\n        ],\n        'user:///very/long/absolute/path' => [\n            'parent' => 'user:///very/long/absolute',\n            'normalize' => 'user:///very/long/absolute/path',\n            'dirname' => 'user:///very/long/absolute',\n            'pathinfo' => [\n                'dirname' => 'user:///very/long/absolute',\n                'basename' => 'path',\n                'filename' => 'path',\n                'scheme' => 'user',\n            ]\n        ],\n        'user://relative' => [\n            'parent' => 'user://',\n            'normalize' => 'user://relative',\n            'dirname' => 'user://',\n            'pathinfo' => [\n                'dirname' => 'user://',\n                'basename' => 'relative',\n                'filename' => 'relative',\n                'scheme' => 'user',\n            ]\n        ],\n        'user://very/long/relative/path' => [\n            'parent' => 'user://very/long/relative',\n            'normalize' => 'user://very/long/relative/path',\n            'dirname' => 'user://very/long/relative',\n            'pathinfo' => [\n                'dirname' => 'user://very/long/relative',\n                'basename' => 'path',\n                'filename' => 'path',\n                'scheme' => 'user',\n            ]\n        ],\n        'user://path/to/file.jpg' => [\n            'parent' => 'user://path/to',\n            'normalize' => 'user://path/to/file.jpg',\n            'dirname' => 'user://path/to',\n            'pathinfo' => [\n                'dirname' => 'user://path/to',\n                'basename' => 'file.jpg',\n                'extension' => 'jpg',\n                'filename' => 'file',\n                'scheme' => 'user',\n            ]\n        ],\n    ];\n\n    protected function _before(): void\n    {\n        $this->class = Filesystem::getInstance();\n    }\n\n    protected function _after(): void\n    {\n        unset($this->class);\n    }\n\n    /**\n     * @param array $tests\n     * @param string $method\n     */\n    protected function runTestSet(array $tests, $method): void\n    {\n        $class = $this->class;\n        foreach ($tests as $path => $candidates) {\n            if (!array_key_exists($method, $candidates)) {\n                continue;\n            }\n\n            $expected = $candidates[$method];\n\n            $result = $class->{$method}($path);\n\n            self::assertSame($expected, $result, \"Test {$method}('{$path}')\");\n\n            if (function_exists($method) && !strpos($path, '://')) {\n                $cmp_result = $method($path);\n\n                self::assertSame($cmp_result, $result, \"Compare to original {$method}('{$path}')\");\n            }\n        }\n    }\n\n    public function testParent(): void\n    {\n        $this->runTestSet($this->tests, 'parent');\n    }\n\n    public function testNormalize(): void\n    {\n        $this->runTestSet($this->tests, 'normalize');\n    }\n\n    public function testDirname(): void\n    {\n        $this->runTestSet($this->tests, 'dirname');\n    }\n\n    public function testPathinfo(): void\n    {\n        $this->runTestSet($this->tests, 'pathinfo');\n    }\n}\n"
  },
  {
    "path": "tests/unit/_bootstrap.php",
    "content": "<?php\n// Here you can initialize variables that will be available to your tests\ndefine('GRAV_CLI', true);\n"
  },
  {
    "path": "tests/unit/data/blueprints/strict.yaml",
    "content": "form:\n  validation: strict\n\n  fields:\n    tabs:\n      type: tabs\n      fields:\n        tab:\n          type: tab\n          fields:\n            test:\n              type: text\n              label: Test\n              validate:\n                required: true\n"
  },
  {
    "path": "tests/unit.suite.yml",
    "content": "# Codeception Test Suite Configuration\n#\n# Suite for unit (internal) tests.\n\nclass_name: UnitTester\nmodules:\n    enabled:\n        - Asserts\n        - \\Helper\\Unit"
  },
  {
    "path": "user/accounts/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "user/config/site.yaml",
    "content": "title: Grav\nauthor:\n  name: Joe Bloggs\n  email: 'joe@example.com'\nmetadata:\n    description: 'Grav is an easy to use, yet powerful, open source flat-file CMS'\n\n"
  },
  {
    "path": "user/config/system.yaml",
    "content": "absolute_urls: false\n\nhome:\n  alias: '/home'\n\npages:\n  theme: quark\n  markdown:\n    extra: false\n  process:\n    markdown: true\n    twig: false\n\ncache:\n  enabled: true\n  check:\n    method: file\n  driver: auto\n  prefix: 'g'\n\ntwig:\n  cache: true\n  debug: true\n  auto_reload: true\n  autoescape: true\n\nassets:\n  css_pipeline: false\n  css_minify: true\n  css_rewrite: true\n  js_pipeline: false\n  js_module_pipeline: false\n  js_minify: true\n\nerrors:\n  display: true\n  log: true\n\ndebugger:\n  enabled: false\n  twig: true\n  shutdown:\n    close_connection: true\ngpm:\n  verify_peer: true\n"
  },
  {
    "path": "user/data/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "user/pages/01.home/default.md",
    "content": "---\ntitle: Home\nbody_classes: title-center title-h1h2\n---\n\n# Say Hello to Grav!\n## installation successful...\n\nCongratulations! You have installed the **Base Grav Package** that provides a **simple page** and the default **Quark** theme to get you started.\n\n!! If you see a **404 Error** when you click `Typography` in the menu, please refer to the [troubleshooting guide](http://learn.getgrav.org/troubleshooting/page-not-found).\n\n### Find out all about Grav\n\n* Learn about **Grav** by checking out our dedicated [Learn Grav](http://learn.getgrav.org) site.\n* Download **plugins**, **themes**, as well as other Grav **skeleton** packages from the [Grav Downloads](http://getgrav.org/downloads) page.\n* Check out our [Grav Development Blog](http://getgrav.org/blog) to find out the latest goings on in the Grav-verse.\n\n!!! If you want a more **full-featured** base install, you should check out [**Skeleton** packages available in the downloads](http://getgrav.org/downloads).\n\n### Edit this Page\n\nTo edit this page, simply navigate to the folder you installed **Grav** into, and then browse to the `user/pages/01.home` folder and open the `default.md` file in your [editor of choice](http://learn.getgrav.org/basics/requirements).  You will see the content of this page in [Markdown format](http://learn.getgrav.org/content/markdown).\n\n### Create a New Page\n\nCreating a new page is a simple affair in **Grav**.  Simply follow these simple steps:\n\n1. Navigate to your pages folder: `user/pages/` and create a new folder.  In this example, we will use [explicit default ordering](http://learn.getgrav.org/content/content-pages) and call the folder `03.mypage`.\n2. Launch your text editor and paste in the following sample code:\n\n        ---\n        title: My New Page\n        ---\n        # My New Page!\n\n        This is the body of **my new page** and I can easily use _Markdown_ syntax here.\n\n3. Save this file in the `user/pages/03.mypage/` folder as `default.md`. This will tell **Grav** to render the page using the **default** template.\n4. That is it! Reload your browser to see your new page in the menu.\n\n! NOTE: The page will automatically show up in the Menu after the \"Typography\" menu item. If you wish to change the name that shows up in the Menu, simple add: `menu: My Page` between the dashes in the page content. This is called the YAML front matter, and it is where you configure page-specific options.\n"
  },
  {
    "path": "user/pages/02.typography/default.md",
    "content": "---\ntitle: Typography\n---\n\n! Details on the full capabilities of Spectre.css can be found in the [Official Spectre Documentation](https://picturepan2.github.io/spectre/elements.html)\n\nThe [Quark theme](https://github.com/getgrav/grav-theme-quark) is the new default theme for Grav built with [Spectre.css](https://picturepan2.github.io/spectre/) the lightweight, responsive and modern CSS framework. Spectre provides  basic styles for typography, elements, and a responsive layout system that utilizes best practices and consistent language design.\n\n### Headings\n\n# H1 Heading `40px`\n\n## H2 Heading `32px`\n\n### H3 Heading `28px`\n\n#### H4 Heading `24px`\n\n##### H5 Heading `20px`\n\n###### H6 Heading `16px`\n\n```html\n# H1 Heading\n# H1 Heading `40px`</small>`\n\n<span class=\"h1\">H1 Heading</span>\n```\n\n### Paragraphs\n\nLorem ipsum dolor sit amet, consectetur [adipiscing elit. Praesent risus leo, dictum in vehicula sit amet](#), feugiat tempus tellus. Duis quis sodales risus. Etiam euismod ornare consequat.\n\nClimb leg rub face on everything give attitude nap all day for under the bed. Chase mice attack feet but rub face on everything hopped up on goofballs.\n\n### Markdown Semantic Text Elements\n\n**Bold** `**Bold**`\n\n_Italic_ `_Italic_`\n\n~~Deleted~~ `~~Deleted~~`\n\n`Inline Code` `` `Inline Code` ``\n\n### HTML Semantic Text Elements\n\n<abbr>I18N</abbr> `<abbr>`\n\n<cite>Citation</cite> `<cite>`\n\n<kbd>Ctrl + S</kbd> `<kbd>`\n\nText<sup>Superscripted</sup> `<sup>`\n\nText<sub>Subscripted</sub> `<sub>`\n\n<u>Underlined</u> `<u>`\n\n<mark>Highlighted</mark> `<mark>`\n\n<time>20:14</time> `<time>`\n\n<var>x = y + 2</var> `<var>`\n\n### Blockquote\n\n> The advance of technology is based on making it fit in so that you don't really even notice it,\n> so it's part of everyday life.\n>\n> <cite>- Bill Gates</cite>\n\n```markdown\n> The advance of technology is based on making it fit in so that you don't really even notice it,\n> so it's part of everyday life.\n>\n> <cite>- Bill Gates</cite>\n```\n\n### Unordered List\n\n* list item 1\n* list item 2\n    * list item 2.1\n    * list item 2.2\n    * list item 2.3\n* list item 3\n\n```markdown\n* list item 1\n* list item 2\n    * list item 2.1\n    * list item 2.2\n    * list item 2.3\n* list item 3\n```\n\n### Ordered List\n\n1. list item 1\n1. list item 2\n    1. list item 2.1\n    1. list item 2.2\n    1. list item 2.3\n1. list item 3\n\n```markdown\n1. list item 1\n1. list item 2\n    1. list item 2.1\n    1. list item 2.2\n    1. list item 2.3\n1. list item 3\n```\n\n### Table\n\n| Name                        | Genre                         | Release date         |\n| :-------------------------- | :---------------------------: | -------------------: |\n| The Shawshank Redemption    | Crime, Drama                  | 14 October 1994      |\n| The Godfather               | Crime, Drama                  | 24 March 1972        |\n| Schindler's List            | Biography, Drama, History     | 4 February 1994      |\n| Se7en                       | Crime, Drama, Mystery         | 22 September 1995    |\n\n```markdown\n| Name                        | Genre                         | Release date         |\n| :-------------------------- | :---------------------------: | -------------------: |\n| The Shawshank Redemption    | Crime, Drama                  | 14 October 1994      |\n| The Godfather               | Crime, Drama                  | 24 March 1972        |\n| Schindler's List            | Biography, Drama, History     | 4 February 1994      |\n| Se7en                       | Crime, Drama, Mystery         | 22 September 1995    |\n```\n\n### Notices\n\nThe notices styles are actually provided by the `markdown-notices` plugin but are useful enough to include here:\n\n! This is a warning notification\n\n!! This is a error notification\n\n!!! This is a default notification\n\n!!!! This is a success notification\n\n```markdown\n! This is a warning notification\n\n!! This is a error notification\n\n!!! This is a default notification\n\n!!!! This is a success notification\n```\n\n"
  },
  {
    "path": "user/plugins/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "user/themes/.gitkeep",
    "content": "/* @copyright  Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved. */\n"
  },
  {
    "path": "webserver-configs/Caddyfile",
    "content": "# To use this file simply install caddy and run the command below from the root of your Grav site\n# Once running it will redirect http://localhost to https://localhost (new default for Caddy2)\n# More infromation here: https://caddyserver.com/docs/\n#\n# $ caddy run --config webserver-configs/Caddyfile\n\nlocalhost\nencode gzip\nroot * .\nfile_server\n\nphp_fastcgi 127.0.0.1:9000\n\n# Begin - Security\n# deny all direct access for these folders\nrewrite /(\\.git|cache|bin|logs|backups|tests)/.* /403\n\n# deny running scripts inside core system folders\nrewrite /(system|vendor)/.*\\.(txt|xml|md|html|htm|shtml|shtm|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ /403\n\n# deny running scripts inside user folder\nrewrite /user/.*\\.(txt|md|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ /403\n\n# deny access to specific files in the root folder\nrewrite /(LICENSE\\.txt|composer\\.lock|composer\\.json|nginx\\.conf|web\\.config|htaccess\\.txt|\\.htaccess) /403\n\nrespond /403 403\n## End - Security\n\n# global rewrite should come last.\ntry_files {path} {path}/ /index.php?_url={uri}&{query}\n"
  },
  {
    "path": "webserver-configs/Caddyfile-0.8.x",
    "content": "# Caddyfile for Caddy 0.8.x and below\n\n:8080\ngzip\nfastcgi / 127.0.0.1:9000 php\n\n# Begin - Security\n# deny all direct access for these folders\nrewrite {\n    r       /(\\.git|cache|bin|logs|backups|tests)/.*$\n    status  403\n}\n# deny running scripts inside core system folders\nrewrite {\n    r       /(system|vendor)/.*\\.(txt|xml|md|html|htm|shtml|shtm|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$\n    status  403\n}\n# deny running scripts inside user folder\nrewrite {\n    r       /user/.*\\.(txt|md|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$\n    status  403\n}\n# deny access to specific files in the root folder\nrewrite {\n    r       /(LICENSE\\.txt|composer\\.lock|composer\\.json|nginx\\.conf|web\\.config|htaccess\\.txt|\\.htaccess)\n    status  403\n}\n## End - Security\n\n# global rewrite should come last.\nrewrite {\n    to  {path} {path}/ /index.php?_url={uri}&{query}\n}\n"
  },
  {
    "path": "webserver-configs/htaccess.txt",
    "content": "<IfModule mod_rewrite.c>\n\nRewriteEngine On\n\n## Begin RewriteBase\n# If you are getting 500 or 404 errors on subpages, you may have to uncomment the RewriteBase entry\n# You should change the '/' to your appropriate subfolder. For example if you have\n# your Grav install at the root of your site '/' should work, else it might be something\n# along the lines of: RewriteBase /<your_sub_folder>\n##\n\n# RewriteBase /\n\n## End - RewriteBase\n\n## Begin - X-Forwarded-Proto\n# In some hosted or load balanced environments, SSL negotiation happens upstream.\n# In order for Grav to recognize the connection as secure, you need to uncomment\n# the following lines.\n#\n# RewriteCond %{HTTP:X-Forwarded-Proto} https\n# RewriteRule .* - [E=HTTPS:on]\n#\n## End - X-Forwarded-Proto\n\n## Begin - Exploits\n# If you experience problems on your site block out the operations listed below\n# This attempts to block the most common type of exploit `attempts` to Grav\n#\n# Block out any script trying to use twig tags in URL.\nRewriteCond %{REQUEST_URI} ({{|}}|{%|%}) [OR]\nRewriteCond %{QUERY_STRING} ({{|}}|{%25|%25}) [OR]\n# Block out any script trying to base64_encode data within the URL.\nRewriteCond %{QUERY_STRING} base64_encode[^(]*\\([^)]*\\) [OR]\n# Block out any script that includes a <script> tag in URL.\nRewriteCond %{QUERY_STRING} (<|%3C)([^s]*s)+cript.*(>|%3E) [NC,OR]\n# Block out any script trying to set a PHP GLOBALS variable via URL.\nRewriteCond %{QUERY_STRING} GLOBALS(=|\\[|\\%[0-9A-Z]{0,2}) [OR]\n# Block out any script trying to modify a _REQUEST variable via URL.\nRewriteCond %{QUERY_STRING} _REQUEST(=|\\[|\\%[0-9A-Z]{0,2})\n# Return 403 Forbidden header and show the content of the root homepage\nRewriteRule .* index.php [F]\n#\n## End - Exploits\n\n## Begin - Index\n# If the requested path and file is not /index.php and the request\n# has not already been internally rewritten to the index.php script\nRewriteCond %{REQUEST_URI} !^/index\\.php\n# and the requested path and file doesn't directly match a physical file\nRewriteCond %{REQUEST_FILENAME} !-f\n# and the requested path and file doesn't directly match a physical folder\nRewriteCond %{REQUEST_FILENAME} !-d\n# internally rewrite the request to the index.php script\nRewriteRule .* index.php [L]\n## End - Index\n\n## Begin - Security\n# Block all direct access for these folders\nRewriteRule ^(\\.git|cache|bin|logs|backup|webserver-configs|tests)/(.*) error [F]\n# Block access to specific file types for these system folders\nRewriteRule ^(system|vendor)/(.*)\\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F]\n# Block access to specific file types for these user folders\nRewriteRule ^(user)/(.*)\\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ error [F]\n# Block all direct access to .md files:\nRewriteRule \\.md$ error [F]\n# Block all direct access to files and folders beginning with a dot\nRewriteRule (^|/)\\.(?!well-known) - [F]\n# Block access to specific files in the root folder\nRewriteRule ^(LICENSE\\.txt|composer\\.lock|composer\\.json|\\.htaccess)$ error [F]\n## End - Security\n\n</IfModule>\n\n# Begin - Prevent Browsing and Set Default Resources\nOptions -Indexes\nDirectoryIndex index.php index.html index.htm\n# End - Prevent Browsing and Set Default Resources\n"
  },
  {
    "path": "webserver-configs/lighttpd.conf",
    "content": "############# DO NOT FORGET TO CHANGE \"grav_path\" BY YOUR ACTUAL GRAV INSTALLATION FOLDER #############\n############# IF GRAV IS AT THE ROOT OF YOUR WEBSITE, ie http://yoursite.tld POINTS TO    #############\n############# GRAV DIRECTLY, THEN JUST REMOVE ANY \"/grav_path/\" MENTION BELOW. OTHERWISE  #############\n############# WE ASSUME YOU RUN AN INSTALLATION SUCH AS http://yoursite.tld/grav_path/    #############\n#######################################################################################################\n### GRAV RULES FOR LIGHTTPD ###\n###        By Mr3ase        ###\n###  Last Rev. 2015/11/20   ###\n\n#PREVENTING EXPLOITS\n$HTTP[\"querystring\"] =~ \"base64_encode[^(]*\\([^)]*\\)\" {\n    url.redirect = (\".*\" => \"/grav_path/index.php\"       )\n}\n$HTTP[\"querystring\"] =~ \"(<|%3C)([^s]*s)+cript.*(>|%3E)\" {\n    url.redirect = (\".*\" => \"/grav_path/index.php\" )\n}\n$HTTP[\"querystring\"] =~ \"GLOBALS(=|\\[|\\%[0-9A-Z])\" {\n    url.redirect = (\".*\" => \"/grav_path/index.php\" )\n}\n$HTTP[\"querystring\"] =~ \"_REQUEST(=|\\[|\\%[0-9A-Z])\" {\n    url.redirect = (\".*\" => \"/grav_path/index.php\" )\n}\n\n#REROUTING TO THE INDEX PAGE\nurl.rewrite-if-not-file = (\n    \"^/grav_path/(.*)$\" => \"/grav_path/index.php?$1\"\n)\n\n#IMPROVING SECURITY\n$HTTP[\"url\"] =~ \"^/grav_path/(LICENSE\\.txt|composer\\.json|composer\\.lock|nginx\\.conf|web\\.config)$\" {\n    url.access-deny = (\"\")\n}\n$HTTP[\"url\"] =~ \"^/grav_path/(\\.git|cache|bin|logs|backup|tests)/(.*)\" {\n    url.access-deny = (\"\")\n}\n$HTTP[\"url\"] =~ \"^/grav_path/(system|user|vendor)/(.*)\\.(txt|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|twig|sh|bat)$\" {\n    url.access-deny = (\"\")\n}\n$HTTP[\"url\"] =~ \"^/grav_path/(\\.(.*))\" {\n    url.access-deny = (\"\")\n}\nurl.access-deny += (\".md\",\"~\",\".inc\")\n\n#PREVENT BROWSING AND SET INDEXES\n$HTTP[\"url\"] =~ \"^/grav_path($|/)\" {\n    dir-listing.activate = \"disable\"\n    index-file.names = ( \"index.php\", \"index.html\" , \"index.htm\" )\n}\n"
  },
  {
    "path": "webserver-configs/nginx.conf",
    "content": "server {\n    #listen 80;\n    index index.html index.php;\n\n    ## Begin - Server Info\n    root /home/USER/www/html;\n    server_name localhost;\n    ## End - Server Info\n\n    ## Begin - Index\n    # for subfolders, simply adjust:\n    # `location /subfolder {`\n    # and the rewrite to use `/subfolder/index.php`\n    location / {\n        try_files $uri $uri/ /index.php?$query_string;\n    }\n    ## End - Index\n\n    ## Begin - Security\n    # deny all direct access for these folders\n    location ~* /(\\.git|cache|bin|logs|backup|tests)/.*$ { return 403; }\n    # deny running scripts inside core system folders\n    location ~* /(system|vendor)/.*\\.(txt|xml|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ { return 403; }\n    # deny running scripts inside user folder\n    location ~* /user/.*\\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$ { return 403; }\n    # deny access to specific files in the root folder\n    location ~ /(LICENSE\\.txt|composer\\.lock|composer\\.json|nginx\\.conf|web\\.config|htaccess\\.txt|\\.htaccess) { return 403; }\n    ## End - Security\n\n    ## Begin - PHP\n    location ~ \\.php$ {\n        # Choose either a socket or TCP/IP address\n        fastcgi_pass unix:/var/run/php/php-fpm.sock;\n        # fastcgi_pass unix:/var/run/php5-fpm.sock; #legacy\n        # fastcgi_pass 127.0.0.1:9000;\n\n        fastcgi_split_path_info ^(.+\\.php)(/.+)$;\n        fastcgi_index index.php;\n        include fastcgi_params;\n        fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name;\n    }\n    ## End - PHP\n}\n\n"
  },
  {
    "path": "webserver-configs/web.config",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n    <system.webServer>\n        <defaultDocument>\n            <files>\n                <remove value=\"index.php\" />\n                <add value=\"index.php\" />\n            </files>\n        </defaultDocument>\n        <rewrite>\n            <rules>\n                <rule name=\"request_filename\" stopProcessing=\"true\">\n                    <match url=\".\" ignoreCase=\"false\" />\n                    <conditions logicalGrouping=\"MatchAll\">\n                        <add input=\"{REQUEST_FILENAME}\" matchType=\"IsFile\" ignoreCase=\"false\" negate=\"true\" />\n                        <add input=\"{REQUEST_FILENAME}\" matchType=\"IsDirectory\" ignoreCase=\"false\" negate=\"true\" />\n                    </conditions>\n                    <action type=\"Rewrite\" url=\"index.php\" />\n                </rule>\n                <rule name=\"user_error_redirect\" stopProcessing=\"true\">\n                    <match url=\"^(user)/(.*)\\.(txt|md|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|pl|py|cgi|twig|sh|bat)$\" ignoreCase=\"false\" />\n                    <action type=\"Redirect\" url=\"error\" redirectType=\"Permanent\" />\n                </rule>\n                <rule name=\"ignore_folders\" stopProcessing=\"true\">\n                    <match url=\"^(\\.git|cache|bin|logs|backup|webserver-configs|tests)/(.*)\" ignoreCase=\"false\" />\n                    <action type=\"Redirect\" url=\"error\" redirectType=\"Permanent\" />\n                </rule>\n                <rule name=\"system\" stopProcessing=\"true\">\n                    <match url=\"^system/(.*)\\.(txt|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|twig|sh|bat)$\" ignoreCase=\"false\" />\n                    <action type=\"Redirect\" url=\"error\" redirectType=\"Permanent\" />\n                </rule>\n                <rule name=\"vendor\" stopProcessing=\"true\">\n                    <match url=\"^vendor/(.*)\\.(txt|md|html|htm|shtml|shtm|json|yaml|yml|php|php2|php3|php4|php5|phar|phtml|twig|sh|bat)$\" ignoreCase=\"false\" />\n                    <action type=\"Redirect\" url=\"error\" redirectType=\"Permanent\" />\n                </rule>\n            </rules>\n        </rewrite>\n    </system.webServer>\n    <system.web>\n        <httpRuntime requestPathInvalidCharacters=\"&lt;,&gt;,*,%,&amp;,\\,?\" />\n    </system.web>\n</configuration>\n"
  }
]