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 | ;
|
15 |
|
16 | const appc = require('node-appc');
|
17 | const fs = require('fs-extra');
|
18 | const DOMParser = require('xmldom').DOMParser;
|
19 | const babel = require('@babel/core');
|
20 | const babylon = require('@babel/parser');
|
21 | const minify = require('babel-preset-minify');
|
22 | const env = require('@babel/preset-env');
|
23 | const apiTracker = require('./babel-plugins/ti-api');
|
24 | const path = require('path');
|
25 |
|
26 | const SOURCE_MAPPING_URL_REGEXP = /\/\/#[ \t]+sourceMappingURL=([^\s'"`]+?)[ \t]*$/mg;
|
27 | const __ = appc.i18n(__dirname).__;
|
28 |
|
29 | function sortObject(o) {
|
30 | const sorted = {};
|
31 | for (const key of Object.keys(o).sort()) {
|
32 | sorted[key] = o[key];
|
33 | }
|
34 | return sorted;
|
35 | }
|
36 | exports.sortObject = sortObject;
|
37 |
|
38 | /**
|
39 | * Returns an object with the Titanium API usage statistics.
|
40 | *
|
41 | * @returns {Object} The API usage stats
|
42 | */
|
43 | exports.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 | */
|
62 | exports.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 | */
|
82 | exports.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 | */
|
261 | function 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 | */
|
304 | exports.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 | */
|
315 | exports.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 | };
|