mage-obsidian 1.0.12 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/jest.config.js +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/fixtures/interceptors/emptyTarget.js +2 -0
  4. package/src/__tests__/fixtures/interceptors/pluginModule.js +8 -0
  5. package/src/__tests__/fixtures/interceptors/targetModule.js +12 -0
  6. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/module.config.js +0 -6
  7. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameB/web/module.config.js +0 -5
  8. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameNoConfig/web/module.config.js +0 -6
  9. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-a/web/theme.config.js +0 -12
  10. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-b/web/theme.config.js +0 -17
  11. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/module.config.js +0 -7
  12. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-c/web/theme.config.js +0 -14
  13. package/src/__tests__/magento_scenarios/app/design/frontend/Vendor/theme-d/web/theme.config.js +0 -11
  14. package/src/__tests__/service/generateInterceptors.test.js +145 -0
  15. package/src/__tests__/service/themeResolverSync.test.js +4 -75
  16. package/src/config/default.js +1 -0
  17. package/src/service/generateInterceptors.js +266 -0
  18. package/src/service/moduleResolver.js +6 -18
  19. package/src/service/pluginManager.js +137 -0
  20. package/src/service/themeResolverSync.js +2 -20
  21. package/src/__tests__/service/moduleResolverSync.test.js +0 -188
  22. package/src/service/tailwindResolver.js +0 -11
package/jest.config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export default {
2
2
  testEnvironment: 'node',
3
3
  transform: {},
4
- testPathIgnorePatterns: ['/__tests__/magento_scenarios/','/node_modules/', '/__tests__/__mocks__/'],
4
+ testPathIgnorePatterns: ['/__tests__/magento_scenarios/','/node_modules/', '/__tests__/__mocks__/', '/__tests__/fixtures/'],
5
5
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mage-obsidian",
3
- "version": "1.0.12",
3
+ "version": "1.1.0",
4
4
  "description": "Mage Obsidian Ultis",
5
5
  "main": "./src/scripts/buildThemes.js",
6
6
  "keywords": [
@@ -0,0 +1,2 @@
1
+
2
+ export const someOtherFunction = () => {};
@@ -0,0 +1,8 @@
1
+
2
+ export function beforeTargetFunction(arg) {
3
+ return [`Modified: ${arg}`];
4
+ }
5
+
6
+ export function afterAnotherFunction(result) {
7
+ return `${result} - Modified`;
8
+ }
@@ -0,0 +1,12 @@
1
+
2
+ export function targetFunction(arg) {
3
+ return `Original: ${arg}`;
4
+ }
5
+
6
+ export function anotherFunction() {
7
+ return 'Another';
8
+ }
9
+
10
+ export default function defaultExport() {
11
+ return 'Default';
12
+ }
@@ -1,8 +1,2 @@
1
1
  export default {
2
- tailwind: {
3
- content: [
4
- '../templates/**/*.phtml',
5
- '/from_theme'
6
- ]
7
- }
8
2
  };
@@ -1,7 +1,2 @@
1
1
  export default {
2
- tailwind: {
3
- content: [
4
- '../templates/**/*.phtml'
5
- ]
6
- }
7
2
  };
@@ -1,8 +1,2 @@
1
1
  export default {
2
- tailwind: {
3
- content: [
4
- '../templates/**/*.phtml',
5
- 'pollo'
6
- ]
7
- }
8
2
  };
@@ -1,16 +1,4 @@
1
1
  export default {
2
- tailwind: {
3
- theme: {
4
- extend: {},
5
- },
6
- plugins: [],
7
- content: [
8
- './**/*.{vue,js}',
9
- '../**/*.phtml',
10
- '../*/page_layout/override/base/*.xml',
11
- ],
12
- },
13
- ignoredTailwindConfigFromModules: [],
14
2
  ignoredCssFromModules: [
15
3
  'Vendor_ModuleNameA',
16
4
  ],
@@ -1,21 +1,4 @@
1
1
  export default {
2
- tailwind: {
3
- theme: {
4
- extend: {
5
- textColor: {
6
- 'primary': '#3490dc',
7
- 'secondary': '#ffed4a',
8
- 'danger': '#e3342f',
9
- },
10
- },
11
- },
12
- plugins: [],
13
- content: [
14
- './**/*.{vue,js}',
15
- '../**/*.phtml',
16
- '../*/page_layout/override/base/*.xml',
17
- ],
18
- },
19
2
  ignoredCssFromModules: [
20
3
  'Vendor_ModuleNameNoConfig'
21
4
  ]
@@ -1,9 +1,2 @@
1
1
  export default {
2
- tailwind: {
3
- plugins: ['test_from_theme'],
4
- content: [
5
- '../templates/**/*.phtml',
6
- '/from_theme'
7
- ]
8
- }
9
2
  };
@@ -1,16 +1,2 @@
1
1
  export default {
2
- tailwind: {
3
- theme: {
4
- extend: {
5
- },
6
- },
7
- plugins: [],
8
- content: [
9
- './**/*.{vue,js}',
10
- '../**/*.phtml',
11
- '../*/page_layout/override/base/*.xml',
12
- ],
13
- },
14
- includeTailwindConfigFromParentThemes: false,
15
- ignoredTailwindConfigFromModules: 'all'
16
2
  }
@@ -1,14 +1,3 @@
1
1
  export default {
2
- tailwind: {
3
- theme: {
4
- extend: {
5
- },
6
- },
7
- plugins: [],
8
- content: [],
9
- },
10
- ignoredTailwindConfigFromModules: [
11
- 'Vendor_ModuleNameC'
12
- ],
13
2
  includeCssSourceFromParentThemes: false,
14
3
  }
@@ -0,0 +1,145 @@
1
+
2
+ import { jest } from '@jest/globals';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const fixturesDir = path.resolve(__dirname, '../fixtures/interceptors');
8
+ const targetModulePath = path.join(fixturesDir, 'targetModule.js');
9
+ const pluginModulePath = path.join(fixturesDir, 'pluginModule.js');
10
+
11
+ describe('generateInterceptors', () => {
12
+ let generateInterceptorsService;
13
+ let themeResolverMock;
14
+ let moduleResolverMock;
15
+ let pluginManager;
16
+
17
+ beforeEach(async () => {
18
+ jest.resetModules();
19
+
20
+ themeResolverMock = {
21
+ getThemeConfig: jest.fn(),
22
+ };
23
+
24
+ moduleResolverMock = {
25
+ getModuleConfigByThemeConfig: jest.fn(),
26
+ getAllJsVueFilesWithInheritanceCached: jest.fn(),
27
+ getAllJsVueFilesWithInheritance: jest.fn(),
28
+ };
29
+
30
+ jest.unstable_mockModule('../../service/themeResolverSync.js', () => ({
31
+ default: themeResolverMock
32
+ }));
33
+
34
+ jest.unstable_mockModule('../../service/moduleResolver.js', () => ({
35
+ default: moduleResolverMock
36
+ }));
37
+
38
+ // Import real pluginManager to reset state
39
+ pluginManager = (await import('../../service/pluginManager.js')).default;
40
+ pluginManager.plugins = {};
41
+
42
+ generateInterceptorsService = (await import('../../service/generateInterceptors.js')).default;
43
+ });
44
+
45
+ test('should generate interceptors correctly', async () => {
46
+ const themeName = 'Vendor/theme-test';
47
+
48
+ themeResolverMock.getThemeConfig.mockReturnValue({
49
+ src: '/path/to/theme'
50
+ });
51
+
52
+ moduleResolverMock.getModuleConfigByThemeConfig.mockResolvedValue({
53
+ 'Vendor_TargetModule': {
54
+ src: '/path/to/vendor/target',
55
+ interceptors: [
56
+ {
57
+ name: 'TestPlugin',
58
+ target: 'Vendor_TargetModule::js/target.js',
59
+ plugin: 'Vendor_PluginModule::js/plugin.js',
60
+ sortOrder: 10,
61
+ active: true
62
+ }
63
+ ]
64
+ }
65
+ });
66
+
67
+ const allFilesMap = {
68
+ 'Vendor_TargetModule/js/target': targetModulePath,
69
+ 'Vendor_PluginModule/js/plugin': pluginModulePath
70
+ };
71
+
72
+ moduleResolverMock.getAllJsVueFilesWithInheritanceCached.mockReturnValue(allFilesMap);
73
+
74
+ const result = await generateInterceptorsService.generateInterceptors(themeName);
75
+
76
+ const targetIdentifier = 'Vendor_TargetModule::js/target.js';
77
+ // Use bracket access check to avoid Jest treating '.' as nested property
78
+ expect(result[targetIdentifier]).toBeDefined();
79
+
80
+ const interceptorData = result[targetIdentifier];
81
+ expect(interceptorData).toHaveProperty('proxy');
82
+ expect(interceptorData).toHaveProperty('source');
83
+ expect(interceptorData).toHaveProperty('targetPath', targetModulePath);
84
+ expect(interceptorData.plugins).toHaveLength(1);
85
+ expect(interceptorData.plugins[0].name).toBe('TestPlugin');
86
+
87
+ // Verify Source Code Generation
88
+ const source = interceptorData.source;
89
+ expect(source).toContain(`import * as originalModule from '/@fs${targetModulePath}';`);
90
+ expect(source).toContain(`import * as plugin_0 from '/@fs${pluginModulePath}';`);
91
+ expect(source).toContain(`pluginManager.addPlugin('${targetIdentifier}::targetFunction', 'TestPlugin', 'before', plugin_0.beforeTargetFunction, 10);`);
92
+ expect(source).toContain(`export const targetFunction = proxy.targetFunction;`);
93
+
94
+ // Verify Proxy Behavior
95
+ const proxy = interceptorData.proxy;
96
+ const resultFunc = await proxy.targetFunction('test');
97
+ expect(resultFunc).toBe('Original: Modified: test');
98
+
99
+ const resultAnother = await proxy.anotherFunction();
100
+ expect(resultAnother).toBe('Another - Modified');
101
+ });
102
+
103
+ test('should handle missing target module gracefully', async () => {
104
+ themeResolverMock.getThemeConfig.mockReturnValue({});
105
+ moduleResolverMock.getModuleConfigByThemeConfig.mockResolvedValue({
106
+ 'Vendor_TargetModule': { interceptors: [] }
107
+ });
108
+ moduleResolverMock.getAllJsVueFilesWithInheritanceCached.mockReturnValue({});
109
+
110
+ const result = await generateInterceptorsService.generateInterceptors('Vendor/theme-missing');
111
+ expect(result).toEqual({});
112
+ });
113
+
114
+ test('should throw error if plugin targets non-existent method', async () => {
115
+ const themeName = 'Vendor/theme-test';
116
+ themeResolverMock.getThemeConfig.mockReturnValue({});
117
+ moduleResolverMock.getModuleConfigByThemeConfig.mockResolvedValue({
118
+ 'Vendor_TargetModule': {
119
+ src: '/path/to/vendor/target',
120
+ interceptors: [
121
+ {
122
+ name: 'BadPlugin',
123
+ target: 'Vendor_TargetModule::js/target.js',
124
+ plugin: 'Vendor_PluginModule::js/plugin.js',
125
+ sortOrder: 10
126
+ }
127
+ ]
128
+ }
129
+ });
130
+
131
+ const emptyTargetModulePath = path.join(fixturesDir, 'emptyTarget.js');
132
+
133
+ const allFilesMap = {
134
+ 'Vendor_TargetModule/js/target': emptyTargetModulePath,
135
+ 'Vendor_PluginModule/js/plugin': pluginModulePath
136
+ };
137
+
138
+ moduleResolverMock.getAllJsVueFilesWithInheritanceCached.mockReturnValue(allFilesMap);
139
+
140
+ // We expect the promise to reject because generateInterceptors throws on invalid target method
141
+ await expect(generateInterceptorsService.generateInterceptors(themeName))
142
+ .rejects
143
+ .toThrow(/does not export/);
144
+ });
145
+ });
@@ -12,90 +12,40 @@ let scenarios = [
12
12
  {
13
13
  code: 'Vendor/theme-a',
14
14
  expected: {
15
- tailwind: {
16
- theme: { extend: {} },
17
- plugins: [],
18
- content: [
19
- 'magento_scenarios/app/design/frontend/Vendor/theme-a/web/**/*.{vue,js}',
20
- 'magento_scenarios/app/design/frontend/Vendor/theme-a/**/*.phtml',
21
- 'magento_scenarios/app/design/frontend/Vendor/theme-a/*/page_layout/override/base/*.xml'
22
- ]
23
- },
24
- includeTailwindConfigFromParentThemes: true,
25
15
  includeCssSourceFromParentThemes: true,
26
16
  ignoredCssFromModules: [
27
17
  'Vendor_ModuleNameA'
28
18
  ],
29
- ignoredTailwindConfigFromModules: [],
30
19
  exposeNpmPackages: []
31
20
  }
32
21
  },
33
22
  {
34
23
  code: 'Vendor/theme-b',
35
24
  expected: {
36
- tailwind: {
37
- theme: {
38
- extend: {
39
- textColor: {
40
- 'primary': '#3490dc',
41
- 'secondary': '#ffed4a',
42
- 'danger': '#e3342f',
43
- },
44
- }
45
- },
46
- plugins: [],
47
- content: [
48
- 'magento_scenarios/app/design/frontend/Vendor/theme-a/web/**/*.{vue,js}',
49
- 'magento_scenarios/app/design/frontend/Vendor/theme-a/**/*.phtml',
50
- 'magento_scenarios/app/design/frontend/Vendor/theme-a/*/page_layout/override/base/*.xml',
51
- 'magento_scenarios/app/design/frontend/Vendor/theme-b/web/**/*.{vue,js}',
52
- 'magento_scenarios/app/design/frontend/Vendor/theme-b/**/*.phtml',
53
- 'magento_scenarios/app/design/frontend/Vendor/theme-b/*/page_layout/override/base/*.xml'
54
- ]
55
- },
56
- includeTailwindConfigFromParentThemes: true,
57
25
  includeCssSourceFromParentThemes: true,
58
26
  ignoredCssFromModules: [
59
27
  'Vendor_ModuleNameA',
60
28
  'Vendor_ModuleNameNoConfig'
61
29
  ],
62
- ignoredTailwindConfigFromModules: [],
63
30
  exposeNpmPackages: []
64
31
  }
65
32
  },
66
33
  {
67
34
  code: 'Vendor/theme-c',
68
35
  expected: {
69
- tailwind: {
70
- theme: { extend: {} },
71
- plugins: [],
72
- content: [
73
- 'magento_scenarios/app/design/frontend/Vendor/theme-c/web/**/*.{vue,js}',
74
- 'magento_scenarios/app/design/frontend/Vendor/theme-c/**/*.phtml',
75
- 'magento_scenarios/app/design/frontend/Vendor/theme-c/*/page_layout/override/base/*.xml'
76
- ]
77
- },
78
- includeTailwindConfigFromParentThemes: false,
79
36
  includeCssSourceFromParentThemes: true,
80
- ignoredCssFromModules: [],
81
- ignoredTailwindConfigFromModules: 'all',
37
+ ignoredCssFromModules: [
38
+ 'Vendor_ModuleNameA',
39
+ 'Vendor_ModuleNameNoConfig'
40
+ ],
82
41
  exposeNpmPackages: []
83
42
  }
84
43
  },
85
44
  {
86
45
  code: 'Vendor/theme-d',
87
46
  expected: {
88
- tailwind: {
89
- theme: { extend: {} },
90
- plugins: [],
91
- content: []
92
- },
93
- includeTailwindConfigFromParentThemes: true,
94
47
  includeCssSourceFromParentThemes: false,
95
48
  ignoredCssFromModules: [],
96
- ignoredTailwindConfigFromModules: [
97
- 'Vendor_ModuleNameC'
98
- ],
99
49
  exposeNpmPackages: []
100
50
  }
101
51
  }
@@ -107,7 +57,6 @@ for (const scenario of scenarios) {
107
57
  for (const themeKey in scenario.themes) {
108
58
  let theme = scenario.themes[themeKey];
109
59
  const expected = theme.expected;
110
- expected.tailwind.content = expected.tailwind.content?.map((content) => path.resolve(__dirname, '..', content));
111
60
  }
112
61
  }
113
62
 
@@ -145,24 +94,4 @@ describe('getThemeConfig', () => {
145
94
  });
146
95
  });
147
96
 
148
- describe('getTailwindThemeConfig', () => {
149
- let themeResolver;
150
-
151
- beforeEach(() => {
152
- jest.resetModules();
153
- });
154
-
155
- test.each(scenarios.flatMap(({ scenario, themes }) =>
156
- themes.map(({ code, expected }) => ([ scenario, code, expected ]))
157
- ))('Scenario "%s", Theme "%s"', async ( scenario, code, expected ) => {
158
- themeResolver = await setupThemeResolver(scenario);
159
-
160
- const result = await themeResolver.getTailwindThemeConfig(code);
161
-
162
- expect(result).toMatchObject(
163
- expected.tailwind,
164
- `Expected:\n${JSON.stringify(expected.tailwind, null, 2)}\nReceived:\n${JSON.stringify(result, null, 2)}\nScenario: "${scenario}", Theme: "${code}"`
165
- );
166
- });
167
- });
168
97
 
@@ -22,5 +22,6 @@ export const OUTPUT_THEME_DIR = 'web/generated';
22
22
  export const OUTPUT_JS_DIR = 'js/';
23
23
  export const OUTPUT_CSS_DIR = 'css/';
24
24
  export const PRECOMPILED_FOLDER = '.precompiled';
25
+ export const INTERCEPTORS_FOLDER = 'interceptors';
25
26
 
26
27
  export const ALL_JS_VUE_FILES_WITH_INHERITANCE_FILE_NAME = 'allJsVueFilesWithInheritance.json';
@@ -0,0 +1,266 @@
1
+ import path from "path";
2
+ import { pathToFileURL, fileURLToPath } from "url";
3
+ import themeResolver from './themeResolverSync.js';
4
+ import moduleResolver from './moduleResolver.js';
5
+ import pluginManager from './pluginManager.js';
6
+
7
+ const interceptorsRegisteredByTheme = new Map();
8
+ const generatedInterceptorsCache = new Map();
9
+
10
+ /**
11
+ * Resolves the absolute path of a file using the cached file map
12
+ * @param {string} identifier
13
+ * @param {Object} fileMap
14
+ * @returns {string|null}
15
+ */
16
+ function resolvePathFromMap(identifier, fileMap) {
17
+ const [moduleName, relativePath] = identifier.split('::');
18
+ if (!moduleName || !relativePath) return null;
19
+
20
+ const parsed = path.parse(relativePath);
21
+ const keyPath = path.join(parsed.dir, parsed.name);
22
+ const key = `${moduleName}/${keyPath}`;
23
+
24
+ return fileMap[key] || null;
25
+ }
26
+
27
+ async function registerInterceptors(themeName) {
28
+ if (interceptorsRegisteredByTheme.has(themeName)) {
29
+ return interceptorsRegisteredByTheme.get(themeName);
30
+ }
31
+
32
+ const themeConfig = await themeResolver.getThemeConfig(themeName);
33
+ const modulesConfig = await moduleResolver.getModuleConfigByThemeConfig(themeName, themeConfig);
34
+
35
+ // Map<Target, Map<PluginName, PluginConfig>>
36
+ const interceptorsMap = new Map();
37
+
38
+ for (const [moduleName, moduleConfig] of Object.entries(modulesConfig)) {
39
+ if (!moduleConfig.interceptors || !Array.isArray(moduleConfig.interceptors)) {
40
+ continue;
41
+ }
42
+ for (const c of moduleConfig.interceptors) {
43
+ const { name, target } = c;
44
+ if (!name || !target) continue;
45
+
46
+ if (!interceptorsMap.has(target)) {
47
+ interceptorsMap.set(target, new Map());
48
+ }
49
+
50
+ const targetPlugins = interceptorsMap.get(target);
51
+
52
+ if (targetPlugins.has(name)) {
53
+ // Merge existing plugin config with new config (allows overriding sortOrder, active, etc.)
54
+ const existing = targetPlugins.get(name);
55
+ targetPlugins.set(name, { ...existing, ...c });
56
+ } else {
57
+ targetPlugins.set(name, { ...c, module: moduleName });
58
+ }
59
+ }
60
+ }
61
+
62
+ // Convert Map to structured object and sort plugins
63
+ const result = {};
64
+ for (const [target, pluginsMap] of interceptorsMap) {
65
+ const plugins = Array.from(pluginsMap.values())
66
+ .filter(p => p.active !== false) // Filter out inactive plugins
67
+ .sort((a, b) => (a.sortOrder || 10) - (b.sortOrder || 10));
68
+
69
+ if (plugins.length > 0) {
70
+ result[target] = plugins;
71
+ }
72
+ }
73
+
74
+ interceptorsRegisteredByTheme.set(themeName, result);
75
+ return result;
76
+ }
77
+
78
+ async function generateInterceptors(themeName) {
79
+ if (generatedInterceptorsCache.has(themeName)) {
80
+ return generatedInterceptorsCache.get(themeName);
81
+ }
82
+
83
+ const interceptorsConfig = await registerInterceptors(themeName);
84
+ const modulesConfig = await moduleResolver.getModuleConfigByThemeConfig(themeName, await themeResolver.getThemeConfig(themeName));
85
+
86
+ // Get all files map from cache
87
+ let allFilesMap = {};
88
+ try {
89
+ allFilesMap = moduleResolver.getAllJsVueFilesWithInheritanceCached(themeName);
90
+ } catch (e) {
91
+ console.warn(`Cache not found for theme ${themeName}, falling back to async generation.`);
92
+ allFilesMap = await moduleResolver.getAllJsVueFilesWithInheritance(themeName);
93
+ }
94
+
95
+ const interceptors = {};
96
+
97
+ for (const [targetIdentifier, plugins] of Object.entries(interceptorsConfig)) {
98
+ // 1. Resolve Target Path
99
+ const targetPath = resolvePathFromMap(targetIdentifier, allFilesMap);
100
+ if (!targetPath) {
101
+ console.warn(`Target module not found for identifier: ${targetIdentifier}`);
102
+ continue;
103
+ }
104
+
105
+ // 2. Load Target Module to inspect exports
106
+ let targetModule;
107
+ try {
108
+ targetModule = await import(pathToFileURL(targetPath).href);
109
+ } catch (e) {
110
+ console.error(`Failed to import target module ${targetIdentifier}:`, e.message);
111
+ continue;
112
+ }
113
+
114
+ const targetExports = Object.keys(targetModule);
115
+ const methodsToIntercept = new Set();
116
+
117
+ // 3. Validate and Register Plugins
118
+ const validPlugins = [];
119
+ for (const pluginConfig of plugins) {
120
+ const pluginPath = resolvePathFromMap(pluginConfig.plugin, allFilesMap);
121
+ if (!pluginPath) {
122
+ console.warn(`Plugin module not found: ${pluginConfig.plugin}`);
123
+ continue;
124
+ }
125
+
126
+ let pluginModule;
127
+ try {
128
+ pluginModule = await import(pathToFileURL(pluginPath).href);
129
+ } catch (e) {
130
+ console.error(`Failed to import plugin module ${pluginConfig.plugin}:`, e.message);
131
+ continue;
132
+ }
133
+
134
+ const pluginMethods = [];
135
+ for (const pluginExport of Object.keys(pluginModule)) {
136
+ let type, targetMethod;
137
+
138
+ if (pluginExport.startsWith('before')) {
139
+ type = 'before';
140
+ targetMethod = pluginExport.substring(6);
141
+ } else if (pluginExport.startsWith('around')) {
142
+ type = 'around';
143
+ targetMethod = pluginExport.substring(6);
144
+ } else if (pluginExport.startsWith('after')) {
145
+ type = 'after';
146
+ targetMethod = pluginExport.substring(5);
147
+ } else {
148
+ continue;
149
+ }
150
+
151
+ // Check if target method exists in target module
152
+ if (!targetExports.includes(targetMethod) && targetMethod !== 'default') {
153
+ const lowerFirst = targetMethod.charAt(0).toLowerCase() + targetMethod.slice(1);
154
+ if (targetExports.includes(lowerFirst)) {
155
+ targetMethod = lowerFirst;
156
+ } else {
157
+ // Skip invalid methods but don't crash the whole process?
158
+ // User requested error if not found.
159
+ throw new Error(`Plugin ${pluginConfig.name} (${pluginConfig.plugin}) exports '${pluginExport}' but target ${targetIdentifier} does not export '${targetMethod}'`);
160
+ }
161
+ }
162
+
163
+ // Register the plugin with PluginManager (Runtime)
164
+ const methodKey = `${targetIdentifier}::${targetMethod}`;
165
+ pluginManager.addPlugin(
166
+ methodKey,
167
+ pluginConfig.name,
168
+ type,
169
+ pluginModule[pluginExport],
170
+ pluginConfig.sortOrder
171
+ );
172
+
173
+ methodsToIntercept.add(targetMethod);
174
+ pluginMethods.push({
175
+ exportName: pluginExport,
176
+ type,
177
+ targetMethod,
178
+ sortOrder: pluginConfig.sortOrder
179
+ });
180
+ }
181
+
182
+ if (pluginMethods.length > 0) {
183
+ validPlugins.push({
184
+ ...pluginConfig,
185
+ path: pluginPath,
186
+ methods: pluginMethods
187
+ });
188
+ }
189
+ }
190
+
191
+ // 4. Create Interceptor Proxy & Source Code
192
+ if (methodsToIntercept.size > 0) {
193
+ const wrapper = { ...targetModule };
194
+ const proxy = pluginManager.intercept(wrapper, targetIdentifier, true);
195
+
196
+ const source = generateInterceptorCode(targetIdentifier, targetPath, validPlugins, targetExports);
197
+
198
+ interceptors[targetIdentifier] = {
199
+ proxy,
200
+ targetPath,
201
+ targetModule,
202
+ plugins: validPlugins,
203
+ source
204
+ };
205
+ }
206
+ }
207
+
208
+ generatedInterceptorsCache.set(themeName, interceptors);
209
+ return interceptors;
210
+ }
211
+
212
+ function generateInterceptorCode(targetIdentifier, targetPath, plugins, targetExports) {
213
+ const imports = [];
214
+ const registrations = [];
215
+
216
+ // Import PluginManager (Assuming it's available via alias or relative path in the build environment)
217
+ // For Vite, we might need to adjust this path or use a virtual module ID.
218
+ // Using a relative path from this service file might not work in the generated code context.
219
+ // We'll assume '@mage-obsidian/plugin-manager' or similar alias is set up,
220
+ // or use the absolute path which Vite handles.
221
+ const pluginManagerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'pluginManager.js');
222
+ imports.push(`import pluginManager from '/@fs${pluginManagerPath}';`);
223
+
224
+ // Import Original Module
225
+ imports.push(`import * as originalModule from '/@fs${targetPath}';`);
226
+
227
+ // Import Plugins
228
+ plugins.forEach((plugin, index) => {
229
+ const pluginVar = `plugin_${index}`;
230
+ imports.push(`import * as ${pluginVar} from '/@fs${plugin.path}';`);
231
+
232
+ plugin.methods.forEach(method => {
233
+ const methodKey = `${targetIdentifier}::${method.targetMethod}`;
234
+ registrations.push(`pluginManager.addPlugin('${methodKey}', '${plugin.name}', '${method.type}', ${pluginVar}.${method.exportName}, ${method.sortOrder});`);
235
+ });
236
+ });
237
+
238
+ // Create Interceptor
239
+ const interceptorCode = `
240
+ const targetWrapper = { ...originalModule };
241
+ const proxy = pluginManager.intercept(targetWrapper, '${targetIdentifier}', true);
242
+ `;
243
+
244
+ // Exports
245
+ const exportsCode = targetExports.map(exp => {
246
+ if (exp === 'default') {
247
+ return `export default proxy.default;`;
248
+ }
249
+ return `export const ${exp} = proxy.${exp};`;
250
+ }).join('\n');
251
+
252
+ return `
253
+ ${imports.join('\n')}
254
+
255
+ ${registrations.join('\n')}
256
+
257
+ ${interceptorCode}
258
+
259
+ ${exportsCode}
260
+ `;
261
+ }
262
+
263
+ export default {
264
+ registerInterceptors,
265
+ generateInterceptors
266
+ };
@@ -110,22 +110,22 @@ function getModuleConfigPath(moduleSrc) {
110
110
  return path.join(moduleSrc, 'view/frontend/web/', MODULE_CONFIG_FILE);
111
111
  }
112
112
 
113
- function resolveFileByTheme(themeName, moduleName, filePath, includeTailwindConfigFromParentThemes) {
113
+ function resolveFileByTheme(themeName, moduleName, filePath) {
114
114
  const theme = configResolver.getMagentoConfig().themes[themeName];
115
115
  const fullFilePath = path.join(theme.src, moduleName, 'web', filePath);
116
116
  if (fs.existsSync(fullFilePath)) {
117
117
  return fullFilePath;
118
118
  }
119
119
 
120
- if (includeTailwindConfigFromParentThemes && theme.parent) {
121
- return resolveFileByTheme(theme.parent, moduleName, filePath, includeTailwindConfigFromParentThemes);
120
+ if (theme.parent) {
121
+ return resolveFileByTheme(theme.parent, moduleName, filePath);
122
122
  }
123
123
  return null;
124
124
  }
125
125
 
126
- async function resolveModuleConfig(moduleName, themeName, includeTailwindConfigFromParentThemes) {
126
+ async function resolveModuleConfig(moduleName, themeName) {
127
127
  const module = configResolver.getMagentoConfig().modules[moduleName];
128
- let moduleConfigSourcePath = resolveFileByTheme(themeName, moduleName, MODULE_CONFIG_FILE, includeTailwindConfigFromParentThemes);
128
+ let moduleConfigSourcePath = resolveFileByTheme(themeName, moduleName, MODULE_CONFIG_FIL);
129
129
 
130
130
  if (!moduleConfigSourcePath && !module) {
131
131
  return null;
@@ -158,20 +158,8 @@ async function getModuleConfigByThemeConfig(themeName, themeConfig) {
158
158
  let modulesConfig = {};
159
159
 
160
160
  for (const moduleName of modules) {
161
- let moduleConfig = await resolveModuleConfig(moduleName, themeName, themeConfig.includeTailwindConfigFromParentThemes);
161
+ let moduleConfig = await resolveModuleConfig(moduleName, themeName);
162
162
  if (!moduleConfig) continue;
163
- if (
164
- themeConfig.ignoredTailwindConfigFromModules === 'all' ||
165
- themeConfig.ignoredTailwindConfigFromModules.includes(moduleName)
166
- ) {
167
- moduleConfig.tailwind = {};
168
- } else if (moduleConfig.tailwind?.content) {
169
- const moduleSrc = configResolver.getMagentoConfig().modules[moduleName].src;
170
- moduleConfig.tailwind.content = moduleConfig.tailwind.content.map((content) =>
171
- path.join(moduleSrc, 'view/frontend/web', content)
172
- );
173
- }
174
-
175
163
  modulesConfig = deepmerge(modulesConfig, moduleConfig);
176
164
  }
177
165
 
@@ -0,0 +1,137 @@
1
+ class PluginManager {
2
+ constructor() {
3
+ this.plugins = {};
4
+ }
5
+
6
+ /**
7
+ * Register a plugin
8
+ * @param {string} target - The name of the target function/method to intercept
9
+ * @param {string} name - Unique name for the plugin
10
+ * @param {string} type - 'before', 'around', 'after'
11
+ * @param {Function} handler - The function to execute
12
+ * @param {number} sortOrder - Order of execution
13
+ */
14
+ addPlugin(target, name, type, handler, sortOrder = 10) {
15
+ if (!this.plugins[target]) {
16
+ this.plugins[target] = { before: [], around: [], after: [] };
17
+ }
18
+ if (!['before', 'around', 'after'].includes(type)) {
19
+ throw new Error(`Invalid plugin type: ${type}`);
20
+ }
21
+ this.plugins[target][type].push({ name, handler, sortOrder });
22
+ this.plugins[target][type].sort((a, b) => a.sortOrder - b.sortOrder);
23
+ }
24
+
25
+ /**
26
+ * Execute the intercepted method chain synchronously
27
+ * @param {string} target - The target method name
28
+ * @param {Function} originalMethod - The original function
29
+ * @param {Object} context - The 'this' context
30
+ * @param {Array} args - Arguments passed to the function
31
+ */
32
+ executeSync(target, originalMethod, context, ...args) {
33
+ const plugins = this.plugins[target] || { before: [], around: [], after: [] };
34
+
35
+ // Execute 'before' plugins
36
+ for (const plugin of plugins.before) {
37
+ const result = plugin.handler.apply(context, args);
38
+ if (Array.isArray(result)) {
39
+ args = result;
40
+ }
41
+ }
42
+
43
+ // Execute 'around' plugins
44
+ let methodToExecute = (...currentArgs) => {
45
+ return originalMethod.apply(context, currentArgs);
46
+ };
47
+
48
+ if (plugins.around.length > 0) {
49
+ const aroundPlugins = [...plugins.around].reverse();
50
+ for (const plugin of aroundPlugins) {
51
+ const next = methodToExecute;
52
+ methodToExecute = (...currentArgs) => {
53
+ return plugin.handler.apply(context, [next, ...currentArgs]);
54
+ };
55
+ }
56
+ }
57
+
58
+ let result = methodToExecute(...args);
59
+
60
+ // Execute 'after' plugins
61
+ for (const plugin of plugins.after) {
62
+ result = plugin.handler.apply(context, [result, ...args]);
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ /**
69
+ * Execute the intercepted method chain
70
+ * @param {string} target - The target method name
71
+ * @param {Function} originalMethod - The original function
72
+ * @param {Object} context - The 'this' context
73
+ * @param {Array} args - Arguments passed to the function
74
+ */
75
+ async execute(target, originalMethod, context, ...args) {
76
+ const plugins = this.plugins[target] || { before: [], around: [], after: [] };
77
+
78
+ // Execute 'before' plugins
79
+ // Before plugins can modify args by returning an array
80
+ for (const plugin of plugins.before) {
81
+ const result = await plugin.handler.apply(context, args);
82
+ if (Array.isArray(result)) {
83
+ args = result;
84
+ }
85
+ }
86
+
87
+ // Execute 'around' plugins
88
+ // Around plugins receive (proceed, ...args)
89
+ let methodToExecute = async (...currentArgs) => {
90
+ return await originalMethod.apply(context, currentArgs);
91
+ };
92
+
93
+ // Wrap around plugins: first registered is outer-most
94
+ if (plugins.around.length > 0) {
95
+ const aroundPlugins = [...plugins.around].reverse();
96
+ for (const plugin of aroundPlugins) {
97
+ const next = methodToExecute;
98
+ methodToExecute = async (...currentArgs) => {
99
+ return await plugin.handler.apply(context, [next, ...currentArgs]);
100
+ };
101
+ }
102
+ }
103
+
104
+ let result = await methodToExecute(...args);
105
+
106
+ // Execute 'after' plugins
107
+ // After plugins receive (result, ...args) and must return result
108
+ for (const plugin of plugins.after) {
109
+ result = await plugin.handler.apply(context, [result, ...args]);
110
+ }
111
+
112
+ return result;
113
+ }
114
+
115
+ /**
116
+ * Create a proxy to intercept method calls on an object
117
+ * @param {Object} target - The target object (e.g. module exports)
118
+ * @param {string} namespace - Namespace for plugins
119
+ * @param {boolean} useAsync - Whether to use async execution
120
+ */
121
+ intercept(target, namespace, useAsync = true) {
122
+ return new Proxy(target, {
123
+ get: (obj, prop) => {
124
+ const value = obj[prop];
125
+ if (typeof value === 'function') {
126
+ return (...args) => {
127
+ const method = useAsync ? this.execute : this.executeSync;
128
+ return method.call(this, `${namespace}::${String(prop)}`, value, obj, ...args);
129
+ };
130
+ }
131
+ return value;
132
+ }
133
+ });
134
+ }
135
+ }
136
+
137
+ export default new PluginManager();
@@ -50,36 +50,18 @@ export async function getThemeConfig(themeName) {
50
50
  let themeConfig = await loadThemeConfig(themeDefinition, themeName);
51
51
  if (!themeConfig) return null;
52
52
 
53
- themeConfig.includeTailwindConfigFromParentThemes ??= true;
54
53
  themeConfig.includeCssSourceFromParentThemes ??= true;
55
54
  themeConfig.ignoredCssFromModules ??= [];
56
- themeConfig.ignoredTailwindConfigFromModules ??= [];
57
55
  themeConfig.exposeNpmPackages ??= [];
58
- themeConfig.tailwind ??= {};
59
-
60
- if (themeConfig.tailwind?.content) {
61
- themeConfig.tailwind.content = themeConfig.tailwind.content.map((content) =>
62
- path.join(themeDefinition.src, "web", content)
63
- );
64
- }
65
-
66
- const includeParent = themeConfig.includeTailwindConfigFromParentThemes;
67
- if (includeParent && themeDefinition.parent) {
56
+ if (themeDefinition.parent) {
68
57
  const parentConfig = await getThemeConfig(themeDefinition.parent);
69
58
  themeConfig = deepmerge(parentConfig || {}, themeConfig);
70
59
  }
71
-
72
60
  themeConfigCache.set(themeName, themeConfig);
73
61
  return themeConfig;
74
62
  }
75
63
 
76
- export async function getTailwindThemeConfig(themeName) {
77
- const themeConfig = await getThemeConfig(themeName);
78
- return themeConfig?.tailwind ?? {};
79
- }
80
-
81
64
  // Export default con todo
82
65
  export default {
83
- getThemeConfig,
84
- getTailwindThemeConfig
66
+ getThemeConfig
85
67
  };
@@ -1,188 +0,0 @@
1
- import { jest } from '@jest/globals';
2
- import createMockConfigResolver from '../__mocks__/configResolver.js';
3
- import path from 'path';
4
- import {fileURLToPath} from 'url';
5
- const __filename = fileURLToPath(import.meta.url);
6
- const __dirname = path.dirname(__filename);
7
-
8
- let scenarios = [
9
- {
10
- scenario: 'a',
11
- themes: [
12
- {
13
- code: 'Vendor/theme-a',
14
- expected: {
15
- tailwind: {
16
- content: [
17
- 'magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/templates/**/*.phtml',
18
- 'magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/from_theme',
19
- 'magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/templates/**/*.phtml',
20
- 'magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/templates/**/*.phtml'
21
- ]
22
- }
23
- }
24
- },
25
- {
26
- code: 'Vendor/theme-b',
27
- expected: {
28
- tailwind: {
29
- content: [
30
- 'magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/templates/**/*.phtml',
31
- 'magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/web/from_theme',
32
- 'magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/templates/**/*.phtml',
33
- 'magento_scenarios/app/code/Vendor/ModuleNameC/view/frontend/templates/**/*.phtml'
34
- ]
35
- }
36
- }
37
- },
38
- {
39
- code: 'Vendor/theme-c',
40
- expected: {
41
- tailwind: {}
42
- }
43
- },
44
- {
45
- code: 'Vendor/theme-d',
46
- expected: {
47
- tailwind: {
48
- theme: { extend: {} },
49
- plugins: [],
50
- content: [
51
- 'magento_scenarios/app/code/Vendor/ModuleNameA/view/frontend/templates/**/*.phtml',
52
- 'magento_scenarios/app/code/Vendor/ModuleNameB/view/frontend/templates/**/*.phtml'
53
- ]
54
- }
55
- }
56
- }
57
- ]
58
- }
59
- ];
60
-
61
- for (const scenario of scenarios) {
62
- for (const themeKey in scenario.themes) {
63
- let theme = scenario.themes[themeKey];
64
- const expected = theme.expected;
65
- expected.tailwind.content = expected.tailwind.content?.map((content) => path.resolve(__dirname, '..', content));
66
- }
67
- }
68
-
69
-
70
- let scenariosForResolveFileByTheme = [
71
- {
72
- scenario: 'a',
73
- themes: [
74
- {
75
- code: 'Vendor/theme-a',
76
- moduleName: 'Vendor_ModuleNameA',
77
- filePath: 'module.config.js',
78
- includeTailwindConfigFromParentThemes: true,
79
- expected: 'magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/module.config.js'
80
- },
81
- {
82
- code: 'Vendor/theme-a',
83
- moduleName: 'Vendor_ModuleNameA',
84
- filePath: 'no_exist_file.js',
85
- includeTailwindConfigFromParentThemes: true,
86
- expected: null
87
- },
88
- {
89
- code: 'Vendor/theme-b',
90
- moduleName: 'Vendor_ModuleNameA',
91
- filePath: 'module.config.js',
92
- includeTailwindConfigFromParentThemes: true,
93
- expected: 'magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/module.config.js'
94
- },
95
- {
96
- code: 'Vendor/theme-b',
97
- moduleName: 'Vendor_ModuleNameA',
98
- filePath: 'module.config.js',
99
- includeTailwindConfigFromParentThemes: false,
100
- expected: null
101
- },
102
- {
103
- code: 'Vendor/theme-c',
104
- moduleName: 'Vendor_ModuleNameA',
105
- filePath: 'module.config.js',
106
- includeTailwindConfigFromParentThemes: true,
107
- expected: 'magento_scenarios/app/design/frontend/Vendor/theme-a/Vendor_ModuleNameA/web/module.config.js'
108
- },
109
- {
110
- code: 'Vendor/theme-c',
111
- moduleName: 'Vendor_ModuleNameB',
112
- filePath: 'module.config.js',
113
- includeTailwindConfigFromParentThemes: true,
114
- expected: 'magento_scenarios/app/design/frontend/Vendor/theme-c/Vendor_ModuleNameB/web/module.config.js'
115
- },
116
- {
117
- code: 'Vendor/theme-d',
118
- moduleName: 'Vendor_ModuleNameA',
119
- filePath: 'module.config.js',
120
- includeTailwindConfigFromParentThemes: false,
121
- expected: null
122
- },
123
- {
124
- code: 'Vendor/theme-d',
125
- moduleName: 'Vendor_ModuleNameA',
126
- filePath: 'module.config.js',
127
- includeTailwindConfigFromParentThemes: true,
128
- expected: null
129
- }
130
- ]
131
- }
132
- ];
133
-
134
- for (const scenario of scenariosForResolveFileByTheme) {
135
- for (const themeKey in scenario.themes) {
136
- let theme = scenario.themes[themeKey];
137
- if (!theme.expected) continue;
138
- theme.expected = path.resolve(__dirname, '..', theme.expected)
139
- }
140
- }
141
-
142
- async function setupResolvers(scenario) {
143
- jest.unstable_mockModule('#service/configResolver.js', () => ({
144
- __esModule: true,
145
- default: createMockConfigResolver(scenario).default,
146
- }));
147
- const moduleResolver = await import('#service/moduleResolver.js');
148
- const themeResolver = await import('#service/themeResolverSync.js');
149
-
150
- return { moduleResolver, themeResolver };
151
- }
152
-
153
-
154
- describe('getModuleConfigByThemeConfig', () => {
155
- beforeEach(() => {
156
- jest.resetModules();
157
- });
158
-
159
- test.each(scenarios.flatMap(({ scenario, themes }) =>
160
- themes.map(({ code, expected }) => ([ scenario, code, expected ]))
161
- ))('Scenario "%s", Theme "%s"', async ( scenario, code, expected ) => {
162
- const { themeResolver, moduleResolver } = await setupResolvers(scenario);
163
- const themeConfig = await themeResolver.getThemeConfig(code);
164
- const result = await moduleResolver.getModuleConfigByThemeConfig(code, themeConfig);
165
- expect(result).toEqual(
166
- expected,
167
- `Expected:\n${JSON.stringify(expected, null, 2)}\nReceived:\n${JSON.stringify(result, null, 2)}\nScenario: "${scenario}", Theme: "${code}"`
168
- );
169
- });
170
- });
171
-
172
- describe('resolveFileByTheme', () => {
173
- beforeEach(() => {
174
- jest.resetModules();
175
- });
176
-
177
- test.each(scenariosForResolveFileByTheme.flatMap(({ scenario, themes }) =>
178
- themes.map(({ code, expected, moduleName, filePath, includeTailwindConfigFromParentThemes }) => ([scenario, code, expected, moduleName, filePath, includeTailwindConfigFromParentThemes]))
179
- ))('Scenario "%s", Theme "%s"', async ( scenario, code, expected, moduleName, filePath, includeTailwindConfigFromParentThemes ) => {
180
- const { moduleResolver } = await setupResolvers(scenario);
181
- const result = await moduleResolver.resolveFileByTheme(code, moduleName, filePath, includeTailwindConfigFromParentThemes);
182
- expect(result).toEqual(
183
- expected,
184
- `Expected:\n${JSON.stringify(expected, null, 2)}\nReceived:\n${JSON.stringify(result, null, 2)}\nScenario: "${scenario}", Theme: "${code}"`
185
- );
186
- });
187
- });
188
-
@@ -1,11 +0,0 @@
1
- import themeResolver from './themeResolverSync.js';
2
- import moduleResolver from './moduleResolver.js';
3
- import deepmerge from 'deepmerge';
4
-
5
- export function getTailwindConfigByTheme(themeName) {
6
- const themeConfig = themeResolver.getThemeConfig(themeName);
7
- return deepmerge(
8
- moduleResolver.getModuleConfigByThemeConfig(themeName, themeConfig)?.tailwind ?? {},
9
- themeConfig?.tailwind ?? {}
10
- );
11
- }