UNPKG

10.1 kBJavaScriptView Raw
1import { init as initESModuleLexer, parse } from 'es-module-lexer';
2import fs from 'fs';
3import glob from 'glob';
4import * as colors from 'kleur/colors';
5import mime from 'mime-types';
6import nodePath from 'path';
7import stripComments from 'strip-comments';
8import validatePackageName from 'validate-npm-package-name';
9import { isTruthy, findMatchingMountScript, HTML_JS_REGEX, getExt } from './util';
10const WEB_MODULES_TOKEN = 'web_modules/';
11const WEB_MODULES_TOKEN_LENGTH = WEB_MODULES_TOKEN.length;
12// [@\w] - Match a word-character or @ (valid package name)
13// (?!.*(:\/\/)) - Ignore if previous match was a protocol (ex: http://)
14const BARE_SPECIFIER_REGEX = /^[@\w](?!.*(:\/\/))/;
15const ESM_IMPORT_REGEX = /import(?:["'\s]*([\w*${}\n\r\t, ]+)\s*from\s*)?\s*["'](.*?)["']/gm;
16const ESM_DYNAMIC_IMPORT_REGEX = /import\((?:['"].+['"]|`[^$]+`)\)/gm;
17const HAS_NAMED_IMPORTS_REGEX = /^[\w\s\,]*\{(.*)\}/s;
18const SPLIT_NAMED_IMPORTS_REGEX = /\bas\s+\w+|,/s;
19const DEFAULT_IMPORT_REGEX = /import\s+(\w)+(,\s\{[\w\s]*\})?\s+from/s;
20function stripJsExtension(dep) {
21 return dep.replace(/\.m?js$/i, '');
22}
23function createInstallTarget(specifier, all = true) {
24 return {
25 specifier,
26 all,
27 default: false,
28 namespace: false,
29 named: [],
30 };
31}
32function removeSpecifierQueryString(specifier) {
33 const queryStringIndex = specifier.indexOf('?');
34 if (queryStringIndex >= 0) {
35 specifier = specifier.substring(0, queryStringIndex);
36 }
37 return specifier;
38}
39function getWebModuleSpecifierFromCode(code, imp) {
40 // import.meta: we can ignore
41 if (imp.d === -2) {
42 return null;
43 }
44 // Static imports: easy to parse
45 if (imp.d === -1) {
46 return code.substring(imp.s, imp.e);
47 }
48 // Dynamic imports: a bit trickier to parse. Today, we only support string literals.
49 const importStatement = code.substring(imp.s, imp.e);
50 const importSpecifierMatch = importStatement.match(/^\s*['"](.*)['"]\s*$/m);
51 return importSpecifierMatch ? importSpecifierMatch[1] : null;
52}
53/**
54 * parses an import specifier, looking for a web modules to install. If a web module is not detected,
55 * null is returned.
56 */
57function parseWebModuleSpecifier(specifier) {
58 if (!specifier) {
59 return null;
60 }
61 // If specifier is a "bare module specifier" (ie: package name) just return it directly
62 if (BARE_SPECIFIER_REGEX.test(specifier)) {
63 return specifier;
64 }
65 // Clean the specifier, remove any query params that may mess with matching
66 const cleanedSpecifier = removeSpecifierQueryString(specifier);
67 // Otherwise, check that it includes the "web_modules/" directory
68 const webModulesIndex = cleanedSpecifier.indexOf(WEB_MODULES_TOKEN);
69 if (webModulesIndex === -1) {
70 return null;
71 }
72 // Check if this matches `@scope/package.js` or `package.js` format.
73 // If it is, assume that this is a top-level pcakage that should be installed without the “.js”
74 const resolvedSpecifier = cleanedSpecifier.substring(webModulesIndex + WEB_MODULES_TOKEN_LENGTH);
75 const resolvedSpecifierWithoutExtension = stripJsExtension(resolvedSpecifier);
76 if (validatePackageName(resolvedSpecifierWithoutExtension).validForNewPackages) {
77 return resolvedSpecifierWithoutExtension;
78 }
79 // Otherwise, this is an explicit import to a file within a package.
80 return resolvedSpecifier;
81}
82function parseImportStatement(code, imp) {
83 const webModuleSpecifier = parseWebModuleSpecifier(getWebModuleSpecifierFromCode(code, imp));
84 if (!webModuleSpecifier) {
85 return null;
86 }
87 const importStatement = code.substring(imp.ss, imp.se);
88 if (/^import\s+type/.test(importStatement)) {
89 return null;
90 }
91 const isDynamicImport = imp.d > -1;
92 const hasDefaultImport = !isDynamicImport && DEFAULT_IMPORT_REGEX.test(importStatement);
93 const hasNamespaceImport = !isDynamicImport && importStatement.includes('*');
94 const namedImports = (importStatement.match(HAS_NAMED_IMPORTS_REGEX) || [, ''])[1]
95 .split(SPLIT_NAMED_IMPORTS_REGEX)
96 .map((name) => name.trim())
97 .filter(isTruthy);
98 return {
99 specifier: webModuleSpecifier,
100 all: isDynamicImport || (!hasDefaultImport && !hasNamespaceImport && namedImports.length === 0),
101 default: hasDefaultImport,
102 namespace: hasNamespaceImport,
103 named: namedImports,
104 };
105}
106function cleanCodeForParsing(code) {
107 code = stripComments(code);
108 const allMatches = [];
109 let match;
110 const importRegex = new RegExp(ESM_IMPORT_REGEX);
111 while ((match = importRegex.exec(code))) {
112 allMatches.push(match);
113 }
114 const dynamicImportRegex = new RegExp(ESM_DYNAMIC_IMPORT_REGEX);
115 while ((match = dynamicImportRegex.exec(code))) {
116 allMatches.push(match);
117 }
118 return allMatches.map(([full]) => full).join('\n');
119}
120function parseCodeForInstallTargets({ locOnDisk, baseExt, code, }) {
121 let imports;
122 // Attempt #1: Parse the file as JavaScript. JSX and some decorator
123 // syntax will break this.
124 try {
125 if (baseExt === '.jsx' || baseExt === '.tsx') {
126 // We know ahead of time that this will almost certainly fail.
127 // Just jump right to the secondary attempt.
128 throw new Error('JSX must be cleaned before parsing');
129 }
130 [imports] = parse(code) || [];
131 }
132 catch (err) {
133 // Attempt #2: Parse only the import statements themselves.
134 // This lets us guarentee we aren't sending any broken syntax to our parser,
135 // but at the expense of possible false +/- caused by our regex extractor.
136 try {
137 code = cleanCodeForParsing(code);
138 [imports] = parse(code) || [];
139 }
140 catch (err) {
141 // Another error! No hope left, just abort.
142 console.error(colors.red(`! ${locOnDisk}`));
143 throw err;
144 }
145 }
146 const allImports = imports
147 .map((imp) => parseImportStatement(code, imp))
148 .filter(isTruthy)
149 // Babel macros are not install targets!
150 .filter((imp) => !/[./]macro(\.js)?$/.test(imp.specifier));
151 return allImports;
152}
153export function scanDepList(depList, cwd) {
154 return depList
155 .map((whitelistItem) => {
156 if (!glob.hasMagic(whitelistItem)) {
157 return [createInstallTarget(whitelistItem, true)];
158 }
159 else {
160 const nodeModulesLoc = nodePath.join(cwd, 'node_modules');
161 return scanDepList(glob.sync(whitelistItem, { cwd: nodeModulesLoc, nodir: true }), cwd);
162 }
163 })
164 .reduce((flat, item) => flat.concat(item), []);
165}
166export async function scanImports(cwd, config) {
167 await initESModuleLexer;
168 const includeFileSets = await Promise.all(config.scripts.map(({ type, args }) => {
169 if (type !== 'mount') {
170 return [];
171 }
172 if (args.fromDisk.includes('web_modules')) {
173 return [];
174 }
175 const dirDisk = nodePath.resolve(cwd, args.fromDisk);
176 return glob.sync(`**/*`, {
177 ignore: config.exclude.concat(['**/web_modules/**/*']),
178 cwd: dirDisk,
179 absolute: true,
180 nodir: true,
181 });
182 }));
183 const includeFiles = Array.from(new Set([].concat.apply([], includeFileSets)));
184 if (includeFiles.length === 0) {
185 return [];
186 }
187 // Scan every matched JS file for web dependency imports
188 const loadedFiles = await Promise.all(includeFiles.map(async (filePath) => {
189 const { baseExt, expandedExt } = getExt(filePath);
190 // Always ignore dotfiles
191 if (filePath.startsWith('.')) {
192 return null;
193 }
194 switch (baseExt) {
195 // Probably a license, a README, etc
196 case '': {
197 return null;
198 }
199 // Our import scanner can handle normal JS & even TypeScript without a problem.
200 case '.js':
201 case '.jsx':
202 case '.mjs':
203 case '.ts':
204 case '.tsx': {
205 return {
206 baseExt,
207 expandedExt,
208 locOnDisk: filePath,
209 code: await fs.promises.readFile(filePath, 'utf-8'),
210 };
211 }
212 case '.html':
213 case '.vue':
214 case '.svelte': {
215 const result = await fs.promises.readFile(filePath, 'utf-8');
216 // TODO: Replace with matchAll once Node v10 is out of TLS.
217 // const allMatches = [...result.matchAll(new RegExp(HTML_JS_REGEX))];
218 const allMatches = [];
219 let match;
220 const regex = new RegExp(HTML_JS_REGEX);
221 while ((match = regex.exec(result))) {
222 allMatches.push(match);
223 }
224 return {
225 baseExt,
226 expandedExt,
227 locOnDisk: filePath,
228 // match[2] is the code inside the <script></script> element
229 code: allMatches
230 .map((match) => match[2])
231 .filter((s) => s.trim())
232 .join('\n'),
233 };
234 }
235 }
236 // If we don't recognize the file type, it could be source. Warn just in case.
237 if (!mime.lookup(baseExt)) {
238 console.warn(colors.dim(`ignoring unsupported file "${nodePath.relative(process.cwd(), filePath)}"`));
239 }
240 return null;
241 }));
242 return scanImportsFromFiles(loadedFiles.filter(isTruthy), config);
243}
244export async function scanImportsFromFiles(loadedFiles, { scripts }) {
245 return (loadedFiles
246 .map(parseCodeForInstallTargets)
247 .reduce((flat, item) => flat.concat(item), [])
248 // Ignore source imports that match a mount directory.
249 .filter((target) => !findMatchingMountScript(scripts, target.specifier))
250 .sort((impA, impB) => impA.specifier.localeCompare(impB.specifier)));
251}