1111
1212class VendorFileMapper
1313{
14+ /**
15+ * @param ComponentRegistrarInterface $componentRegistrar
16+ * @param DirectoryList $directoryList
17+ */
1418 public function __construct (
1519 private readonly ComponentRegistrarInterface $ componentRegistrar ,
1620 private readonly DirectoryList $ directoryList
17- ) {}
21+ ) {
22+ }
1823
24+ /**
25+ * Map a vendor file path to the correct theme override path
26+ *
27+ * @param string $sourcePath
28+ * @param string $themePath
29+ * @return string
30+ * @throws RuntimeException
31+ */
1932 public function mapToThemePath (string $ sourcePath , string $ themePath ): string
2033 {
21- // 1. Normalize: Ensure $sourcePath is relative from Magento Root if it's absolute
34+ // 1. Determine target theme area (frontend or adminhtml)
35+ $ themeArea = $ this ->extractThemeArea ($ themePath );
36+
37+ // 2. Normalize: Ensure $sourcePath is relative from Magento Root if it's absolute
2238 $ rootPath = rtrim ($ this ->directoryList ->getRoot (), '/ ' );
2339 if (str_starts_with ($ sourcePath , $ rootPath . '/ ' )) {
2440 $ sourcePath = substr ($ sourcePath , strlen ($ rootPath ) + 1 );
2541 }
2642
27- // 2 . Detect "Standard Module" Pattern (Priority 1) - Best for Local Modules & Composer Packages
43+ // 3 . Detect "Standard Module" Pattern (Priority 1) - Best for Local Modules & Composer Packages
2844 $ modules = $ this ->componentRegistrar ->getPaths (ComponentRegistrar::MODULE );
2945 foreach ($ modules as $ moduleName => $ path ) {
3046 // Normalize module path relative to root
@@ -36,18 +52,14 @@ public function mapToThemePath(string $sourcePath, string $themePath): string
3652 if (str_starts_with ($ sourcePath , $ path . '/ ' )) {
3753 $ pathInsideModule = substr ($ sourcePath , strlen ($ path ) + 1 );
3854
39- // Remove view/frontend/ or view/base/ from the path
40- $ cleanPath = (string ) preg_replace (
41- '#^view/(frontend|base)/# ' ,
42- '' ,
43- $ pathInsideModule
44- );
55+ // Validate area and extract clean path
56+ $ cleanPath = $ this ->validateAndExtractViewPath ($ pathInsideModule , $ themeArea , $ sourcePath );
4557
4658 return rtrim ($ themePath , '/ ' ) . '/ ' . $ moduleName . '/ ' . ltrim ($ cleanPath , '/ ' );
4759 }
4860 }
4961
50- // 3 . Detect "Nested Module" Pattern (Priority 2) - Works for Hyva Compat & Vendor Themes
62+ // 4 . Detect "Nested Module" Pattern (Priority 2) - Works for Hyva Compat & Vendor Themes
5163 // Regex search for a segment matching Vendor_Module (e.g. Magento_Catalog).
5264 // Captures (Group 1): "Vendor_Module"
5365 if (preg_match ('/([A-Z][a-zA-Z0-9]*_[A-Z][a-zA-Z0-9]*)/ ' , $ sourcePath , $ matches , PREG_OFFSET_CAPTURE )) {
@@ -56,10 +68,109 @@ public function mapToThemePath(string $sourcePath, string $themePath): string
5668 // Extract from Vendor_Module onwards (e.g. "Mollie_Payment/templates/file.phtml")
5769 $ relativePath = substr ($ sourcePath , $ offset );
5870
71+ // Validate that this path contains a valid view area
72+ // Extract the part after Vendor_Module to check
73+ $ parts = explode ('/ ' , $ relativePath , 3 );
74+ if (count ($ parts ) >= 3 && $ parts [1 ] === 'view ' ) {
75+ // Format: Vendor_Module/view/{area}/...
76+ $ area = $ parts [2 ];
77+ if (!$ this ->isAreaCompatible ($ area , $ themeArea )) {
78+ throw new RuntimeException (
79+ sprintf (
80+ "Cannot map file from area '%s' to %s theme. File: %s " ,
81+ $ area ,
82+ $ themeArea ,
83+ $ sourcePath
84+ )
85+ );
86+ }
87+ }
88+
5989 return rtrim ($ themePath , '/ ' ) . '/ ' . ltrim ($ relativePath , '/ ' );
6090 }
6191
62- // 4 . Fallback
92+ // 5 . Fallback
6393 throw new RuntimeException ("Could not determine target module or theme structure for file: " . $ sourcePath );
6494 }
95+
96+ /**
97+ * Extract theme area from theme path
98+ *
99+ * @param string $themePath
100+ * @return string
101+ * @throws RuntimeException
102+ */
103+ private function extractThemeArea (string $ themePath ): string
104+ {
105+ if (preg_match ('#/(frontend|adminhtml)/# ' , $ themePath , $ matches )) {
106+ return $ matches [1 ];
107+ }
108+
109+ throw new RuntimeException ("Could not determine theme area from path: " . $ themePath );
110+ }
111+
112+ /**
113+ * Validate that the path is under view/{area}/ and compatible with target theme area
114+ *
115+ * @param string $pathInsideModule
116+ * @param string $targetArea
117+ * @param string $originalPath
118+ * @return string Clean path without view/{area}/ prefix
119+ * @throws RuntimeException
120+ */
121+ private function validateAndExtractViewPath (
122+ string $ pathInsideModule ,
123+ string $ targetArea ,
124+ string $ originalPath
125+ ): string {
126+ // Check if path starts with view/{area}/
127+ if (!preg_match ('#^view/([^/]+)/# ' , $ pathInsideModule , $ matches )) {
128+ throw new RuntimeException (
129+ sprintf (
130+ "File is not under a view/ directory. " .
131+ "Only files under view/{area}/ can be mapped to themes. File: %s " ,
132+ $ originalPath
133+ )
134+ );
135+ }
136+
137+ $ sourceArea = $ matches [1 ];
138+
139+ // Validate area compatibility
140+ if (!$ this ->isAreaCompatible ($ sourceArea , $ targetArea )) {
141+ throw new RuntimeException (
142+ sprintf (
143+ "Cannot map file from area '%s' to %s theme. File: %s " ,
144+ $ sourceArea ,
145+ $ targetArea ,
146+ $ originalPath
147+ )
148+ );
149+ }
150+
151+ // Remove view/{area}/ prefix
152+ return (string ) preg_replace ('#^view/[^/]+/# ' , '' , $ pathInsideModule );
153+ }
154+
155+ /**
156+ * Check if source area is compatible with target theme area
157+ *
158+ * @param string $sourceArea
159+ * @param string $targetArea
160+ * @return bool
161+ */
162+ private function isAreaCompatible (string $ sourceArea , string $ targetArea ): bool
163+ {
164+ // Exact match
165+ if ($ sourceArea === $ targetArea ) {
166+ return true ;
167+ }
168+
169+ // 'base' area is compatible with both frontend and adminhtml
170+ if ($ sourceArea === 'base ' ) {
171+ return true ;
172+ }
173+
174+ return false ;
175+ }
65176}
0 commit comments