diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index ea22ad291..90ed324b9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - php: [ 8.0, 7.4, 7.3] + php: [ 8.1, 8.0, 7.4, 7.3] os: [ubuntu-latest] steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3900481..0485ea929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# v1.7.27 +## 01/12/2022 + +1. [](#new) + * Support for `YubiKey OTP` 2-Factor authenticator + * Added support for generic `assets.link()` for external references. No pipeline support + * Added support for `assets.addJsModule()` with full pipeline support + * Added `Utils::getExtensionsByMime()` method to get all the registered extensions for the specific mime type + * Added `Media::getRoute()` and `Media::getRawRoute()` methods to get page route if available + * Added `Medium::getAlternatives()` to be able to list all the retina sizes +2. [](#improved) + * Improved `Utils::download()` method to allow overrides on download name, mime and expires header + * Improved `onPageFallBackUrl` event + * Reorganized the Asset system configuration blueprint for clarity +3. [](#bugfix) + * Fixed CLI `--env` and `--lang` options having no effect if they aren't added before all the other options + * Fixed scaled image medium filename when using non-existing retina file + * Fixed an issue with JS `imports` and pipelining Assets + # v1.7.26.1 ## 01/04/2022 diff --git a/README.md b/README.md index 5f0e679a9..562726dfd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Grav is a **Fast**, **Simple**, and **Flexible**, file-based Web-platform. Ther The underlying architecture of Grav is designed to use well-established and _best-in-class_ technologies to ensure that Grav is simple to use and easy to extend. Some of these key technologies include: -* [Twig Templating](https://twig.sensiolabs.org/): for powerful control of the user interface +* [Twig Templating](https://twig.symfony.com/): for powerful control of the user interface * [Markdown](https://en.wikipedia.org/wiki/Markdown): for easy content creation * [YAML](https://yaml.org): for simple configuration * [Parsedown](https://parsedown.org/): for fast Markdown and Markdown Extra support diff --git a/system/blueprints/config/system.yaml b/system/blueprints/config/system.yaml index aff54b46c..b6beb41b9 100644 --- a/system/blueprints/config/system.yaml +++ b/system/blueprints/config/system.yaml @@ -888,9 +888,45 @@ form: title: PLUGIN_ADMIN.ASSETS fields: - assets_section: + general_config_section: type: section - title: PLUGIN_ADMIN.ASSETS + title: PLUGIN_ADMIN.GENERAL_CONFIG + underline: true + + assets.enable_asset_timestamp: + type: toggle + label: PLUGIN_ADMIN.ENABLED_TIMESTAMPS_ON_ASSETS + help: PLUGIN_ADMIN.ENABLED_TIMESTAMPS_ON_ASSETS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.enable_asset_sri: + type: toggle + label: PLUGIN_ADMIN.ENABLED_SRI_ON_ASSETS + help: PLUGIN_ADMIN.ENABLED_SRI_ON_ASSETS_HELP + highlight: 0 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO + validate: + type: bool + + assets.collections: + type: multilevel + label: PLUGIN_ADMIN.COLLECTIONS + placeholder_key: collection_name + placeholder_value: collection_path + validate: + type: array + + + css_assets_section: + type: section + title: PLUGIN_ADMIN.CSS_ASSETS underline: true assets.css_pipeline: @@ -959,6 +995,11 @@ form: validate: type: bool + js_assets_section: + type: section + title: PLUGIN_ADMIN.JS_ASSETS + underline: true + assets.js_pipeline: type: toggle label: PLUGIN_ADMIN.JAVASCRIPT_PIPELINE @@ -1003,10 +1044,15 @@ form: validate: type: bool - assets.enable_asset_timestamp: + js_module_assets_section: + type: section + title: PLUGIN_ADMIN.JS_MODULE_ASSETS + underline: true + + assets.js_module_pipeline: type: toggle - label: PLUGIN_ADMIN.ENABLED_TIMESTAMPS_ON_ASSETS - help: PLUGIN_ADMIN.ENABLED_TIMESTAMPS_ON_ASSETS_HELP + label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE + help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_HELP highlight: 0 options: 1: PLUGIN_ADMIN.YES @@ -1014,24 +1060,29 @@ form: validate: type: bool - assets.enable_asset_sri: + assets.js_module_pipeline_include_externals: type: toggle - label: PLUGIN_ADMIN.ENABLED_SRI_ON_ASSETS - help: PLUGIN_ADMIN.ENABLED_SRI_ON_ASSETS_HELP - highlight: 0 + label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_INCLUDE_EXTERNALS + help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_INCLUDE_EXTERNALS_HELP + highlight: 1 options: 1: PLUGIN_ADMIN.YES 0: PLUGIN_ADMIN.NO validate: type: bool - assets.collections: - type: multilevel - label: PLUGIN_ADMIN.COLLECTIONS - placeholder_key: collection_name - placeholder_value: collection_path + assets.js_module_pipeline_before_excludes: + type: toggle + label: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_BEFORE_EXCLUDES + help: PLUGIN_ADMIN.JAVASCRIPT_MODULE_PIPELINE_BEFORE_EXCLUDES_HELP + highlight: 1 + options: + 1: PLUGIN_ADMIN.YES + 0: PLUGIN_ADMIN.NO validate: - type: array + type: bool + + errors: type: tab diff --git a/system/blueprints/user/account.yaml b/system/blueprints/user/account.yaml index 127a99e5b..cfe537620 100644 --- a/system/blueprints/user/account.yaml +++ b/system/blueprints/user/account.yaml @@ -107,6 +107,12 @@ form: label: PLUGIN_ADMIN.2FA_SECRET sublabel: PLUGIN_ADMIN.2FA_SECRET_HELP + yubikey_id: + type: text + label: PLUGIN_ADMIN.YUBIKEY_ID + description: PLUGIN_ADMIN.YUBIKEY_HELP + size: small + maxlength: 12 diff --git a/system/config/system.yaml b/system/config/system.yaml index 60abcee34..2de075b26 100644 --- a/system/config/system.yaml +++ b/system/config/system.yaml @@ -127,6 +127,9 @@ assets: # Configuration for Assets Mana js_pipeline: false # The JS pipeline is the unification of multiple JS resources into one file js_pipeline_include_externals: true # Include external URLs in the pipeline by default js_pipeline_before_excludes: true # Render the pipeline before any excluded files + js_module_pipeline: false # The JS Module pipeline is the unification of multiple JS Module resources into one file + js_module_pipeline_include_externals: true # Include external URLs in the pipeline by default + js_module_pipeline_before_excludes: true # Render the pipeline before any excluded files js_minify: true # Minify the JS during pipelining enable_asset_timestamp: false # Enable asset timestamps enable_asset_sri: false # Enable asset SRI diff --git a/system/defines.php b/system/defines.php index 2bebab46c..a8ee1adb7 100644 --- a/system/defines.php +++ b/system/defines.php @@ -9,7 +9,7 @@ // Some standard defines define('GRAV', true); -define('GRAV_VERSION', '1.7.26.1'); +define('GRAV_VERSION', '1.7.27'); define('GRAV_SCHEMA', '1.7.0_2020-11-20_1'); define('GRAV_TESTING', false); diff --git a/system/src/Grav/Common/Assets.php b/system/src/Grav/Common/Assets.php index 36dc79dd0..953bbc88e 100644 --- a/system/src/Grav/Common/Assets.php +++ b/system/src/Grav/Common/Assets.php @@ -30,14 +30,21 @@ class Assets extends PropertyObject use TestingAssetsTrait; use LegacyAssetsTrait; + const LINK = 'link'; const CSS = 'css'; const JS = 'js'; + const JS_MODULE = 'js_module'; + const LINK_COLLECTION = 'assets_link'; const CSS_COLLECTION = 'assets_css'; const JS_COLLECTION = 'assets_js'; + const JS_MODULE_COLLECTION = 'assets_js_module'; + const LINK_TYPE = Assets\Link::class; const CSS_TYPE = Assets\Css::class; const JS_TYPE = Assets\Js::class; + const JS_MODULE_TYPE = Assets\JsModule::class; const INLINE_CSS_TYPE = Assets\InlineCss::class; const INLINE_JS_TYPE = Assets\InlineJs::class; + const INLINE_JS_MODULE_TYPE = Assets\InlineJsModule::class; /** @const Regex to match CSS and JavaScript files */ const DEFAULT_REGEX = '/.\.(css|js)$/i'; @@ -48,15 +55,24 @@ class Assets extends PropertyObject /** @const Regex to match JavaScript files */ const JS_REGEX = '/.\.js$/i'; + /** @const Regex to match JavaScriptModyle files */ + const JS_MODULE_REGEX = '/.\.mjs$/i'; + /** @var string */ protected $assets_dir; /** @var string */ protected $assets_url; + /** @var array */ + protected $assets_link = []; /** @var array */ protected $assets_css = []; /** @var array */ protected $assets_js = []; + /** @var array */ + protected $assets_js_module = []; + + // Following variables come from the configuration: /** @var bool */ @@ -66,19 +82,17 @@ class Assets extends PropertyObject /** @var bool */ protected $css_pipeline_before_excludes; /** @var bool */ - protected $inlinecss_pipeline_include_externals; - /** @var bool */ - protected $inlinecss_pipeline_before_excludes; - /** @var bool */ protected $js_pipeline; /** @var bool */ protected $js_pipeline_include_externals; /** @var bool */ protected $js_pipeline_before_excludes; /** @var bool */ - protected $inlinejs_pipeline_include_externals; + protected $js_module_pipeline; + /** @var bool */ + protected $js_module_pipeline_include_externals; /** @var bool */ - protected $inlinejs_pipeline_before_excludes; + protected $js_module_pipeline_before_excludes; /** @var array */ protected $pipeline_options = []; @@ -193,6 +207,8 @@ public function add($asset) call_user_func_array([$this, 'addCss'], $args); } elseif ($extension === 'js') { call_user_func_array([$this, 'addJs'], $args); + } elseif ($extension === 'mjs') { + call_user_func_array([$this, 'addJsModule'], $args); } } } @@ -222,7 +238,7 @@ protected function addType($collection, $type, $asset, $options) return $this; } - if (($type === $this::CSS_TYPE || $type === $this::JS_TYPE) && isset($this->collections[$asset])) { + if ($this->isValidType($type) && isset($this->collections[$asset])) { $this->addType($collection, $type, $this->collections[$asset], $options); return $this; } @@ -230,7 +246,9 @@ protected function addType($collection, $type, $asset, $options) // If pipeline disabled, set to position if provided, else after if (isset($options['pipeline'])) { if ($options['pipeline'] === false) { - $exclude_type = ($type === $this::JS_TYPE || $type === $this::INLINE_JS_TYPE) ? $this::JS : $this::CSS; + + $exclude_type = $this->getBaseType($type); + $excludes = strtolower($exclude_type . '_pipeline_before_excludes'); if ($this->{$excludes}) { $default = 'after'; @@ -269,6 +287,16 @@ protected function addType($collection, $type, $asset, $options) return $this; } + /** + * Add a CSS asset or a collection of assets. + * + * @return $this + */ + public function addLink($asset) + { + return $this->addType($this::LINK_COLLECTION, $this::LINK_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::LINK_TYPE)); + } + /** * Add a CSS asset or a collection of assets. * @@ -309,6 +337,25 @@ public function addInlineJs($asset) return $this->addType($this::JS_COLLECTION, $this::INLINE_JS_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_JS_TYPE)); } + /** + * Add a JS asset or a collection of assets. + * + * @return $this + */ + public function addJsModule($asset) + { + return $this->addType($this::JS_MODULE_COLLECTION, $this::JS_MODULE_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::JS_MODULE_TYPE)); + } + + /** + * Add an Inline JS asset or a collection of assets. + * + * @return $this + */ + public function addInlineJsModule($asset) + { + return $this->addType($this::JS_MODULE_COLLECTION, $this::INLINE_JS_MODULE_TYPE, $asset, $this->unifyLegacyArguments(func_get_args(), $this::INLINE_JS_MODULE_TYPE)); + } /** * Add/replace collection. @@ -400,7 +447,7 @@ public function render($type, $group = 'head', $attributes = []) $after_assets = $this->filterAssets($group_assets, 'position', 'after', true); // Pipeline - if ($this->{$pipeline_enabled}) { + if ($this->{$pipeline_enabled} ?? false) { $options = array_merge($this->pipeline_options, ['timestamp' => $this->timestamp]); $pipeline = new Pipeline($options); @@ -432,9 +479,29 @@ public function render($type, $group = 'head', $attributes = []) * @param array $attributes * @return string */ - public function css($group = 'head', $attributes = []) + public function css($group = 'head', $attributes = [], $include_link = true) + { + $output = ''; + + if ($include_link) { + $output = $this->link($group, $attributes); + } + + $output .= $this->render(self::CSS, $group, $attributes); + + return $output; + } + + /** + * Build the CSS link tags. + * + * @param string $group name of the group + * @param array $attributes + * @return string + */ + public function link($group = 'head', $attributes = []) { - return $this->render('css', $group, $attributes); + return $this->render(self::LINK, $group, $attributes); } /** @@ -444,8 +511,58 @@ public function css($group = 'head', $attributes = []) * @param array $attributes * @return string */ - public function js($group = 'head', $attributes = []) + public function js($group = 'head', $attributes = [], $include_js_module = true) { - return $this->render('js', $group, $attributes); + $output = $this->render(self::JS, $group, $attributes); + + if ($include_js_module) { + $output .= $this->jsModule($group, $attributes); + } + + return $output; + } + + /** + * Build the Javascript Modules tags + * + * @param $group + * @param $attributes + * @return string + */ + public function jsModule($group = 'head', $attributes = []) + { + return $this->render(self::JS_MODULE, $group, $attributes); + } + + public function all($group = 'head', $attributes = []) + { + $output = $this->css($group, $attributes, false); + $output .= $this->link($group, $attributes); + $output .= $this->js($group, $attributes, false); + $output .= $this->jsModule($group, $attributes); + return $output; + } + + protected function isValidType($type) + { + return in_array($type, [self::CSS_TYPE, self::JS_TYPE, self::JS_MODULE_TYPE]); + } + + protected function getBaseType($type) + { + switch ($type) { + case $this::JS_TYPE: + case $this::INLINE_JS_TYPE: + $base_type = $this::JS; + break; + case $this::JS_MODULE_TYPE: + case $this::INLINE_JS_MODULE_TYPE: + $base_type = $this::JS_MODULE; + break; + default: + $base_type = $this::CSS; + } + + return $base_type; } } diff --git a/system/src/Grav/Common/Assets/BaseAsset.php b/system/src/Grav/Common/Assets/BaseAsset.php index 21d9eef63..4f74f55f9 100644 --- a/system/src/Grav/Common/Assets/BaseAsset.php +++ b/system/src/Grav/Common/Assets/BaseAsset.php @@ -26,8 +26,9 @@ abstract class BaseAsset extends PropertyObject { use AssetUtilsTrait; - protected const CSS_ASSET = true; - protected const JS_ASSET = false; + protected const CSS_ASSET = 1; + protected const JS_ASSET = 2; + protected const JS_MODULE_ASSET = 3; /** @var string|false */ protected $asset; @@ -69,7 +70,7 @@ abstract function render(); * @param array $elements * @param string|null $key */ - public function __construct(array $elements = [], $key = null) + public function __construct(array $elements = [], ?string $key = null) { $base_config = [ 'group' => 'head', diff --git a/system/src/Grav/Common/Assets/Css.php b/system/src/Grav/Common/Assets/Css.php index 701c94235..ea6b388fa 100644 --- a/system/src/Grav/Common/Assets/Css.php +++ b/system/src/Grav/Common/Assets/Css.php @@ -22,7 +22,7 @@ class Css extends BaseAsset * @param array $elements * @param string|null $key */ - public function __construct(array $elements = [], $key = null) + public function __construct(array $elements = [], ?string $key = null) { $base_options = [ 'asset_type' => 'css', diff --git a/system/src/Grav/Common/Assets/InlineCss.php b/system/src/Grav/Common/Assets/InlineCss.php index 943cef6b6..4984db4d1 100644 --- a/system/src/Grav/Common/Assets/InlineCss.php +++ b/system/src/Grav/Common/Assets/InlineCss.php @@ -22,7 +22,7 @@ class InlineCss extends BaseAsset * @param array $elements * @param string|null $key */ - public function __construct(array $elements = [], $key = null) + public function __construct(array $elements = [], ?string $key = null) { $base_options = [ 'asset_type' => 'css', diff --git a/system/src/Grav/Common/Assets/InlineJs.php b/system/src/Grav/Common/Assets/InlineJs.php index 9ad365574..e38a51aee 100644 --- a/system/src/Grav/Common/Assets/InlineJs.php +++ b/system/src/Grav/Common/Assets/InlineJs.php @@ -22,7 +22,7 @@ class InlineJs extends BaseAsset * @param array $elements * @param string|null $key */ - public function __construct(array $elements = [], $key = null) + public function __construct(array $elements = [], ?string $key = null) { $base_options = [ 'asset_type' => 'js', diff --git a/system/src/Grav/Common/Assets/InlineJsModule.php b/system/src/Grav/Common/Assets/InlineJsModule.php new file mode 100644 index 000000000..42ce6f14a --- /dev/null +++ b/system/src/Grav/Common/Assets/InlineJsModule.php @@ -0,0 +1,46 @@ + 'js_module', + 'attributes' => ['type' => 'module'], + 'position' => 'after' + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + return 'renderAttributes(). ">\n" . trim($this->asset) . "\n\n"; + } + +} diff --git a/system/src/Grav/Common/Assets/Js.php b/system/src/Grav/Common/Assets/Js.php index fb67491f3..8687a86b1 100644 --- a/system/src/Grav/Common/Assets/Js.php +++ b/system/src/Grav/Common/Assets/Js.php @@ -22,7 +22,7 @@ class Js extends BaseAsset * @param array $elements * @param string|null $key */ - public function __construct(array $elements = [], $key = null) + public function __construct(array $elements = [], ?string $key = null) { $base_options = [ 'asset_type' => 'js', diff --git a/system/src/Grav/Common/Assets/JsModule.php b/system/src/Grav/Common/Assets/JsModule.php new file mode 100644 index 000000000..5c2a836c2 --- /dev/null +++ b/system/src/Grav/Common/Assets/JsModule.php @@ -0,0 +1,49 @@ + 'js_module', + 'attributes' => ['type' => 'module'] + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + if (isset($this->attributes['loading']) && $this->attributes['loading'] === 'inline') { + $buffer = $this->gatherLinks([$this], self::JS_MODULE_ASSET); + return 'renderAttributes() . ">\n" . trim($buffer) . "\n\n"; + } + + return '\n"; + } +} diff --git a/system/src/Grav/Common/Assets/Link.php b/system/src/Grav/Common/Assets/Link.php new file mode 100644 index 000000000..ecafcea90 --- /dev/null +++ b/system/src/Grav/Common/Assets/Link.php @@ -0,0 +1,43 @@ + 'link', + ]; + + $merged_attributes = Utils::arrayMergeRecursiveUnique($base_options, $elements); + + parent::__construct($merged_attributes, $key); + } + + /** + * @return string + */ + public function render() + { + return 'renderAttributes() . $this->integrityHash($this->asset) . ">\n"; + } +} diff --git a/system/src/Grav/Common/Assets/Pipeline.php b/system/src/Grav/Common/Assets/Pipeline.php index a41e924a5..58ef7d1d0 100644 --- a/system/src/Grav/Common/Assets/Pipeline.php +++ b/system/src/Grav/Common/Assets/Pipeline.php @@ -29,12 +29,16 @@ class Pipeline extends PropertyObject { use AssetUtilsTrait; - protected const CSS_ASSET = true; - protected const JS_ASSET = false; + protected const CSS_ASSET = 1; + protected const JS_ASSET = 2; + protected const JS_MODULE_ASSET = 3; /** @const Regex to match CSS urls */ protected const CSS_URL_REGEX = '{url\(([\'\"]?)(.*?)\1\)}'; + /** @const Regex to match JS imports */ + protected const JS_IMPORT_REGEX = '{import.+from\s?[\'|\"](.+?)[\'|\"]}'; + /** @const Regex to match CSS sourcemap comments */ protected const CSS_SOURCEMAP_REGEX = '{\/\*# (.*?) \*\/}'; @@ -169,7 +173,7 @@ public function renderCss($assets, $group, $attributes = []) * @param array $attributes * @return bool|string URL or generated content if available, else false */ - public function renderJs($assets, $group, $attributes = []) + public function renderJs($assets, $group, $attributes = [], $type = self::JS_ASSET) { // temporary list of assets to pipeline $inline_group = false; @@ -198,7 +202,7 @@ public function renderJs($assets, $group, $attributes = []) } // Concatenate files - $buffer = $this->gatherLinks($assets, self::JS_ASSET); + $buffer = $this->gatherLinks($assets, $type); // Minify if required if ($this->shouldMinify('js')) { @@ -223,6 +227,19 @@ public function renderJs($assets, $group, $attributes = []) return $output; } + /** + * Minify and concatenate JS files. + * + * @param array $assets + * @param string $group + * @param array $attributes + * @return bool|string URL or generated content if available, else false + */ + public function renderJs_Module($assets, $group, $attributes = []) + { + $attributes['type'] = 'module'; + return $this->renderJs($assets, $group, $attributes, self::JS_MODULE_ASSET); + } /** * Finds relative CSS urls() and rewrites the URL with an absolute one @@ -262,6 +279,42 @@ protected function cssRewrite($file, $dir, $local) return $file; } + /** + * Finds relative JS urls() and rewrites the URL with an absolute one + * + * @param string $file the css source file + * @param string $dir , $local relative path to the css file + * @param bool $local is this a local or remote asset + * @return string + */ + protected function jsRewrite($file, $dir, $local) + { + // Find any js import elements, grab the URLs and calculate an absolute path + // Then replace the old url with the new one + $file = (string)preg_replace_callback(self::JS_IMPORT_REGEX, function ($matches) use ($dir, $local) { + + $old_url = $matches[1]; + + // Ensure link is not rooted to web server, a data URL, or to a remote host + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url) || $this->isRemoteLink($old_url)) { + return $matches[0]; + } + + // clean leading / + $old_url = Utils::normalizePath($dir . '/' . $old_url); + $old_url = str_replace('/./', '/', $old_url); + if (preg_match(self::FIRST_FORWARDSLASH_REGEX, $old_url)) { + $old_url = ltrim($old_url, '/'); + } + + $new_url = ($local ? $this->base_url : '') . $old_url; + + return str_replace($matches[1], $new_url, $matches[0]); + }, $file); + + return $file; + } + /** * @param string $type * @return bool diff --git a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php index 5b1087d6c..f04ba438f 100644 --- a/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php +++ b/system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php @@ -62,13 +62,13 @@ public static function isRemoteLink($link) * Download and concatenate the content of several links. * * @param array $assets - * @param bool $css + * @param int $type * @return string */ - protected function gatherLinks(array $assets, $css = true) + protected function gatherLinks(array $assets, int $type = self::CSS_ASSET): string { $buffer = ''; - foreach ($assets as $id => $asset) { + foreach ($assets as $asset) { $local = true; $link = $asset->getAsset(); @@ -100,21 +100,25 @@ protected function gatherLinks(array $assets, $css = true) } // Double check last character being - if (!$css) { + if ($type === self::CSS_ASSET) { $file = rtrim($file, ' ;') . ';'; } // If this is CSS + the file is local + rewrite enabled - if ($css && $this->css_rewrite) { + if ($type === self::CSS_ASSET && $this->css_rewrite) { $file = $this->cssRewrite($file, $relative_dir, $local); } + if ($type === self::JS_MODULE_ASSET) { + $file = $this->jsRewrite($file, $relative_dir, $local); + } + $file = rtrim($file) . PHP_EOL; $buffer .= $file; } // Pull out @imports and move to top - if ($css) { + if ($type === self::CSS_ASSET) { $buffer = $this->moveImports($buffer); } diff --git a/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php index ec00a6079..b11b439b6 100644 --- a/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php +++ b/system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php @@ -285,6 +285,15 @@ public function addDir($directory, $pattern = self::DEFAULT_REGEX) return $this; } + // Add JavaScript Module files + if ($pattern === self::JS_MODULE_REGEX) { + foreach ($files as $file) { + $this->addJsModule($file); + } + + return $this; + } + // Unknown pattern. foreach ($files as $asset) { $this->add($asset); diff --git a/system/src/Grav/Common/Grav.php b/system/src/Grav/Common/Grav.php index ca6112548..5519175e2 100644 --- a/system/src/Grav/Common/Grav.php +++ b/system/src/Grav/Common/Grav.php @@ -729,18 +729,36 @@ protected function registerServices(): void */ public function fallbackUrl($path) { - $this->fireEvent('onPageFallBackUrl'); - /** @var Uri $uri */ $uri = $this['uri']; /** @var Config $config */ $config = $this['config']; + $path_parts = pathinfo($path); + + /** @var Pages $pages */ + $pages = $this['pages']; + $page = $pages->find($path_parts['dirname'], true); + $uri_extension = strtolower($uri->extension() ?? ''); - $fallback_types = $config->get('system.media.allowed_fallback_types', null); + $fallback_types = $config->get('system.media.allowed_fallback_types'); $supported_types = $config->get('media.types'); + $parsed_url = parse_url(rawurldecode($uri->basename())); + $media_file = $parsed_url['path']; + + $event = new Event([ + 'uri' => $uri, + 'page' => &$page, + 'filename' => &$media_file, + 'extension' => $uri_extension, + 'allowed_fallback_types' => &$fallback_types, + 'media_types' => &$supported_types + ]); + + $this->fireEvent('onPageFallBackUrl', $event); + // Check whitelist first, then ensure extension is a valid media type if (!empty($fallback_types) && !in_array($uri_extension, $fallback_types, true)) { return false; @@ -749,16 +767,8 @@ public function fallbackUrl($path) return false; } - $path_parts = pathinfo($path); - - /** @var Pages $pages */ - $pages = $this['pages']; - $page = $pages->find($path_parts['dirname'], true); - if ($page) { $media = $page->media()->all(); - $parsed_url = parse_url(rawurldecode($uri->basename())); - $media_file = $parsed_url['path']; // if this is a media object, try actions first if (isset($media[$media_file])) { diff --git a/system/src/Grav/Common/Media/Interfaces/MediaObjectInterface.php b/system/src/Grav/Common/Media/Interfaces/MediaObjectInterface.php index c46dd38c7..4af3052b0 100644 --- a/system/src/Grav/Common/Media/Interfaces/MediaObjectInterface.php +++ b/system/src/Grav/Common/Media/Interfaces/MediaObjectInterface.php @@ -63,6 +63,14 @@ public function addMetaFile($filepath); */ public function addAlternative($ratio, MediaObjectInterface $alternative); + /** + * Get list of image alternatives. Includes the current media image as well. + * + * @param bool $withDerived If true, include generated images as well. If false, only return existing files. + * @return array + */ + public function getAlternatives(bool $withDerived = true): array; + /** * Return string representation of the object (html). * diff --git a/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php b/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php index 492b3af85..0949f1d50 100644 --- a/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php +++ b/system/src/Grav/Common/Media/Traits/MediaObjectTrait.php @@ -128,11 +128,29 @@ public function addAlternative($ratio, MediaObjectInterface $alternative) } $alternative->set('ratio', $ratio); - $width = $alternative->get('width'); + $width = $alternative->get('width', 0); $this->alternatives[$width] = $alternative; } + /** + * @param bool $withDerived + * @return array + */ + public function getAlternatives(bool $withDerived = true): array + { + $alternatives = []; + foreach ($this->alternatives + [$this->get('width', 0) => $this] as $size => $alternative) { + if ($withDerived || $alternative->filename === basename($alternative->filepath)) { + $alternatives[$size] = $alternative; + } + } + + ksort($alternatives, SORT_NUMERIC); + + return $alternatives; + } + /** * Return string representation of the object (html). * diff --git a/system/src/Grav/Common/Page/Media.php b/system/src/Grav/Common/Page/Media.php index 24bc2a6c3..6c0a6c390 100644 --- a/system/src/Grav/Common/Page/Media.php +++ b/system/src/Grav/Common/Page/Media.php @@ -60,6 +60,46 @@ public function __wakeup() } } + /** + * Return raw route to the page. + * + * @return string|null Route to the page or null if media isn't for a page. + */ + public function getRawRoute(): ?string + { + $path = $this->getPath(); + if ($path) { + /** @var Pages $pages */ + $pages = $this->getGrav()['pages']; + $page = $pages->get($path); + if ($page) { + return $page->rawRoute(); + } + } + + return null; + } + + /** + * Return page route. + * + * @return string|null Route to the page or null if media isn't for a page. + */ + public function getRoute(): ?string + { + $path = $this->getPath(); + if ($path) { + /** @var Pages $pages */ + $pages = $this->getGrav()['pages']; + $page = $pages->get($path); + if ($page) { + return $page->route(); + } + } + + return null; + } + /** * @param string $offset * @return bool diff --git a/system/src/Grav/Common/Page/Medium/Medium.php b/system/src/Grav/Common/Page/Medium/Medium.php index 1fe882e98..b65f7d0fa 100644 --- a/system/src/Grav/Common/Page/Medium/Medium.php +++ b/system/src/Grav/Common/Page/Medium/Medium.php @@ -23,6 +23,8 @@ * @package Grav\Common\Page\Medium * * @property string $filepath + * @property string $filename + * @property string $basename * @property string $mime * @property int $size * @property int $modified diff --git a/system/src/Grav/Common/Page/Medium/MediumFactory.php b/system/src/Grav/Common/Page/Medium/MediumFactory.php index cf25f22cf..6e9f820eb 100644 --- a/system/src/Grav/Common/Page/Medium/MediumFactory.php +++ b/system/src/Grav/Common/Page/Medium/MediumFactory.php @@ -193,7 +193,7 @@ public static function scaledFromMedium($medium, $from, $to) $height = $medium->get('height') * $ratio; $prev_basename = $medium->get('basename'); - $basename = str_replace('@'.$from.'x', '@'.$to.'x', $prev_basename); + $basename = str_replace('@' . $from . 'x', $to !== 1 ? '@' . $to . 'x' : '', $prev_basename); $debug = $medium->get('debug'); $medium->set('debug', false); @@ -208,6 +208,8 @@ public static function scaledFromMedium($medium, $from, $to) $medium = self::fromFile($file); if ($medium) { + $medium->set('basename', $basename); + $medium->set('filename', $basename . '.' . $medium->extension); $medium->set('size', $size); } diff --git a/system/src/Grav/Common/Page/Medium/StaticImageMedium.php b/system/src/Grav/Common/Page/Medium/StaticImageMedium.php index 5c5bf8c73..97801000a 100644 --- a/system/src/Grav/Common/Page/Medium/StaticImageMedium.php +++ b/system/src/Grav/Common/Page/Medium/StaticImageMedium.php @@ -37,4 +37,12 @@ protected function sourceParsedownElement(array $attributes, $reset = true) return ['name' => 'img', 'attributes' => $attributes]; } + + /** + * @return $this + */ + public function higherQualityAlternative() + { + return $this; + } } diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php index 72cd1ceae..9d76e1dd6 100644 --- a/system/src/Grav/Common/Utils.php +++ b/system/src/Grav/Common/Utils.php @@ -653,16 +653,17 @@ public static function uniqueId(int $length = 13, array $options = []): string * @param bool $force_download as opposed to letting browser choose if to download or render * @param int $sec Throttling, try 0.1 for some speed throttling of downloads * @param int $bytes Size of chunks to send in bytes. Default is 1024 + * @param array $options Extra options: [mime, download_name, expires] * @throws Exception */ - public static function download($file, $force_download = true, $sec = 0, $bytes = 1024) + public static function download($file, $force_download = true, $sec = 0, $bytes = 1024, array $options = []) { if (file_exists($file)) { // fire download event - Grav::instance()->fireEvent('onBeforeDownload', new Event(['file' => $file])); + Grav::instance()->fireEvent('onBeforeDownload', new Event(['file' => $file, 'options' => &$options])); $file_parts = pathinfo($file); - $mimetype = static::getMimeByExtension($file_parts['extension']); + $mimetype = $options['mime'] ?? static::getMimeByExtension($file_parts['extension']); $size = filesize($file); // File size // clean all buffers @@ -680,7 +681,7 @@ public static function download($file, $force_download = true, $sec = 0, $bytes if ($force_download) { // output the regular HTTP headers - header('Content-Disposition: attachment; filename="' . $file_parts['basename'] . '"'); + header('Content-Disposition: attachment; filename="' . ($options['download_name'] ?? $file_parts['basename']) . '"'); } // multipart-download and download resuming support @@ -704,7 +705,7 @@ public static function download($file, $force_download = true, $sec = 0, $bytes header('Content-Length: ' . $size); if (Grav::instance()['config']->get('system.cache.enabled')) { - $expires = Grav::instance()['config']->get('system.pages.expires'); + $expires = $options['expires'] ?? Grav::instance()['config']->get('system.pages.expires'); if ($expires > 0) { $expires_date = gmdate('D, d M Y H:i:s T', time() + $expires); header('Cache-Control: max-age=' . $expires); @@ -830,6 +831,31 @@ public static function getMimeTypes(array $extensions) return $mimetypes; } + /** + * Return all extensions for given mimetype. The first extension is the default one. + * + * @param string $mime Mime type (eg 'image/jpeg') + * @return string[] List of extensions eg. ['jpg', 'jpe', 'jpeg'] + */ + public static function getExtensionsByMime($mime) + { + $mime = strtolower($mime); + + $media_types = (array)Grav::instance()['config']->get('media.types'); + + $list = []; + foreach ($media_types as $extension => $type) { + if ($extension === '' || $extension === 'defaults') { + continue; + } + + if (isset($type['mime']) && $type['mime'] === $mime) { + $list[] = $extension; + } + } + + return $list; + } /** * Return the mimetype based on filename extension diff --git a/system/src/Grav/Console/Application/Application.php b/system/src/Grav/Console/Application/Application.php index 29c18f3f1..873b94e4f 100644 --- a/system/src/Grav/Console/Application/Application.php +++ b/system/src/Grav/Console/Application/Application.php @@ -10,11 +10,14 @@ namespace Grav\Console\Application; use Grav\Common\Grav; +use Symfony\Component\Console\ConsoleEvents; +use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\EventDispatcher\EventDispatcher; /** * Class GpmApplication @@ -29,19 +32,48 @@ class Application extends \Symfony\Component\Console\Application /** @var bool */ protected $initialized = false; + /** + * PluginApplication constructor. + * @param string $name + * @param string $version + */ + public function __construct(string $name = 'UNKNOWN', string $version = 'UNKNOWN') + { + parent::__construct($name, $version); + + // Add listener to prepare environment. + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(ConsoleEvents::COMMAND, [$this, 'prepareEnvironment']); + + $this->setDispatcher($dispatcher); + } + /** * @param InputInterface $input * @return string|null */ public function getCommandName(InputInterface $input): ?string { - $this->environment = $input->getOption('env'); - $this->language = $input->getOption('lang') ?? $this->language; + if ($input->hasParameterOption('--env', true)) { + $this->environment = $input->getParameterOption('--env'); + } + if ($input->hasParameterOption('--lang', true)) { + $this->language = $input->getParameterOption('--lang'); + } + $this->init(); return parent::getCommandName($input); } + /** + * @param ConsoleCommandEvent $event + * @return void + */ + public function prepareEnvironment(ConsoleCommandEvent $event): void + { + } + /** * @return void */ @@ -58,7 +90,7 @@ protected function init(): void } /** - * Add global a --env option. + * Add global --env and --lang options. * * @return InputDefinition */ @@ -67,16 +99,16 @@ protected function getDefaultInputDefinition(): InputDefinition $inputDefinition = parent::getDefaultInputDefinition(); $inputDefinition->addOption( new InputOption( - 'env', - null, + '--env', + '', InputOption::VALUE_OPTIONAL, 'Use environment configuration (defaults to localhost)' ) ); $inputDefinition->addOption( new InputOption( - 'lang', - null, + '--lang', + '', InputOption::VALUE_OPTIONAL, 'Language to be used (defaults to en)' ) diff --git a/system/src/Grav/Console/Cli/SchedulerCommand.php b/system/src/Grav/Console/Cli/SchedulerCommand.php index 8b8470a4c..91fc57d14 100644 --- a/system/src/Grav/Console/Cli/SchedulerCommand.php +++ b/system/src/Grav/Console/Cli/SchedulerCommand.php @@ -77,8 +77,6 @@ protected function serve(): int $scheduler = $grav['scheduler']; $grav->fireEvent('onSchedulerInitialized', new Event(['scheduler' => $scheduler])); - $this->setHelp('foo'); - $input = $this->getInput(); $io = $this->getIO(); $error = 0; diff --git a/user/config/system.yaml b/user/config/system.yaml index 7a8ffe58c..2e992778d 100644 --- a/user/config/system.yaml +++ b/user/config/system.yaml @@ -29,6 +29,7 @@ assets: css_minify: true css_rewrite: true js_pipeline: false + js_module_pipeline: false js_minify: true errors: