Merge branch 'MDL-66021-master' of git://github.com/peterRd/moodle
[moodle.git] / babel-plugin-add-module-to-define.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * This is a babel plugin to add the Moodle module names to the AMD modules
18  * as part of the transpiling process.
19  *
20  * In addition it will also add a return statement for the default export if the
21  * module is using default exports. This is a highly specific Moodle thing because
22  * we're transpiling to AMD and none of the existing Babel 7 plugins work correctly.
23  *
24  * This will fix the issue where an ES6 module using "export default Foo" will be
25  * transpiled into an AMD module that returns {default: Foo}; Instead it will now
26  * just simply return Foo.
27  *
28  * Note: This means all other named exports in that module are ignored and won't be
29  * exported.
30  *
31  * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
32  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33  */
35 "use strict";
37 module.exports = ({ template, types }) => {
38     const fs = require('fs');
39     const glob = require('glob');
40     const cwd = process.cwd();
42     // Static variable to hold the modules.
43     let moodleSubsystems = null;
44     let moodlePlugins = null;
46     /**
47      * Parse Moodle's JSON files containing the lists of components.
48      *
49      * The values are stored in the static variables because we
50      * only need to load them once per transpiling run.
51      */
52     function loadMoodleModules() {
53         moodleSubsystems = {'lib': 'core'};
54         moodlePlugins = {};
55         let components = fs.readFileSync('lib/components.json');
56         components = JSON.parse(components);
58         for (const [component, path] of Object.entries(components.subsystems)) {
59             if (path) {
60                 // Prefix "core_" to the front of the subsystems.
61                 moodleSubsystems[path] = `core_${component}`;
62             }
63         }
65         for (const [component, path] of Object.entries(components.plugintypes)) {
66             if (path) {
67                 moodlePlugins[path] = component;
68             }
69         }
71         for (const file of glob.sync('**/db/subplugins.json')) {
72             var rawContents = fs.readFileSync(file);
73             var subplugins = JSON.parse(rawContents);
75             for (const [component, path] of Object.entries(subplugins)) {
76                 if (path) {
77                     moodlePlugins[path] = component;
78                 }
79             }
80         }
81     }
83     /**
84      * Search the list of components that match the given file name
85      * and return the Moodle component for that file, if found.
86      *
87      * Throw an exception if no matching component is found.
88      *
89      * @throws {Error}
90      * @param {string} searchFileName The file name to look for.
91      * @return {string} Moodle component
92      */
93     function getModuleNameFromFileName(searchFileName) {
94         searchFileName = fs.realpathSync(searchFileName);
95         const relativeFileName = searchFileName.replace(`${cwd}/`, '');
96         const [componentPath, file] = relativeFileName.split('/amd/src/');
97         const fileName = file.replace('.js', '');
99         // Check subsystems first which require an exact match.
100         if (moodleSubsystems.hasOwnProperty(componentPath)) {
101             return `${moodleSubsystems[componentPath]}/${fileName}`;
102         }
104         // It's not a subsystem so it must be a plugin. Moodle defines root folders
105         // where plugins can be installed so our path with be <plugin_root>/<plugin_name>.
106         // Let's separate the two.
107         let pathParts = componentPath.split('/');
108         const pluginName = pathParts.pop();
109         const pluginPath = pathParts.join('/');
111         // The plugin path mutch match exactly because some plugins are subplugins of
112         // other plugins which means their paths would partially match.
113         if (moodlePlugins.hasOwnProperty(pluginPath)) {
114             return `${moodlePlugins[pluginPath]}_${pluginName}/${fileName}`;
115         }
117         // This matches the previous PHP behaviour that would throw an exception
118         // if it couldn't parse an AMD file.
119         throw new Error('Unable to find module name for ' + searchFileName);
120     }
122     // This is heavily inspired by the babel-plugin-add-module-exports plugin.
123     // See: https://github.com/59naga/babel-plugin-add-module-exports
124     //
125     // This is used when we detect a module using "export default Foo;" to make
126     // sure the transpiled code just returns Foo directly rather than an object
127     // with the default property (i.e. {default: Foo}).
128     //
129     // Note: This means that we can't support modules that combine named exports
130     // with a default export.
131     function addModuleExportsDefaults(path, exportObjectName) {
132         const rootPath = path.findParent(path => {
133             return path.key === 'body' || !path.parentPath;
134         });
136         // HACK: `path.node.body.push` instead of path.pushContainer(due doesn't work in Plugin.post).
137         // This is hardcoded to work specifically with AMD.
138         rootPath.node.body.push(template(`return ${exportObjectName}.default`)())
139     }
141     return {
142         pre() {
143             this.seenDefine = false;
144             this.addedReturnForDefaultExport = false;
146             if (moodleSubsystems === null) {
147                 loadMoodleModules();
148             }
149         },
150         visitor: {
151             // Plugin ordering is only respected if we visit the "Program" node.
152             // See: https://babeljs.io/docs/en/plugins.html#plugin-preset-ordering
153             //
154             // We require this to run after the other AMD module transformation so
155             // let's visit the "Program" node.
156             Program: {
157                 exit(path) {
158                     path.traverse({
159                         CallExpression(path) {
160                             // If we find a "define" function call.
161                             if (!this.seenDefine && path.get('callee').isIdentifier({name: 'define'})) {
162                                 // We only want to modify the first instance of define that we find.
163                                 this.seenDefine = true;
164                                 // Get the Moodle component for the file being processed.
165                                 var moduleName = getModuleNameFromFileName(this.file.opts.filename);
166                                 // Add the module name as the first argument to the define function.
167                                 path.node.arguments.unshift(types.stringLiteral(moduleName));
168                                 // Add a space after the define function in the built file so that previous versions
169                                 // of Moodle will not try to add the module name to the file when it's being served
170                                 // by PHP. This forces the regex in PHP to not match for this file.
171                                 path.node.callee.name = 'define ';
172                             }
174                             // Check for any Object.defineProperty('exports', 'default') calls.
175                             if (!this.addedReturnForDefaultExport && path.get('callee').matchesPattern('Object.defineProperty')) {
176                                 const [identifier, prop] = path.get('arguments')
177                                 const objectName = identifier.get('name').node
178                                 const propertyName = prop.get('value').node
180                                 if ((objectName === 'exports' || objectName === '_exports') && propertyName === 'default') {
181                                     addModuleExportsDefaults(path, objectName);
182                                     this.addedReturnForDefaultExport = true;
183                                 }
184                             }
185                         },
186                         AssignmentExpression(path) {
187                             // Check for an exports.default assignments.
188                             if (
189                                 !this.addedReturnForDefaultExport &&
190                                 (
191                                     path.get('left').matchesPattern('exports.default') ||
192                                     path.get('left').matchesPattern('_exports.default')
193                                 )
194                             ) {
195                                 const objectName = path.get('left.object.name').node;
196                                 addModuleExportsDefaults(path, objectName);
197                                 this.addedReturnForDefaultExport = true;
198                             }
199                         }
200                     }, this);
201                 }
202             }
203         }
204     };
205 };