UNPKG

10.1 kBJavaScriptView Raw
1"use strict";
2
3const p = require('path');
4
5const resolve = require('resolve'); // const printAST = require('ast-pretty-print')
6
7
8const macrosRegex = /[./]macro(\.c?js)?$/;
9
10const testMacrosRegex = v => macrosRegex.test(v); // https://stackoverflow.com/a/32749533/971592
11
12
13class MacroError extends Error {
14 constructor(message) {
15 super(message);
16 this.name = 'MacroError';
17 /* istanbul ignore else */
18
19 if (typeof Error.captureStackTrace === 'function') {
20 Error.captureStackTrace(this, this.constructor);
21 } else if (!this.stack) {
22 this.stack = new Error(message).stack;
23 }
24 }
25
26}
27
28let _configExplorer = null;
29
30function getConfigExplorer() {
31 return _configExplorer = _configExplorer || // Lazy load cosmiconfig since it is a relatively large bundle
32 require('cosmiconfig').cosmiconfigSync('babel-plugin-macros', {
33 searchPlaces: ['package.json', '.babel-plugin-macrosrc', '.babel-plugin-macrosrc.json', '.babel-plugin-macrosrc.yaml', '.babel-plugin-macrosrc.yml', '.babel-plugin-macrosrc.js', 'babel-plugin-macros.config.js'],
34 packageProp: 'babelMacros'
35 });
36}
37
38function createMacro(macro, options = {}) {
39 if (options.configName === 'options') {
40 throw new Error(`You cannot use the configName "options". It is reserved for babel-plugin-macros.`);
41 }
42
43 macroWrapper.isBabelMacro = true;
44 macroWrapper.options = options;
45 return macroWrapper;
46
47 function macroWrapper(args) {
48 const {
49 source,
50 isBabelMacrosCall
51 } = args;
52
53 if (!isBabelMacrosCall) {
54 throw new MacroError(`The macro you imported from "${source}" is being executed outside the context of compilation with babel-plugin-macros. ` + `This indicates that you don't have the babel plugin "babel-plugin-macros" configured correctly. ` + `Please see the documentation for how to configure babel-plugin-macros properly: ` + 'https://github.com/kentcdodds/babel-plugin-macros/blob/master/other/docs/user.md');
55 }
56
57 return macro(args);
58 }
59}
60
61function nodeResolvePath(source, basedir) {
62 return resolve.sync(source, {
63 basedir,
64 // This is here to support the package being globally installed
65 // read more: https://github.com/kentcdodds/babel-plugin-macros/pull/138
66 paths: [p.resolve(__dirname, '../../')]
67 });
68}
69
70function macrosPlugin(babel, // istanbul doesn't like the default of an object for the plugin options
71// but I think older versions of babel didn't always pass options
72// istanbul ignore next
73{
74 require: _require = require,
75 resolvePath = nodeResolvePath,
76 isMacrosName = testMacrosRegex,
77 ...options
78} = {}) {
79 function interopRequire(path) {
80 // eslint-disable-next-line import/no-dynamic-require
81 const o = _require(path);
82
83 return o && o.__esModule && o.default ? o.default : o;
84 }
85
86 return {
87 name: 'macros',
88 visitor: {
89 Program(progPath, state) {
90 progPath.traverse({
91 ImportDeclaration(path) {
92 const isMacros = looksLike(path, {
93 node: {
94 source: {
95 value: v => isMacrosName(v)
96 }
97 }
98 });
99
100 if (!isMacros) {
101 return;
102 }
103
104 const imports = path.node.specifiers.map(s => ({
105 localName: s.local.name,
106 importedName: s.type === 'ImportDefaultSpecifier' ? 'default' : s.imported.name
107 }));
108 const source = path.node.source.value;
109 const result = applyMacros({
110 path,
111 imports,
112 source,
113 state,
114 babel,
115 interopRequire,
116 resolvePath,
117 options
118 });
119
120 if (!result || !result.keepImports) {
121 path.remove();
122 }
123 },
124
125 VariableDeclaration(path) {
126 const isMacros = child => looksLike(child, {
127 node: {
128 init: {
129 callee: {
130 type: 'Identifier',
131 name: 'require'
132 },
133 arguments: args => args.length === 1 && isMacrosName(args[0].value)
134 }
135 }
136 });
137
138 path.get('declarations').filter(isMacros).forEach(child => {
139 const imports = child.node.id.name ? [{
140 localName: child.node.id.name,
141 importedName: 'default'
142 }] : child.node.id.properties.map(property => ({
143 localName: property.value.name,
144 importedName: property.key.name
145 }));
146 const call = child.get('init');
147 const source = call.node.arguments[0].value;
148 const result = applyMacros({
149 path: call,
150 imports,
151 source,
152 state,
153 babel,
154 interopRequire,
155 resolvePath,
156 options
157 });
158
159 if (!result || !result.keepImports) {
160 child.remove();
161 }
162 });
163 }
164
165 });
166 }
167
168 }
169 };
170} // eslint-disable-next-line complexity
171
172
173function applyMacros({
174 path,
175 imports,
176 source,
177 state,
178 babel,
179 interopRequire,
180 resolvePath,
181 options
182}) {
183 /* istanbul ignore next (pretty much only useful for astexplorer I think) */
184 const {
185 file: {
186 opts: {
187 filename = ''
188 }
189 }
190 } = state;
191 let hasReferences = false;
192 const referencePathsByImportName = imports.reduce((byName, {
193 importedName,
194 localName
195 }) => {
196 const binding = path.scope.getBinding(localName);
197 byName[importedName] = binding.referencePaths;
198 hasReferences = hasReferences || Boolean(byName[importedName].length);
199 return byName;
200 }, {});
201 const isRelative = source.indexOf('.') === 0;
202 const requirePath = resolvePath(source, p.dirname(getFullFilename(filename)));
203 const macro = interopRequire(requirePath);
204
205 if (!macro.isBabelMacro) {
206 throw new Error(`The macro imported from "${source}" must be wrapped in "createMacro" ` + `which you can get from "babel-plugin-macros". ` + `Please refer to the documentation to see how to do this properly: https://github.com/kentcdodds/babel-plugin-macros/blob/master/other/docs/author.md#writing-a-macro`);
207 }
208
209 const config = getConfig(macro, filename, source, options);
210 let result;
211
212 try {
213 /**
214 * Other plugins that run before babel-plugin-macros might use path.replace, where a path is
215 * put into its own replacement. Apparently babel does not update the scope after such
216 * an operation. As a remedy, the whole scope is traversed again with an empty "Identifier"
217 * visitor - this makes the problem go away.
218 *
219 * See: https://github.com/kentcdodds/import-all.macro/issues/7
220 */
221 state.file.scope.path.traverse({
222 Identifier() {}
223
224 });
225 result = macro({
226 references: referencePathsByImportName,
227 source,
228 state,
229 babel,
230 config,
231 isBabelMacrosCall: true
232 });
233 } catch (error) {
234 if (error.name === 'MacroError') {
235 throw error;
236 }
237
238 error.message = `${source}: ${error.message}`;
239
240 if (!isRelative) {
241 error.message = `${error.message} Learn more: https://www.npmjs.com/package/${source.replace( // remove everything after package name
242 // @org/package/macro -> @org/package
243 // package/macro -> package
244 /^((?:@[^/]+\/)?[^/]+).*/, '$1')}`;
245 }
246
247 throw error;
248 }
249
250 return result;
251}
252
253function getConfigFromFile(configName, filename) {
254 try {
255 const loaded = getConfigExplorer().search(filename);
256
257 if (loaded) {
258 return {
259 options: loaded.config[configName],
260 path: loaded.filepath
261 };
262 }
263 } catch (e) {
264 return {
265 error: e
266 };
267 }
268
269 return {};
270}
271
272function getConfigFromOptions(configName, options) {
273 if (options.hasOwnProperty(configName)) {
274 if (options[configName] && typeof options[configName] !== 'object') {
275 // eslint-disable-next-line no-console
276 console.error(`The macro plugin options' ${configName} property was not an object or null.`);
277 } else {
278 return {
279 options: options[configName]
280 };
281 }
282 }
283
284 return {};
285}
286
287function getConfig(macro, filename, source, options) {
288 const {
289 configName
290 } = macro.options;
291
292 if (configName) {
293 const fileConfig = getConfigFromFile(configName, filename);
294 const optionsConfig = getConfigFromOptions(configName, options);
295
296 if (optionsConfig.options === undefined && fileConfig.options === undefined && fileConfig.error !== undefined) {
297 // eslint-disable-next-line no-console
298 console.error(`There was an error trying to load the config "${configName}" ` + `for the macro imported from "${source}. ` + `Please see the error thrown for more information.`);
299 throw fileConfig.error;
300 }
301
302 if (fileConfig.options !== undefined && optionsConfig.options !== undefined && typeof fileConfig.options !== 'object') {
303 throw new Error(`${fileConfig.path} specified a ${configName} config of type ` + `${typeof optionsConfig.options}, but the the macros plugin's ` + `options.${configName} did contain an object. Both configs must ` + `contain objects for their options to be mergeable.`);
304 }
305
306 return { ...optionsConfig.options,
307 ...fileConfig.options
308 };
309 }
310
311 return undefined;
312}
313/*
314 istanbul ignore next
315 because this is hard to test
316 and not worth it...
317 */
318
319
320function getFullFilename(filename) {
321 if (p.isAbsolute(filename)) {
322 return filename;
323 }
324
325 return p.join(process.cwd(), filename);
326}
327
328function looksLike(a, b) {
329 return a && b && Object.keys(b).every(bKey => {
330 const bVal = b[bKey];
331 const aVal = a[bKey];
332
333 if (typeof bVal === 'function') {
334 return bVal(aVal);
335 }
336
337 return isPrimitive(bVal) ? bVal === aVal : looksLike(aVal, bVal);
338 });
339}
340
341function isPrimitive(val) {
342 // eslint-disable-next-line
343 return val == null || /^[sbn]/.test(typeof val);
344}
345
346module.exports = macrosPlugin;
347Object.assign(module.exports, {
348 createMacro,
349 MacroError
350});
\No newline at end of file