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 | /**
|
30 | * Returns an object with the Titanium API usage statistics.
|
31 | *
|
32 | * @returns {Object} The API usage stats
|
33 | */
|
34 | exports.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 | */
|
53 | exports.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 | */
|
73 | exports.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 | */
|
252 | function 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 | */
|
295 | exports.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 | */
|
306 | exports.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 | };
|