UNPKG

13.2 kBJavaScriptView Raw
1/**
2 * @overview
3 * Analyzes Titanium JavaScript files for symbols and optionally minifies the code.
4 *
5 * @module lib/jsanalyze
6 *
7 * @copyright
8 * Copyright (c) 2009-Present by Appcelerator, Inc. All Rights Reserved.
9 *
10 * @license
11 * Licensed under the terms of the Apache Public License
12 * Please see the LICENSE included with this distribution for details.
13 */
14'use strict';
15
16const appc = require('node-appc');
17const fs = require('fs-extra');
18const DOMParser = require('xmldom').DOMParser;
19const babel = require('@babel/core');
20const babylon = require('@babel/parser');
21const minify = require('babel-preset-minify');
22const env = require('@babel/preset-env');
23const apiTracker = require('./babel-plugins/ti-api');
24const path = require('path');
25
26const SOURCE_MAPPING_URL_REGEXP = /\/\/#[ \t]+sourceMappingURL=([^\s'"`]+?)[ \t]*$/mg;
27const __ = appc.i18n(__dirname).__;
28
29/**
30 * Returns an object with the Titanium API usage statistics.
31 *
32 * @returns {Object} The API usage stats
33 */
34exports.getAPIUsage = function getAPIUsage() {
35 return apiTracker.apiUsage;
36};
37
38/**
39 * Analyzes a Titanium JavaScript file for all Titanium API symbols.
40 *
41 * @param {String} file - The full path to the JavaScript file
42 * @param {Object} [opts] - Analyze options
43 * @param {Boolean} [opts.minify=false] - If true, minifies the JavaScript and returns it
44 * @param {String} [opts.dest] full filepath of the destination JavaScript file we'll write the contents to
45 * @param {Boolean} [opts.minify=false] - If true, minifies the JavaScript and returns it
46 * @param {Boolean} [opts.transpile=false] - If true, transpiles the JS code and retuns it
47 * @param {Array} [opts.plugins=[]] - An array of resolved Babel plugins
48 * @param {Function} [opts.logger] - Logger instance to use for logging warnings.
49 * @param {object} [opts.transform={}] - object holding static values about the app/platform/build for the babel titanium transform plugin
50 * @returns {Object} An object containing symbols and minified JavaScript
51 * @throws {Error} An error if unable to parse the JavaScript
52 */
53exports.analyzeJsFile = function analyzeJsFile(file, opts = {}) {
54 opts.filename = file;
55 return exports.analyzeJs(fs.readFileSync(file).toString(), opts);
56};
57
58/**
59 * Analyzes a string containing JavaScript for all Titanium API symbols.
60 *
61 * @param {String} contents - A string of JavaScript
62 * @param {Object} [opts] - Analyze options
63 * @param {String} [opts.filename] - The filename of the original JavaScript source
64 * @param {String} [opts.dest] full filepath of the destination JavaScript file we'll write the contents to
65 * @param {Boolean} [opts.minify=false] - If true, minifies the JavaScript and returns it
66 * @param {Boolean} [opts.transpile=false] - If true, transpiles the JS code and retuns it
67 * @param {Array} [opts.plugins=[]] - An array of resolved Babel plugins
68 * @param {Function} [opts.logger] - Logger instance to use for logging warnings.
69 * @param {object} [opts.transform={}] - object holding static values about the app/platform/build for the babel titanium transform plugin
70 * @returns {Object} An object containing symbols and minified JavaScript
71 * @throws {Error} An error if unable to parse the JavaScript
72 */
73exports.analyzeJs = function analyzeJs(contents, opts = {}) {
74 opts.plugins || (opts.plugins = []);
75 opts.transform || (opts.transform = {});
76
77 // parse the js file
78 let ast;
79 const parserOpts = {
80 sourceType: 'unambiguous',
81 filename: opts.filename
82 };
83 try {
84 try {
85 ast = babylon.parse(contents, parserOpts);
86 } catch (err) {
87 // fall back to much looser parsing
88 parserOpts.allowReturnOutsideFunction = true;
89 ast = babylon.parse(contents, parserOpts);
90 }
91 } catch (ex) {
92 const errmsg = [ __('Failed to parse %s', opts.filename) ];
93 if (ex.line) {
94 errmsg.push(__('%s [line %s, column %s]', ex.message, ex.line, ex.col));
95 } else {
96 errmsg.push(ex.message);
97 }
98 try {
99 contents = contents.split('\n');
100 if (ex.line && ex.line <= contents.length) {
101 errmsg.push('');
102 errmsg.push(' ' + contents[ex.line - 1].replace(/\t/g, ' '));
103 if (ex.col) {
104 var i = 0,
105 len = ex.col,
106 buffer = ' ';
107 for (; i < len; i++) {
108 buffer += '-';
109 }
110 errmsg.push(buffer + '^');
111 }
112 errmsg.push('');
113 }
114 } catch (ex2) {} // eslint-disable-line no-empty
115 throw new Error(errmsg.join('\n'));
116 }
117
118 const results = {
119 original: contents,
120 contents: contents,
121 symbols: [] // apiTracker plugin will gather these!
122 };
123
124 const options = {
125 filename: opts.filename,
126 retainLines: true,
127 presets: [],
128 plugins: [
129 [ apiTracker, { skipStats: opts.skipStats } ] // track our API usage no matter what
130 ],
131 parserOpts
132 };
133
134 // transpile
135 if (opts.transpile) {
136 options.plugins.push(require.resolve('./babel-plugins/global-this'));
137 options.plugins.push([ require.resolve('babel-plugin-transform-titanium'), opts.transform ]);
138 options.presets.push([ env, { targets: opts.targets } ]);
139 }
140
141 // minify
142 if (opts.minify) {
143 Object.assign(options, {
144 minified: true,
145 compact: true,
146 comments: false
147 });
148
149 options.presets.push([ minify, {
150 mangle: false,
151 deadcode: false
152 } ]);
153
154 options.plugins.push(require.resolve('@babel/plugin-transform-property-literals'));
155 }
156
157 if (opts.plugins.length) {
158 options.plugins.push.apply(options.plugins, opts.plugins);
159 }
160
161 let sourceFileName = opts.filename; // used to point to correct original source in sources/sourceRoot of final source map
162 // generate a source map
163 if (opts.sourceMap) {
164 // we manage the source maps ourselves rather than just choose 'inline'
165 // because we need to do some massaging
166 options.sourceMaps = true;
167
168 // If the original file already has a source map, load it so we can pass it along to babel
169 const mapResults = findSourceMap(contents, opts.filename);
170 if (mapResults) {
171 const existingMap = mapResults.map;
172 // location we should try and resolve sources against (after sourceRoot)
173 // this may be a pointer to the external .map file, or point to the source file where the map was inlined
174 // Note that the spec is pretty ambiguous about this (relative to the SourceMap) and I think Safari may try relative to
175 // the original sourceURL too?
176 const mapFile = mapResults.filepath;
177
178 // Choose last entry in 'sources' as the one we'll report as original sourceURL
179 sourceFileName = existingMap.sources[existingMap.sources.length - 1];
180
181 // If the existing source map has a source root, we need to combine it with source path to get full filepath
182 if (existingMap.sourceRoot) {
183 // source root may be a filepath, file URI or URL. We assume file URI/path here
184 if (existingMap.sourceRoot.startsWith('file://')) {
185 existingMap.sourceRoot = existingMap.sourceRoot.slice(7);
186 }
187 sourceFileName = path.resolve(existingMap.sourceRoot, sourceFileName);
188 }
189 // if sourceFilename is still not absolute, resolve relative to map file
190 sourceFileName = path.resolve(path.dirname(mapFile), sourceFileName);
191
192 // ok, we've mangled the source map enough for babel to consume it
193 options.inputSourceMap = existingMap;
194 }
195 }
196 const transformed = babel.transformFromAstSync(ast, contents, options);
197
198 // if the file is ignored by babel config, transformed will be null
199 if (transformed && transformed.code) {
200
201 results.contents = transformed.code;
202
203 if (opts.sourceMap) {
204 // Drop the original sourceMappingURL comment (for alloy files)
205 results.contents = results.contents.replace(SOURCE_MAPPING_URL_REGEXP, '');
206
207 // Point the sourceRoot at the original dir (so the full filepath of the original source can be gleaned from sources/sourceRoot combo)
208 // we already have a sourceRoot in the case of an input source map - so don't override that
209 if (!transformed.map.sourceRoot) {
210 transformed.map.sourceRoot = path.dirname(sourceFileName);
211 }
212 // Android / Chrome DevTools is sane and can load the source from disk
213 // so we can ditch inlining the original source there (I'm looking at you Safari/WebInspector!)
214 if (opts.platform === 'android') {
215 // NOTE that this will drop some of the alloy template code too. If we want users to see the original source from that, don't delete this!
216 // Or alternatively, make alloy report the real template filename in it's source mapping
217 delete transformed.map.sourcesContent;
218 } else {
219 // if they didn't specify the final filepath, assume the js file's base name won't change from the source one (it shouldn't)
220 const generatedBasename = path.basename(opts.dest || opts.filename);
221 transformed.map.sources = transformed.map.sources.map(s => {
222 const sourceBasename = path.basename(s);
223 return sourceBasename === generatedBasename ? sourceBasename : s;
224 });
225 // FIXME: on iOS/Safari - If there are multiple sources, any sources whose basename matches the generated file basename
226 // will get their parent path listed as a folder, but WILL NOT SHOW THE FILE!
227 // How in the world do we fix this?
228 // Well, if there's only one source with the same basename and we rename it to just the basename, that "fixes" the Web Inspector display
229 // IDEA: What if we fix iOS to report URIs like /app.js as Android does, will that make it behave more properly?
230 }
231
232 // Do our own inlined source map so we can have control over the map object that is written!
233 const base64Contents = Buffer.from(JSON.stringify(transformed.map)).toString('base64');
234 // NOTE: We MUST append a \n or else iOS will break, because it injects a ';' after the
235 // original file contents when doing it's require() impl
236 // NOTE: Construction of the source-map url needs to be obfuscated to avoid unwanted
237 // detection of this line as an actual source-map by `source-map-support` package.
238 const sourceMapPrefix = 'sourceMappingURL=data:application/json;charset=utf-8;base64';
239 results.contents += `\n//# ${sourceMapPrefix},${base64Contents}\n`;
240 }
241 }
242 results.symbols = Array.from(apiTracker.symbols.values()); // convert Set values to Array
243
244 return results;
245};
246
247/**
248 * @param {string} contents source code to check
249 * @param {string} filepath original absolute path to JS file the contents came from
250 * @returns {object}
251 */
252function findSourceMap(contents, filepath) {
253 const m = SOURCE_MAPPING_URL_REGEXP.exec(contents);
254 if (!m) {
255 return null;
256 }
257
258 let lastMatch = m.pop();
259 // HANDLE inlined data: source maps!
260 if (lastMatch.startsWith('data:')) {
261 const parts = lastMatch.split(';');
262 const contents = parts[2];
263 if (contents.startsWith('base64,')) {
264 const map = JSON.parse(Buffer.from(contents.slice(7), 'base64').toString('utf-8'));
265 return {
266 map,
267 filepath
268 };
269 }
270 // if starts with file://, drop that
271 } else if (lastMatch.startsWith('file://')) {
272 lastMatch = lastMatch.slice(7);
273 }
274 // resolve filepath relative to the original input JS file if we need to...
275 const mapFile = path.resolve(path.dirname(filepath), lastMatch);
276
277 try {
278 const map = fs.readJSONSync(mapFile);
279 return {
280 map,
281 filepath: mapFile
282 };
283 } catch (err) {
284 return null;
285 }
286}
287
288/**
289 * Analyzes an HTML file for all app:// JavaScript files
290 *
291 * @param {String} file - The full path to the HTML file
292 * @param {String} [relPath] - A relative path to the HTML file with respect to the Resources directory
293 * @returns {Array} An array of app:// JavaScript files
294 */
295exports.analyzeHtmlFile = function analyzeHtmlFile(file, relPath) {
296 return exports.analyzeHtml(fs.readFileSync(file).toString(), relPath);
297};
298
299/**
300 * Analyzes a string containing JavaScript for all Titanium API symbols.
301 *
302 * @param {String} contents - A string of JavaScript
303 * @param {String} [relPath] - A relative path to the HTML file with respect to the Resources directory
304 * @returns {Array} An array of app:// JavaScript files
305 */
306exports.analyzeHtml = function analyzeHtml(contents, relPath) {
307 const files = [];
308
309 function addFile(src) {
310 const m = src && src.match(/^(?:(.*):\/\/)?(.+)/);
311 let res = m && m[2];
312 if (res) {
313 if (!m[1]) {
314 if (relPath && res.indexOf('/') !== 0) {
315 res = relPath.replace(/\/$/, '') + '/' + res;
316 }
317
318 // compact the path
319 const p = res.split(/\/|\\/);
320 const r = [];
321 let q;
322 while (q = p.shift()) {
323 if (q === '..') {
324 r.pop();
325 } else {
326 r.push(q);
327 }
328 }
329
330 files.push(r.join('/'));
331 } else if (m[1] === 'app') {
332 files.push(res);
333 }
334 }
335 }
336
337 try {
338 const dom = new DOMParser({ errorHandler: function () {} }).parseFromString('<temp>\n' + contents + '\n</temp>', 'text/html'),
339 doc = dom && dom.documentElement,
340 scripts = doc && doc.getElementsByTagName('script'),
341 len = scripts.length;
342
343 if (scripts) {
344 for (let i = 0; i < len; i++) {
345 const src = scripts[i].getAttribute('src');
346 src && addFile(src);
347 }
348 }
349 } catch (e) {
350 // bad html file, try to manually parse out the script tags
351 contents.split('<script').slice(1).forEach(function (chunk) {
352 const p = chunk.indexOf('>');
353 if (p !== -1) {
354 let m = chunk.substring(0, p).match(/src\s*=\s*['"]([^'"]+)/);
355 if (!m) {
356 // try again without the quotes
357 m = chunk.substring(0, p).match(/src\s*=\s*([^>\s]+)/);
358 }
359 m && addFile(m[1]);
360 }
361 });
362 }
363
364 return files;
365};