diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f5623fd1..3418ab67 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,10 +38,11 @@ updates: labels: - Dependencies # Actions are pulled by SHA, but a compromised release is still a risk — - # wait a week so takedowns/reverts can happen first. + # wait a week so takedowns/reverts can happen first. semver-major-days is + # not supported for the github-actions ecosystem, so only the default + # cooldown applies here. cooldown: default-days: 7 - semver-major-days: 14 groups: github-actions: patterns: diff --git a/src/Block/Inspector.php b/src/Block/Inspector.php index 475fbfca..7af90343 100644 --- a/src/Block/Inspector.php +++ b/src/Block/Inspector.php @@ -138,6 +138,46 @@ public function getPosition(): string return is_string($value) && $value !== '' ? $value : InspectorConfig::DEFAULT_POSITION; } + /** + * Whether keyboard shortcuts are enabled for toolbar and inspector + * + * @return bool + */ + public function getKeyboardShortcutsEnabled(): bool + { + $value = $this->scopeConfig->getValue( + InspectorConfig::XML_PATH_KEYBOARD_SHORTCUTS_ENABLED, + InspectorConfig::SCOPE_STORE, + ); + // Default to true when not explicitly set to '0' + return !is_string($value) || $value !== '0'; + } + + /** + * Get configured toolbar keyboard shortcut + * + * @return string + */ + public function getToolbarShortcut(): string + { + $value = $this->scopeConfig->getValue(InspectorConfig::XML_PATH_TOOLBAR_SHORTCUT, InspectorConfig::SCOPE_STORE); + return is_string($value) && $value !== '' ? $value : InspectorConfig::DEFAULT_TOOLBAR_SHORTCUT; + } + + /** + * Get configured inspector keyboard shortcut + * + * @return string + */ + public function getInspectorShortcut(): string + { + $value = $this->scopeConfig->getValue( + InspectorConfig::XML_PATH_INSPECTOR_SHORTCUT, + InspectorConfig::SCOPE_STORE, + ); + return is_string($value) && $value !== '' ? $value : InspectorConfig::DEFAULT_INSPECTOR_SHORTCUT; + } + /** * Render block HTML * diff --git a/src/Model/Config/Inspector.php b/src/Model/Config/Inspector.php index c6362c9d..6cd6bdff 100644 --- a/src/Model/Config/Inspector.php +++ b/src/Model/Config/Inspector.php @@ -10,8 +10,13 @@ class Inspector public const XML_PATH_SHOW_BUTTON_LABELS = 'mageforge/inspector/show_button_labels'; public const XML_PATH_THEME = 'mageforge/inspector/theme'; public const XML_PATH_POSITION = 'mageforge/inspector/position'; + public const XML_PATH_KEYBOARD_SHORTCUTS_ENABLED = 'mageforge/inspector/keyboard_shortcuts_enabled'; + public const XML_PATH_TOOLBAR_SHORTCUT = 'mageforge/inspector/toolbar_shortcut'; + public const XML_PATH_INSPECTOR_SHORTCUT = 'mageforge/inspector/inspector_shortcut'; public const DEFAULT_THEME = 'dark'; public const DEFAULT_POSITION = 'bottom-left'; + public const DEFAULT_TOOLBAR_SHORTCUT = 'Ctrl+Shift+A'; + public const DEFAULT_INSPECTOR_SHORTCUT = 'Ctrl+Shift+I'; /** * Store scope type. diff --git a/src/Model/Config/TemplateOverride.php b/src/Model/Config/TemplateOverride.php index 0022e43d..8a149b5f 100644 --- a/src/Model/Config/TemplateOverride.php +++ b/src/Model/Config/TemplateOverride.php @@ -8,6 +8,30 @@ class TemplateOverride { public const XML_PATH_ADD_HEADER = 'mageforge/template_override/add_header'; + // @mago-format-ignore-start + // Kept multi-line so PHPCS' 120-character limit is respected; Mago would re-fold them. + public const XML_PATH_SOURCE_HEADER_INCLUDE_DATE + = 'mageforge/template_override/source_header_include_date'; + public const XML_PATH_SOURCE_HEADER_INCLUDE_MODULE_VERSION + = 'mageforge/template_override/source_header_include_module_version'; + public const XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_PATH + = 'mageforge/template_override/source_header_include_source_path'; + public const XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_MODULE + = 'mageforge/template_override/source_header_include_source_module'; + public const XML_PATH_SOURCE_HEADER_INCLUDE_OVERRIDE_FOR + = 'mageforge/template_override/source_header_include_override_for'; + public const XML_PATH_SOURCE_HEADER_ENABLE_PHTML + = 'mageforge/template_override/source_header_enable_phtml'; + public const XML_PATH_SOURCE_HEADER_ENABLE_HTML + = 'mageforge/template_override/source_header_enable_html'; + public const XML_PATH_SOURCE_HEADER_ENABLE_XML + = 'mageforge/template_override/source_header_enable_xml'; + public const XML_PATH_SOURCE_HEADER_ENABLE_WEB_ASSETS + = 'mageforge/template_override/source_header_enable_web_assets'; + public const XML_PATH_SOURCE_HEADER_ENABLE_SHELL + = 'mageforge/template_override/source_header_enable_shell'; + // @mago-format-ignore-end + /** * Store scope type. * diff --git a/src/Service/TemplateOverride/TemplateCopier.php b/src/Service/TemplateOverride/TemplateCopier.php index 25f561af..870f1580 100644 --- a/src/Service/TemplateOverride/TemplateCopier.php +++ b/src/Service/TemplateOverride/TemplateCopier.php @@ -35,6 +35,104 @@ public function __construct( ) { } + /** + * Check whether the override date should be included in the source header + * + * @return bool + */ + private function shouldIncludeDateInHeader(): bool + { + return $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_DATE, + TemplateOverrideConfig::SCOPE_STORE, + ); + } + + /** + * Check whether the source module version should be included in the source header + * + * @return bool + */ + private function shouldIncludeModuleVersionInHeader(): bool + { + return $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_MODULE_VERSION, + TemplateOverrideConfig::SCOPE_STORE, + ); + } + + /** + * Check whether the relative source path should be included in the source header + * + * @return bool + */ + private function shouldIncludeSourcePathInHeader(): bool + { + return $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_PATH, + TemplateOverrideConfig::SCOPE_STORE, + ); + } + + /** + * Check whether the source module name should be included in the source header + * + * @return bool + */ + private function shouldIncludeSourceModuleInHeader(): bool + { + return $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_MODULE, + TemplateOverrideConfig::SCOPE_STORE, + ); + } + + /** + * Check whether the logical override target should be included in the source header + * + * @return bool + */ + private function shouldIncludeOverrideForInHeader(): bool + { + return $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_OVERRIDE_FOR, + TemplateOverrideConfig::SCOPE_STORE, + ); + } + + /** + * Check whether source headers are enabled for the given file type + * + * @param string $filePath + * @return bool + */ + private function isHeaderEnabledForFile(string $filePath): bool + { + return match ($this->extension($filePath)) { + 'phtml', 'php' => $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_PHTML, + TemplateOverrideConfig::SCOPE_STORE, + ), + 'html', 'htm' => $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_HTML, + TemplateOverrideConfig::SCOPE_STORE, + ), + 'xml', 'xhtml', 'svg' => $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_XML, + TemplateOverrideConfig::SCOPE_STORE, + ), + 'css', 'js', 'less', 'scss', 'sass', 'ts' => $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_WEB_ASSETS, + TemplateOverrideConfig::SCOPE_STORE, + ), + 'sh', 'bash', 'zsh', 'fish' => $this->scopeConfig->isSetFlag( + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_SHELL, + TemplateOverrideConfig::SCOPE_STORE, + ), + default => false, + }; + } + /** * Copy the source template to the target location * @@ -52,7 +150,7 @@ public function copy(string $sourceFile, string $targetFile, ?string $sourceModu } $commentStyle = $this->commentStyle->fromFilePath($targetFile); - if ($commentStyle->isSupported() && $this->shouldAddHeader()) { + if ($commentStyle->isSupported() && $this->shouldAddHeader() && $this->isHeaderEnabledForFile($targetFile)) { $this->copyWithHeader($sourceFile, $targetFile, $sourceModuleName, $commentStyle); return; } @@ -120,23 +218,34 @@ private function copyWithHeader( */ private function buildHeaderLines(string $sourceFile, ?string $sourceModuleName): array { - $date = date('Y-m-d'); $lines = [ - 'MageForge Template Override from ' . $date, - 'Source: ' . $this->toRelativePath($sourceFile), + 'MageForge Template Override', ]; + if ($this->shouldIncludeDateInHeader()) { + $lines[] = 'Date: ' . date('Y-m-d'); + } + + if ($this->shouldIncludeSourcePathInHeader()) { + $lines[] = 'Source: ' . $this->toRelativePath($sourceFile); + } + $actualSourceModule = $this->resolveSourceModule($sourceFile); + $includeSourceModule = $this->shouldIncludeSourceModuleInHeader(); - if ($actualSourceModule !== null) { + if ($actualSourceModule !== null && $includeSourceModule) { $lines[] = 'Source Module: ' . $actualSourceModule; - $version = $this->packageInfo->getVersion($actualSourceModule); + $version = $this->shouldIncludeModuleVersionInHeader() + ? $this->packageInfo->getVersion($actualSourceModule) + : ''; if ($version !== '') { $lines[] = 'Source Module-Version: ' . $version; } - } elseif ($sourceModuleName !== null && $sourceModuleName !== '') { + } elseif ($sourceModuleName !== null && $sourceModuleName !== '' && $includeSourceModule) { $lines[] = 'Override For: ' . $sourceModuleName; - $version = $this->packageInfo->getVersion($sourceModuleName); + $version = $this->shouldIncludeModuleVersionInHeader() + ? $this->packageInfo->getVersion($sourceModuleName) + : ''; if ($version !== '') { $lines[] = 'Module-Version: ' . $version; } @@ -158,36 +267,62 @@ private function buildHeaderLines(string $sourceFile, ?string $sourceModuleName) */ private function buildPhpDocHeaderLines(string $sourceFile, ?string $sourceModuleName): array { - $date = date('Y-m-d'); $lines = [ '@mageforge-template-override', - '@date ' . $date, - '@source ' . $this->toRelativePath($sourceFile), ]; + if ($this->shouldIncludeDateInHeader()) { + $lines[] = '@date ' . date('Y-m-d'); + } + + if ($this->shouldIncludeSourcePathInHeader()) { + $lines[] = '@source ' . $this->toRelativePath($sourceFile); + } + $actualSourceModule = $this->resolveSourceModule($sourceFile); + $includeSourceModule = $this->shouldIncludeSourceModuleInHeader(); - if ($actualSourceModule !== null) { + if ($actualSourceModule !== null && $includeSourceModule) { $lines[] = '@module ' . $actualSourceModule; - $version = $this->packageInfo->getVersion($actualSourceModule); + $version = $this->shouldIncludeModuleVersionInHeader() + ? $this->packageInfo->getVersion($actualSourceModule) + : ''; if ($version !== '') { $lines[] = '@module-version ' . $version; } - } elseif ($sourceModuleName !== null && $sourceModuleName !== '') { + } elseif ($sourceModuleName !== null && $sourceModuleName !== '' && $includeSourceModule) { $lines[] = '@module ' . $sourceModuleName; - $version = $this->packageInfo->getVersion($sourceModuleName); + $version = $this->shouldIncludeModuleVersionInHeader() + ? $this->packageInfo->getVersion($sourceModuleName) + : ''; if ($version !== '') { $lines[] = '@module-version ' . $version; } } - if ($this->isOverrideForDifferentModule($actualSourceModule, $sourceModuleName)) { + $includeOverrideFor = + $this->shouldIncludeOverrideForInHeader() + && $this->isOverrideForDifferentModule($actualSourceModule, $sourceModuleName); + if ($includeOverrideFor) { $lines[] = '@override-for ' . (string) $sourceModuleName; } return $lines; } + /** + * Extract the lower-cased file extension from a path + * + * @param string $filePath + * @return string + */ + private function extension(string $filePath): string + { + $lastDot = strrpos($filePath, '.'); + + return $lastDot === false ? '' : strtolower(substr($filePath, $lastDot + 1)); + } + /** * Check whether the source file belongs to a different module than the logical override target * diff --git a/src/etc/adminhtml/system.xml b/src/etc/adminhtml/system.xml index 02c5fada..efe30986 100644 --- a/src/etc/adminhtml/system.xml +++ b/src/etc/adminhtml/system.xml @@ -35,6 +35,25 @@ Position of the MageForge Toolbar on the page. Default: Bottom Left. + + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/inspector/keyboard_shortcuts_enabled + Master switch for the MageForge keyboard shortcuts. Default: Yes. + + + + mageforge/inspector/toolbar_shortcut + Shortcut to toggle all toolbar audits. Use "none" to disable only this shortcut. Examples: Ctrl+Shift+A, Shift+F8, F12, Cmd+Option+S. Default: Ctrl+Shift+A. + + + + mageforge/inspector/inspector_shortcut + Shortcut to toggle the element inspector. Use "none" to disable only this shortcut. Examples: Ctrl+Shift+I, Shift+F8, F12. Default: Ctrl+Shift+I. + +
@@ -46,7 +65,70 @@ Magento\Config\Model\Config\Source\Yesno mageforge/template_override/add_header - When enabled, a comment header with the source path and module version is prepended to every copied override file. + When enabled, an information header is prepended to every copied override file. Use the Source Headers options below to control which details are included and for which file types. + + + + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_include_date + When enabled, the override date is included in the source header of every copied override file. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_include_module_version + When enabled, the source module version is included in the source header of every copied override file. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_include_source_path + When enabled, the relative source path is included in the source header of every copied override file. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_include_source_module + When enabled, the source module name is included in the source header of every copied override file. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_include_override_for + When enabled, the logical override target module is included in the source header when it differs from the source module. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_enable_phtml + When enabled, source headers are prepended to copied PHP and PHTML files. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_enable_html + When enabled, source headers are prepended to copied HTML files. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_enable_xml + When enabled, source headers are prepended to copied XML, XHTML and SVG files. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_enable_web_assets + When enabled, source headers are prepended to copied CSS, JavaScript, TypeScript, LESS and SCSS files. Default: Yes. + + + + Magento\Config\Model\Config\Source\Yesno + mageforge/template_override/source_header_enable_shell + When enabled, source headers are prepended to copied shell scripts. Default: Yes.
diff --git a/src/etc/config.xml b/src/etc/config.xml index 592bbcb6..34572bc1 100644 --- a/src/etc/config.xml +++ b/src/etc/config.xml @@ -12,9 +12,22 @@ dark 1 bottom-left + 1 + Ctrl+Shift+A + Ctrl+Shift+I 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 diff --git a/src/i18n/de_DE.csv b/src/i18n/de_DE.csv index 6ee86af4..83fd5035 100644 --- a/src/i18n/de_DE.csv +++ b/src/i18n/de_DE.csv @@ -9,6 +9,13 @@ "Show text labels on the Toolbar and Inspector buttons. Default: Yes.","Textbeschriftungen auf Toolbar- und Inspector-Schaltflächen anzeigen. Standard: Ja." "Toolbar Position","Toolbar-Position" "Position of the MageForge Toolbar on the page. Default: Bottom Left.","Position der MageForge Toolbar auf der Seite. Standard: Unten links." +"Keyboard Shortcuts","Tastenkürzel" +"Enable Keyboard Shortcuts","Tastenkürzel aktivieren" +"Master switch for the MageForge keyboard shortcuts. Default: Yes.","Hauptschalter für die MageForge-Tastenkürzel. Standard: Ja." +"Toolbar Shortcut","Toolbar-Tastenkürzel" +"Shortcut to toggle all toolbar audits. Use \"none\" to disable only this shortcut. Examples: Ctrl+Shift+A, Shift+F8, F12, Cmd+Option+S. Default: Ctrl+Shift+A.","Tastenkürzel zum Umschalten aller Toolbar-Audits. Verwende \"none\", um nur dieses Kürzel zu deaktivieren. Beispiele: Strg+Shift+A, Shift+F8, F12, Cmd+Option+S. Standard: Strg+Shift+A." +"Inspector Shortcut","Inspector-Tastenkürzel" +"Shortcut to toggle the element inspector. Use \"none\" to disable only this shortcut. Examples: Ctrl+Shift+I, Shift+F8, F12. Default: Ctrl+Shift+I.","Tastenkürzel zum Umschalten des Element-Inspectors. Verwende \"none\", um nur dieses Kürzel zu deaktivieren. Beispiele: Strg+Shift+I, Shift+F8, F12. Standard: Strg+Shift+I." "Dark","Dunkel" "Light","Hell" "Auto (System Preference)","Auto (Systemeinstellung)" @@ -19,4 +26,25 @@ "Template Override","Template-Override" "Template Override Settings","Template-Override-Einstellungen" "Add Source Header","Quellen-Header hinzufügen" -"When enabled, a comment header with the source path and module version is prepended to every copied override file.","Wenn aktiviert, wird jeder kopierte Override-Datei ein Kommentar-Header mit Quellpfad und Modulversion vorangestellt." +"When enabled, an information header is prepended to every copied override file. Use the Source Headers options below to control which details are included and for which file types.","Wenn aktiviert, wird jeder kopierte Override-Datei ein Informations-Header vorangestellt. Über die untenstehenden Source-Headers-Optionen lässt sich steuern, welche Details für welche Dateitypen enthalten sind." +"Source Headers","Quellen-Header" +"Include Date in Header","Datum im Header anzeigen" +"When enabled, the override date is included in the source header of every copied override file. Default: Yes.","Wenn aktiviert, wird das Override-Datum im Quellen-Header jeder kopierten Override-Datei angezeigt. Standard: Ja." +"Include Module Version in Header","Modulversion im Header anzeigen" +"When enabled, the source module version is included in the source header of every copied override file. Default: Yes.","Wenn aktiviert, wird die Quellmodulversion im Quellen-Header jeder kopierten Override-Datei angezeigt. Standard: Ja." +"Include Source Path in Header","Quellpfad im Header anzeigen" +"When enabled, the relative source path is included in the source header of every copied override file. Default: Yes.","Wenn aktiviert, wird der relative Quellpfad im Quellen-Header jeder kopierten Override-Datei angezeigt. Standard: Ja." +"Include Source Module in Header","Quellmodul im Header anzeigen" +"When enabled, the source module name is included in the source header of every copied override file. Default: Yes.","Wenn aktiviert, wird der Name des Quellmoduls im Quellen-Header jeder kopierten Override-Datei angezeigt. Standard: Ja." +"Include Override Target in Header","Override-Ziel im Header anzeigen" +"When enabled, the logical override target module is included in the source header when it differs from the source module. Default: Yes.","Wenn aktiviert, wird das logische Override-Zielmodul im Quellen-Header angezeigt, wenn es vom Quellmodul abweicht. Standard: Ja." +"Enable for PHP/PHTML","Für PHP/PHTML aktivieren" +"When enabled, source headers are prepended to copied PHP and PHTML files. Default: Yes.","Wenn aktiviert, werden Quellen-Header kopierten PHP- und PHTML-Dateien vorangestellt. Standard: Ja." +"Enable for HTML","Für HTML aktivieren" +"When enabled, source headers are prepended to copied HTML files. Default: Yes.","Wenn aktiviert, werden Quellen-Header kopierten HTML-Dateien vorangestellt. Standard: Ja." +"Enable for XML/SVG","Für XML/SVG aktivieren" +"When enabled, source headers are prepended to copied XML, XHTML and SVG files. Default: Yes.","Wenn aktiviert, werden Quellen-Header kopierten XML-, XHTML- und SVG-Dateien vorangestellt. Standard: Ja." +"Enable for CSS/JS/TS/LESS/SCSS","Für CSS/JS/TS/LESS/SCSS aktivieren" +"When enabled, source headers are prepended to copied CSS, JavaScript, TypeScript, LESS and SCSS files. Default: Yes.","Wenn aktiviert, werden Quellen-Header kopierten CSS-, JavaScript-, TypeScript-, LESS- und SCSS-Dateien vorangestellt. Standard: Ja." +"Enable for Shell Scripts","Für Shell-Skripte aktivieren" +"When enabled, source headers are prepended to copied shell scripts. Default: Yes.","Wenn aktiviert, werden Quellen-Header kopierten Shell-Skripten vorangestellt. Standard: Ja." diff --git a/src/i18n/en_US.csv b/src/i18n/en_US.csv index c30357d5..7efb55ef 100644 --- a/src/i18n/en_US.csv +++ b/src/i18n/en_US.csv @@ -9,6 +9,13 @@ "Show text labels on the Toolbar and Inspector buttons. Default: Yes.","Show text labels on the Toolbar and Inspector buttons. Default: Yes." "Toolbar Position","Toolbar Position" "Position of the MageForge Toolbar on the page. Default: Bottom Left.","Position of the MageForge Toolbar on the page. Default: Bottom Left." +"Keyboard Shortcuts","Keyboard Shortcuts" +"Enable Keyboard Shortcuts","Enable Keyboard Shortcuts" +"Master switch for the MageForge keyboard shortcuts. Default: Yes.","Master switch for the MageForge keyboard shortcuts. Default: Yes." +"Toolbar Shortcut","Toolbar Shortcut" +"Shortcut to toggle all toolbar audits. Use \"none\" to disable only this shortcut. Examples: Ctrl+Shift+A, Shift+F8, F12, Cmd+Option+S. Default: Ctrl+Shift+A.","Shortcut to toggle all toolbar audits. Use \"none\" to disable only this shortcut. Examples: Ctrl+Shift+A, Shift+F8, F12, Cmd+Option+S. Default: Ctrl+Shift+A." +"Inspector Shortcut","Inspector Shortcut" +"Shortcut to toggle the element inspector. Use \"none\" to disable only this shortcut. Examples: Ctrl+Shift+I, Shift+F8, F12. Default: Ctrl+Shift+I.","Shortcut to toggle the element inspector. Use \"none\" to disable only this shortcut. Examples: Ctrl+Shift+I, Shift+F8, F12. Default: Ctrl+Shift+I." "Dark","Dark" "Light","Light" "Auto (System Preference)","Auto (System Preference)" @@ -19,4 +26,25 @@ "Template Override","Template Override" "Template Override Settings","Template Override Settings" "Add Source Header","Add Source Header" -"When enabled, a comment header with the source path and module version is prepended to every copied override file.","When enabled, a comment header with the source path and module version is prepended to every copied override file." +"When enabled, an information header is prepended to every copied override file. Use the Source Headers options below to control which details are included and for which file types.","When enabled, an information header is prepended to every copied override file. Use the Source Headers options below to control which details are included and for which file types." +"Source Headers","Source Headers" +"Include Date in Header","Include Date in Header" +"When enabled, the override date is included in the source header of every copied override file. Default: Yes.","When enabled, the override date is included in the source header of every copied override file. Default: Yes." +"Include Module Version in Header","Include Module Version in Header" +"When enabled, the source module version is included in the source header of every copied override file. Default: Yes.","When enabled, the source module version is included in the source header of every copied override file. Default: Yes." +"Include Source Path in Header","Include Source Path in Header" +"When enabled, the relative source path is included in the source header of every copied override file. Default: Yes.","When enabled, the relative source path is included in the source header of every copied override file. Default: Yes." +"Include Source Module in Header","Include Source Module in Header" +"When enabled, the source module name is included in the source header of every copied override file. Default: Yes.","When enabled, the source module name is included in the source header of every copied override file. Default: Yes." +"Include Override Target in Header","Include Override Target in Header" +"When enabled, the logical override target module is included in the source header when it differs from the source module. Default: Yes.","When enabled, the logical override target module is included in the source header when it differs from the source module. Default: Yes." +"Enable for PHP/PHTML","Enable for PHP/PHTML" +"When enabled, source headers are prepended to copied PHP and PHTML files. Default: Yes.","When enabled, source headers are prepended to copied PHP and PHTML files. Default: Yes." +"Enable for HTML","Enable for HTML" +"When enabled, source headers are prepended to copied HTML files. Default: Yes.","When enabled, source headers are prepended to copied HTML files. Default: Yes." +"Enable for XML/SVG","Enable for XML/SVG" +"When enabled, source headers are prepended to copied XML, XHTML and SVG files. Default: Yes.","When enabled, source headers are prepended to copied XML, XHTML and SVG files. Default: Yes." +"Enable for CSS/JS/TS/LESS/SCSS","Enable for CSS/JS/TS/LESS/SCSS" +"When enabled, source headers are prepended to copied CSS, JavaScript, TypeScript, LESS and SCSS files. Default: Yes.","When enabled, source headers are prepended to copied CSS, JavaScript, TypeScript, LESS and SCSS files. Default: Yes." +"Enable for Shell Scripts","Enable for Shell Scripts" +"When enabled, source headers are prepended to copied shell scripts. Default: Yes.","When enabled, source headers are prepended to copied shell scripts. Default: Yes." diff --git a/src/view/frontend/templates/inspector.phtml b/src/view/frontend/templates/inspector.phtml index c0dc11f0..f7906bbc 100644 --- a/src/view/frontend/templates/inspector.phtml +++ b/src/view/frontend/templates/inspector.phtml @@ -72,9 +72,13 @@ $alpineBootstrap = << + data-show-labels="getShowButtonLabels() ?>" + data-keyboard-shortcuts-enabled="getKeyboardShortcutsEnabled() ?>" + data-shortcut="escapeHtmlAttr($block->getToolbarShortcut()) ?>">
+ data-theme="escapeHtmlAttr($block->getTheme()) ?>" + data-keyboard-shortcuts-enabled="getKeyboardShortcutsEnabled() ?>" + data-shortcut="escapeHtmlAttr($block->getInspectorShortcut()) ?>"> diff --git a/src/view/frontend/web/js/inspector.js b/src/view/frontend/web/js/inspector.js index f1411ba7..2da85d1a 100644 --- a/src/view/frontend/web/js/inspector.js +++ b/src/view/frontend/web/js/inspector.js @@ -136,7 +136,7 @@ function _registerMageforgeInspector() { // Use div instead of button to avoid Luma/theme button CSS overrides const btn = document.createElement("div"); btn.className = "mageforge-inspector-float-button"; - btn.title = "Activate Inspector (Ctrl+Shift+I)"; + btn.title = "Activate Inspector"; btn.setAttribute("role", "button"); btn.setAttribute("tabindex", "0"); btn.setAttribute("aria-pressed", "false"); diff --git a/src/view/frontend/web/js/inspector/picker.js b/src/view/frontend/web/js/inspector/picker.js index 188bec00..290b56aa 100644 --- a/src/view/frontend/web/js/inspector/picker.js +++ b/src/view/frontend/web/js/inspector/picker.js @@ -2,6 +2,7 @@ * MageForge Inspector - Keyboard Shortcuts, Inspector Toggle & Element Picker */ +import { matchesShortcut } from "../shortcut-parser.js"; import { blockDataMap } from "./blockData.js"; export const pickerMethods = { @@ -9,9 +10,13 @@ export const pickerMethods = { * Setup keyboard shortcuts */ setupKeyboardShortcuts() { + this.keyboardShortcutsEnabled = + this.$el?.getAttribute("data-keyboard-shortcuts-enabled") !== "0"; + this.shortcut = this.$el?.getAttribute("data-shortcut") || "Ctrl+Shift+I"; + this.keydownHandler = (e) => { - // Ctrl+Shift+I or Cmd+Option+I - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "I") { + // Configured inspector shortcut (default: Ctrl/Cmd+Shift+I) + if (this.keyboardShortcutsEnabled && matchesShortcut(e, this.shortcut)) { e.preventDefault(); this.toggleInspector(); } diff --git a/src/view/frontend/web/js/shortcut-parser.js b/src/view/frontend/web/js/shortcut-parser.js new file mode 100644 index 00000000..fcabc8e0 --- /dev/null +++ b/src/view/frontend/web/js/shortcut-parser.js @@ -0,0 +1,82 @@ +/** + * MageForge Shortcut Parser + * + * Parses human-readable shortcut strings (e.g. "Ctrl+Shift+A", "Shift+F8", + * "F12", "none") and matches them against a native KeyboardEvent. + * + * Supported modifiers: Ctrl, Cmd, Meta, Shift, Alt, Option. + * "Cmd" and "Meta" are treated as equivalent to "Ctrl" because browsers map + * Cmd on macOS to metaKey and Ctrl on Windows/Linux to ctrlKey. + */ + +/** + * @typedef {object} ParsedShortcut + * @property {string} key + * @property {boolean} ctrlOrMeta + * @property {boolean} shift + * @property {boolean} alt + */ + +/** + * Memoised parse results keyed by the normalised shortcut string, so global + * keydown handlers do not re-split and re-lowercase the same shortcut on + * every keypress. + * + * @type {Map} + */ +const parsedShortcutCache = new Map(); + +/** + * Parse a shortcut string into its components. + * + * Results are memoised by normalised shortcut string. + * + * @param {string} shortcut + * @returns {ParsedShortcut|null} Null when shortcut is "none" or empty. + */ +export function parseShortcut(shortcut) { + const normalised = (shortcut || "").trim().toLowerCase(); + + if (parsedShortcutCache.has(normalised)) { + return parsedShortcutCache.get(normalised); + } + + let parsed = null; + if (normalised !== "" && normalised !== "none") { + const parts = normalised.split("+").map((part) => part.trim()); + const key = parts.pop() || ""; + + parsed = { + key, + ctrlOrMeta: + parts.includes("ctrl") || + parts.includes("cmd") || + parts.includes("meta"), + shift: parts.includes("shift"), + alt: parts.includes("alt") || parts.includes("option"), + }; + } + + parsedShortcutCache.set(normalised, parsed); + + return parsed; +} + +/** + * Check whether a keyboard event matches the configured shortcut. + * + * @param {KeyboardEvent} event + * @param {string} shortcut + * @returns {boolean} + */ +export function matchesShortcut(event, shortcut) { + const parsed = parseShortcut(shortcut); + if (!parsed) return false; + + return ( + event.key.toLowerCase() === parsed.key && + (event.ctrlKey || event.metaKey) === parsed.ctrlOrMeta && + event.shiftKey === parsed.shift && + event.altKey === parsed.alt + ); +} diff --git a/src/view/frontend/web/js/toolbar.js b/src/view/frontend/web/js/toolbar.js index c4655126..4044791c 100644 --- a/src/view/frontend/web/js/toolbar.js +++ b/src/view/frontend/web/js/toolbar.js @@ -2,6 +2,7 @@ * MageForge Toolbar - Standalone audit toolbar. */ +import { matchesShortcut } from "./shortcut-parser.js"; import { uiMethods } from "./toolbar/ui.js"; import { auditMethods } from "./toolbar/audits.js"; @@ -39,6 +40,12 @@ function _registerMageforgeToolbar() { /** @type {Function|null} Global keydown handler for keyboard shortcuts */ _keyboardShortcutHandler: null, + /** @type {boolean} Whether keyboard shortcuts are enabled */ + keyboardShortcutsEnabled: true, + + /** @type {string} Configured keyboard shortcut for toggling all audits */ + shortcut: "Ctrl+Shift+A", + /** @type {Map} In-memory audit badge status (avoids DOM reads in score calc) */ _auditStatus: new Map(), @@ -50,10 +57,14 @@ function _registerMageforgeToolbar() { this.createToolbar(); this.currentTheme = this.$el?.getAttribute("data-theme") || "dark"; this.setTheme(this.currentTheme); + this.keyboardShortcutsEnabled = + this.$el?.getAttribute("data-keyboard-shortcuts-enabled") !== "0"; + this.shortcut = this.$el?.getAttribute("data-shortcut") || "Ctrl+Shift+A"; - // Global keyboard shortcut: Ctrl/Cmd+Shift+A → toggle all audits + // Global keyboard shortcut for toggling all audits this._keyboardShortcutHandler = (e) => { - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "A") { + if (!this.keyboardShortcutsEnabled) return; + if (matchesShortcut(e, this.shortcut)) { e.preventDefault(); this.toggleAllAudits(); } diff --git a/tests/Unit/Block/InspectorTest.php b/tests/Unit/Block/InspectorTest.php index 2678d623..f8743588 100644 --- a/tests/Unit/Block/InspectorTest.php +++ b/tests/Unit/Block/InspectorTest.php @@ -54,6 +54,9 @@ protected function setUp(): void public function testShouldRenderReturnsFalseWhenNotInDeveloperMode(): void { $this->state->method('getMode')->willReturn(State::MODE_PRODUCTION); + // Every later gate passes, so only the developer-mode check can make this return false + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->developerAccessChecker->method('isDevAllowed')->willReturn(true); $this->assertFalse($this->block->shouldRender()); } @@ -62,6 +65,8 @@ public function testShouldRenderReturnsFalseWhenInspectorDisabled(): void { $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); $this->scopeConfig->method('isSetFlag')->willReturn(false); + // The IP gate passes, so only the disabled config flag can make this return false + $this->developerAccessChecker->method('isDevAllowed')->willReturn(true); $this->assertFalse($this->block->shouldRender()); } @@ -143,4 +148,57 @@ public function testGetPositionReturnsDefaultWhenEmpty(): void $this->assertSame(InspectorConfig::DEFAULT_POSITION, $this->block->getPosition()); } + + public function testGetKeyboardShortcutsEnabledDefaultsToTrue(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + + $this->assertTrue($this->block->getKeyboardShortcutsEnabled()); + } + + public function testGetKeyboardShortcutsEnabledReturnsFalseWhenExplicitlyDisabled(): void + { + $this->scopeConfig->method('getValue')->willReturn('0'); + + $this->assertFalse($this->block->getKeyboardShortcutsEnabled()); + } + + public function testGetKeyboardShortcutsEnabledReturnsTrueForOtherValues(): void + { + $this->scopeConfig->method('getValue')->willReturn('1'); + + $this->assertTrue($this->block->getKeyboardShortcutsEnabled()); + } + + public function testGetToolbarShortcutReturnsConfiguredValue(): void + { + $this->scopeConfig->method('getValue') + ->with(InspectorConfig::XML_PATH_TOOLBAR_SHORTCUT, InspectorConfig::SCOPE_STORE) + ->willReturn('Shift+F8'); + + $this->assertSame('Shift+F8', $this->block->getToolbarShortcut()); + } + + public function testGetToolbarShortcutReturnsDefaultWhenEmpty(): void + { + $this->scopeConfig->method('getValue')->willReturn(''); + + $this->assertSame(InspectorConfig::DEFAULT_TOOLBAR_SHORTCUT, $this->block->getToolbarShortcut()); + } + + public function testGetInspectorShortcutReturnsConfiguredValue(): void + { + $this->scopeConfig->method('getValue') + ->with(InspectorConfig::XML_PATH_INSPECTOR_SHORTCUT, InspectorConfig::SCOPE_STORE) + ->willReturn('F12'); + + $this->assertSame('F12', $this->block->getInspectorShortcut()); + } + + public function testGetInspectorShortcutReturnsDefaultWhenEmpty(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + + $this->assertSame(InspectorConfig::DEFAULT_INSPECTOR_SHORTCUT, $this->block->getInspectorShortcut()); + } } diff --git a/tests/Unit/Model/Config/InspectorTest.php b/tests/Unit/Model/Config/InspectorTest.php index 16662233..25894ace 100644 --- a/tests/Unit/Model/Config/InspectorTest.php +++ b/tests/Unit/Model/Config/InspectorTest.php @@ -15,11 +15,22 @@ public function testConfigPathConstants(): void $this->assertSame('mageforge/inspector/show_button_labels', Inspector::XML_PATH_SHOW_BUTTON_LABELS); $this->assertSame('mageforge/inspector/theme', Inspector::XML_PATH_THEME); $this->assertSame('mageforge/inspector/position', Inspector::XML_PATH_POSITION); + $this->assertSame( + 'mageforge/inspector/keyboard_shortcuts_enabled', + Inspector::XML_PATH_KEYBOARD_SHORTCUTS_ENABLED, + ); + $this->assertSame('mageforge/inspector/toolbar_shortcut', Inspector::XML_PATH_TOOLBAR_SHORTCUT); + $this->assertSame( + 'mageforge/inspector/inspector_shortcut', + Inspector::XML_PATH_INSPECTOR_SHORTCUT, + ); } public function testDefaultValueConstants(): void { $this->assertSame('dark', Inspector::DEFAULT_THEME); $this->assertSame('bottom-left', Inspector::DEFAULT_POSITION); + $this->assertSame('Ctrl+Shift+A', Inspector::DEFAULT_TOOLBAR_SHORTCUT); + $this->assertSame('Ctrl+Shift+I', Inspector::DEFAULT_INSPECTOR_SHORTCUT); } } diff --git a/tests/Unit/Service/TemplateOverride/TemplateCopierTest.php b/tests/Unit/Service/TemplateOverride/TemplateCopierTest.php index de0b7fc1..2195f0ac 100644 --- a/tests/Unit/Service/TemplateOverride/TemplateCopierTest.php +++ b/tests/Unit/Service/TemplateOverride/TemplateCopierTest.php @@ -113,10 +113,7 @@ public function testDoesNotCreateExistingTargetDirectory(): void public function testAddsHeaderWhenEnabled(): void { - $this->scopeConfig - ->method('isSetFlag') - ->with(TemplateOverrideConfig::XML_PATH_ADD_HEADER, TemplateOverrideConfig::SCOPE_STORE) - ->willReturn(true); + $this->scopeConfig->method('isSetFlag')->willReturn(true); $this->registerModulePaths([ 'Magento_Catalog' => '/module', ]); @@ -381,4 +378,580 @@ public function testUsesHtmlCommentForEmailTemplates(): void $this->assertStringStartsWith('\n\n

order

", $captured ?? ''); } + + public function testPhtmlHeaderExcludesDateWhenDisabled(): void + { + $this->scopeConfig + ->method('isSetFlag') + ->willReturnCallback(static fn(string $path): bool => match ($path) { + TemplateOverrideConfig::XML_PATH_ADD_HEADER, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_PHTML, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_PATH, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_MODULE, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_MODULE_VERSION => true, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_DATE => false, + default => false, + }); + $this->registerModulePaths([ + 'Vendor_Module' => '/magento/vendor/module', + ]); + $this->packageInfo->method('getVersion')->with('Vendor_Module')->willReturn('1.2.3'); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('
content
'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + '/magento/vendor/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + 'Vendor_Module', + ); + + $this->assertStringContainsString('@mageforge-template-override', $captured ?? ''); + $this->assertStringContainsString('@module-version 1.2.3', $captured ?? ''); + $this->assertStringNotContainsString('@date ', $captured ?? ''); + } + + public function testPhtmlHeaderExcludesModuleVersionWhenDisabled(): void + { + $this->scopeConfig + ->method('isSetFlag') + ->willReturnCallback(static fn(string $path): bool => match ($path) { + TemplateOverrideConfig::XML_PATH_ADD_HEADER, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_PHTML, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_PATH, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_MODULE, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_DATE => true, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_MODULE_VERSION => false, + default => false, + }); + $this->registerModulePaths([ + 'Vendor_Module' => '/magento/vendor/module', + ]); + $this->packageInfo->method('getVersion')->with('Vendor_Module')->willReturn('1.2.3'); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('
content
'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + '/magento/vendor/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + 'Vendor_Module', + ); + + $this->assertStringContainsString('@date ' . date('Y-m-d'), $captured ?? ''); + $this->assertStringContainsString('@module Vendor_Module', $captured ?? ''); + $this->assertStringNotContainsString('@module-version', $captured ?? ''); + } + + public function testPhtmlHeaderExcludesSourcePathWhenDisabled(): void + { + $this->scopeConfig + ->method('isSetFlag') + ->willReturnCallback(static fn(string $path): bool => match ($path) { + TemplateOverrideConfig::XML_PATH_ADD_HEADER, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_PHTML, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_DATE, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_MODULE, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_MODULE_VERSION => true, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_PATH => false, + default => false, + }); + $this->registerModulePaths([ + 'Vendor_Module' => '/magento/vendor/module', + ]); + $this->packageInfo->method('getVersion')->with('Vendor_Module')->willReturn('1.2.3'); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('
content
'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + '/magento/vendor/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + 'Vendor_Module', + ); + + $this->assertStringContainsString('@module Vendor_Module', $captured ?? ''); + $this->assertStringContainsString('@module-version 1.2.3', $captured ?? ''); + $this->assertStringNotContainsString('@source ', $captured ?? ''); + } + + public function testPhtmlHeaderExcludesOverrideForWhenDisabled(): void + { + $this->scopeConfig + ->method('isSetFlag') + ->willReturnCallback(static fn(string $path): bool => match ($path) { + TemplateOverrideConfig::XML_PATH_ADD_HEADER, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_PHTML, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_DATE, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_PATH, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_SOURCE_MODULE, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_MODULE_VERSION => true, + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_OVERRIDE_FOR => false, + default => false, + }); + $this->registerModulePaths([ + 'Hyva_MageWorxFaq' => '/magento/vendor/hyva-themes/magento2-mageworx-faq/src', + ]); + $this->packageInfo + ->method('getVersion') + ->willReturnCallback(static fn(string $module): string => match ($module) { + 'Hyva_MageWorxFaq' => '1.0.6', + default => '', + }); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('
content
'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + '/magento/vendor/hyva-themes/magento2-mageworx-faq/src/view/frontend/templates/faq/list.phtml', + '/theme/dir/widget.phtml', + 'MageWorx_Faq', + ); + + $this->assertStringContainsString('@module Hyva_MageWorxFaq', $captured ?? ''); + $this->assertStringContainsString('@module-version 1.0.6', $captured ?? ''); + $this->assertStringNotContainsString('@override-for', $captured ?? ''); + } + + /** + * @dataProvider formatToggleProvider + * @param string $sourcePath + * @param string $targetPath + * @param string $enableConfigPath + */ + public function testSkipsHeaderWhenFormatToggleDisabled( + string $sourcePath, + string $targetPath, + string $enableConfigPath, + ): void { + $this->scopeConfig + ->method('isSetFlag') + ->willReturnCallback(static fn(string $path): bool => match ($path) { + TemplateOverrideConfig::XML_PATH_ADD_HEADER => true, + $enableConfigPath => false, + default => true, + }); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->expects($this->once())->method('copy')->with($sourcePath, $targetPath); + $this->fileDriver->expects($this->never())->method('fileGetContents'); + $this->fileDriver->expects($this->never())->method('filePutContents'); + + $this->copier->copy($sourcePath, $targetPath, 'Vendor_Module'); + } + + /** + * @return array + */ + public static function formatToggleProvider(): array + { + return [ + 'phtml disabled' => [ + '/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_PHTML, + ], + 'html disabled' => [ + '/module/view/frontend/templates/mail.html', + '/theme/dir/mail.html', + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_HTML, + ], + 'xml disabled' => [ + '/module/view/frontend/layout/default.xml', + '/theme/dir/default.xml', + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_XML, + ], + 'web asset disabled' => [ + '/module/web/js/source.js', + '/theme/dir/source.js', + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_WEB_ASSETS, + ], + 'shell disabled' => [ + '/module/web/scripts/deploy.sh', + '/theme/dir/deploy.sh', + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_ENABLE_SHELL, + ], + ]; + } + + /** + * @dataProvider enabledFormatProvider + * @param string $sourcePath + * @param string $targetPath + * @param string $headerMarker + */ + public function testAddsHeaderForEachEnabledFileFormat( + string $sourcePath, + string $targetPath, + string $headerMarker, + ): void { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('content'); + $this->fileDriver->expects($this->never())->method('copy'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy($sourcePath, $targetPath, 'Vendor_Module'); + + $this->assertStringContainsString($headerMarker, $captured ?? ''); + } + + /** + * Every extension of every match arm must map to its format toggle, + * including the alias extensions (php, htm, xhtml, ...). + * + * @return array + */ + public static function enabledFormatProvider(): array + { + $plainMarker = 'MageForge Template Override'; + + return [ + 'phtml' => [ + '/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + '@mageforge-template-override', + ], + 'php' => ['/module/Block/Widget.php', '/theme/dir/Widget.php', '@mageforge-template-override'], + 'html' => ['/module/view/frontend/templates/mail.html', '/theme/dir/mail.html', $plainMarker], + 'htm' => ['/module/view/frontend/templates/mail.htm', '/theme/dir/mail.htm', $plainMarker], + 'xml' => ['/module/view/frontend/layout/default.xml', '/theme/dir/default.xml', $plainMarker], + 'xhtml' => ['/module/view/frontend/templates/page.xhtml', '/theme/dir/page.xhtml', $plainMarker], + 'svg' => ['/module/web/images/icon.svg', '/theme/dir/icon.svg', $plainMarker], + 'css' => ['/module/web/css/styles.css', '/theme/dir/styles.css', $plainMarker], + 'js' => ['/module/web/js/source.js', '/theme/dir/source.js', $plainMarker], + 'less' => ['/module/web/css/styles.less', '/theme/dir/styles.less', $plainMarker], + 'scss' => ['/module/web/css/styles.scss', '/theme/dir/styles.scss', $plainMarker], + 'sass' => ['/module/web/css/styles.sass', '/theme/dir/styles.sass', $plainMarker], + 'ts' => ['/module/web/ts/source.ts', '/theme/dir/source.ts', $plainMarker], + 'sh' => ['/module/web/scripts/deploy.sh', '/theme/dir/deploy.sh', $plainMarker], + 'bash' => ['/module/web/scripts/deploy.bash', '/theme/dir/deploy.bash', $plainMarker], + 'zsh' => ['/module/web/scripts/deploy.zsh', '/theme/dir/deploy.zsh', $plainMarker], + 'fish' => ['/module/web/scripts/deploy.fish', '/theme/dir/deploy.fish', $plainMarker], + ]; + } + + public function testUppercaseExtensionIsMatchedCaseInsensitively(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('content'); + $this->fileDriver->expects($this->never())->method('copy'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + '/module/view/frontend/templates/widget.phtml', + '/theme/dir/WIDGET.PHTML', + 'Vendor_Module', + ); + + $this->assertStringContainsString('@mageforge-template-override', $captured ?? ''); + } + + public function testSkipsHeaderWhenAddHeaderDisabledButFormatEnabled(): void + { + $this->scopeConfig + ->method('isSetFlag') + ->willReturnCallback(static fn(string $path): bool => match ($path) { + TemplateOverrideConfig::XML_PATH_ADD_HEADER => false, + default => true, + }); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->expects($this->once())->method('copy')->with( + '/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + ); + $this->fileDriver->expects($this->never())->method('fileGetContents'); + $this->fileDriver->expects($this->never())->method('filePutContents'); + + $this->copier->copy( + '/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + 'Vendor_Module', + ); + } + + public function testPlainHeaderContainsAllDetailsForUnresolvableModule(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->packageInfo->method('getVersion')->with('Vendor_Module')->willReturn('4.5.6'); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('content'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy('/module/web/js/source.js', '/theme/dir/source.js', 'Vendor_Module'); + + $this->assertStringContainsString('MageForge Template Override', $captured ?? ''); + $this->assertStringContainsString('Date: ' . date('Y-m-d'), $captured ?? ''); + $this->assertStringContainsString('Source: /module/web/js/source.js', $captured ?? ''); + $this->assertStringContainsString('Override For: Vendor_Module', $captured ?? ''); + $this->assertStringContainsString('Module-Version: 4.5.6', $captured ?? ''); + $this->assertStringNotContainsString('Source Module:', $captured ?? ''); + } + + public function testPlainHeaderOmitsDateWhenDisabled(): void + { + $this->scopeConfig + ->method('isSetFlag') + ->willReturnCallback(static fn(string $path): bool => match ($path) { + TemplateOverrideConfig::XML_PATH_SOURCE_HEADER_INCLUDE_DATE => false, + default => true, + }); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('

order

'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy('/source.html', '/theme/dir/target.html', 'Vendor_Module'); + + $this->assertStringContainsString('MageForge Template Override', $captured ?? ''); + $this->assertStringNotContainsString('Date:', $captured ?? ''); + } + + public function testPhpDocHeaderFallsBackToLogicalModuleWhenSourceNotResolvable(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->packageInfo->method('getVersion')->with('Vendor_Module')->willReturn('9.9.9'); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('
content
'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy('/module/view/frontend/templates/widget.phtml', '/theme/dir/widget.phtml', 'Vendor_Module'); + + $this->assertStringContainsString('@module Vendor_Module', $captured ?? ''); + $this->assertStringContainsString('@module-version 9.9.9', $captured ?? ''); + $this->assertStringNotContainsString('@override-for', $captured ?? ''); + } + + public function testPlainHeaderOmitsOverrideForWhenNoModuleNameGiven(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('content'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy('/module/web/js/source.js', '/theme/dir/source.js'); + + $this->assertStringContainsString('MageForge Template Override', $captured ?? ''); + $this->assertStringNotContainsString('Override For', $captured ?? ''); + $this->assertStringNotContainsString('Source Module', $captured ?? ''); + } + + public function testPlainHeaderOmitsOverrideForWhenModuleNameEmpty(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('content'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy('/module/web/js/source.js', '/theme/dir/source.js', ''); + + $this->assertStringContainsString('MageForge Template Override', $captured ?? ''); + $this->assertStringNotContainsString('Override For', $captured ?? ''); + $this->assertStringNotContainsString('Module-Version', $captured ?? ''); + } + + public function testPhpDocHeaderOmitsModuleLinesWhenNoModuleNameGiven(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->registerModulePaths([ + 'Vendor_Module' => '/magento/vendor/module', + ]); + $this->packageInfo->method('getVersion')->with('Vendor_Module')->willReturn('1.2.3'); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('
content
'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy('/magento/vendor/module/view/frontend/templates/widget.phtml', '/theme/dir/widget.phtml'); + + $this->assertStringContainsString('@module Vendor_Module', $captured ?? ''); + $this->assertStringNotContainsString('@override-for', $captured ?? ''); + } + + public function testNoOverrideForWhenActualModuleMatchesLogicalModule(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->registerModulePaths([ + 'Vendor_Module' => '/magento/vendor/module', + ]); + $this->packageInfo->method('getVersion')->with('Vendor_Module')->willReturn('1.2.3'); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('
content
'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + '/magento/vendor/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + 'Vendor_Module', + ); + + $this->assertStringContainsString('@module Vendor_Module', $captured ?? ''); + $this->assertStringNotContainsString('@override-for', $captured ?? ''); + } + + public function testResolvesModuleWithWindowsStylePaths(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->registerModulePaths([ + 'Vendor_Module' => 'C:\\magento\\vendor\\module\\', + ]); + $this->packageInfo->method('getVersion')->with('Vendor_Module')->willReturn('1.2.3'); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('content'); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + 'C:\\magento\\vendor\\module\\web\\js\\source.js', + '/theme/dir/source.js', + 'Vendor_Module', + ); + + $this->assertStringContainsString('Source Module: Vendor_Module', $captured ?? ''); + } + + public function testHeaderIsPrependedWhenPhpTagAppearsLaterInTemplate(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn("
\n"); + $captured = null; + $this->fileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + '/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + 'Vendor_Module', + ); + + $this->assertStringStartsWith("assertStringEndsWith("
\n", $captured ?? ''); + } + + public function testHeaderIsInjectedAfterPhpOpenTagPrecededByBlankLine(): void + { + $this->scopeConfig->method('isSetFlag')->willReturn(true); + $this->fileDriver->method('getParentDirectory')->willReturn('/theme/dir'); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn("\nfileDriver + ->method('filePutContents') + ->willReturnCallback(static function (string $path, string $content) use (&$captured): bool { + $captured = $content; + return true; + }); + + $this->copier->copy( + '/module/view/frontend/templates/widget.phtml', + '/theme/dir/widget.phtml', + 'Vendor_Module', + ); + + $this->assertStringStartsWith("\nassertStringEndsWith("echo 'x';\n", $captured ?? ''); + } }