UNPKG

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