@davidwells/extract-deps 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/dep-graph.js +97 -13
- package/src/get-cjs-exports.js +5 -3
- package/src/get-exports.js +4 -2
- package/src/get-imports.js +2 -0
- package/src/get-requires.js +43 -1
- package/src/index.js +29 -0
- package/src/utils.js +216 -58
- package/tests/dep-graph.test.js +56 -1
- package/tests/fixtures/ts-regex-export-fixture/index.ts +8 -0
- package/tests/fixtures/ts-regex-export-fixture/message-builder.ts +23 -0
- package/tests/get-exports.test.js +27 -2
- package/tests/index.test.js +11 -0
- package/tests/parser-edge-cases.test.js +101 -0
- package/tests/utils.test.js +8 -0
- package/types/dep-graph.d.ts +45 -0
- package/types/dep-graph.d.ts.map +1 -0
- package/types/detect-syntax.d.ts +39 -0
- package/types/detect-syntax.d.ts.map +1 -0
- package/types/get-cjs-exports.d.ts +45 -0
- package/types/get-cjs-exports.d.ts.map +1 -0
- package/types/get-exports.d.ts +54 -0
- package/types/get-exports.d.ts.map +1 -0
- package/types/get-imports.d.ts +59 -0
- package/types/get-imports.d.ts.map +1 -0
- package/types/get-requires.d.ts +37 -0
- package/types/get-requires.d.ts.map +1 -0
- package/types/index.d.ts +25 -0
- package/types/index.d.ts.map +1 -0
- package/types/utils.d.ts +76 -0
- package/types/utils.d.ts.map +1 -0
- package/types/vendor/inspect-parameters-declaration.d.ts +4 -0
- package/types/vendor/inspect-parameters-declaration.d.ts.map +1 -0
- package/types/verify-package-entries.d.ts +30 -0
- package/types/verify-package-entries.d.ts.map +1 -0
package/package.json
CHANGED
package/src/dep-graph.js
CHANGED
|
@@ -3,9 +3,82 @@ const path = require('path')
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const { pathToFileURL, fileURLToPath } = require('url')
|
|
5
5
|
const { resolve: resolveImport } = require('@dual-bundle/import-meta-resolve')
|
|
6
|
-
const { extractDeps } = require('./index')
|
|
6
|
+
const { extractDepsShared: extractDeps } = require('./index')
|
|
7
7
|
const { isBuiltIn } = require('./utils')
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Parsed deps per file, keyed on mtime + size: callers walk the same shared
|
|
11
|
+
* files for every entry point (and a walk parses each import target twice:
|
|
12
|
+
* to verify the importer's names, then to recurse). Results are read-only.
|
|
13
|
+
* @type {Map<string, { key: string, deps: ReturnType<typeof extractDeps> }>}
|
|
14
|
+
*/
|
|
15
|
+
const parseCache = new Map()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} filePath
|
|
19
|
+
* @returns {ReturnType<typeof extractDeps>}
|
|
20
|
+
*/
|
|
21
|
+
function readDeps(filePath) {
|
|
22
|
+
const stat = fs.statSync(filePath)
|
|
23
|
+
const key = `${stat.mtimeMs}:${stat.size}`
|
|
24
|
+
const cached = parseCache.get(filePath)
|
|
25
|
+
if (cached && cached.key === key) return cached.deps
|
|
26
|
+
const deps = extractDeps(fs.readFileSync(filePath, 'utf8'))
|
|
27
|
+
parseCache.set(filePath, { key, deps })
|
|
28
|
+
return deps
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, string | null>} `${baseDir}\0${importPath}` -> resolved file (or null) */
|
|
32
|
+
const relativeResolveCache = new Map()
|
|
33
|
+
/** @type {Map<string, { url: string } | { error: Error }>} `${parentDir}\0${specifier}` -> package resolution */
|
|
34
|
+
const packageResolveCache = new Map()
|
|
35
|
+
/** @type {Map<string, string>} path -> realpath */
|
|
36
|
+
const realpathCache = new Map()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* fs.realpathSync, memoized per path.
|
|
40
|
+
* @param {string} filePath
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function realpath(filePath) {
|
|
44
|
+
let real = realpathCache.get(filePath)
|
|
45
|
+
if (real === undefined) {
|
|
46
|
+
real = fs.realpathSync(filePath)
|
|
47
|
+
realpathCache.set(filePath, real)
|
|
48
|
+
}
|
|
49
|
+
return real
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* resolveImport (import-meta-resolve) memoized per importing directory: a
|
|
54
|
+
* bare specifier resolves the same from every file in one directory.
|
|
55
|
+
* @param {string} specifier
|
|
56
|
+
* @param {string} parentPath - Importing file
|
|
57
|
+
* @returns {string} resolved URL
|
|
58
|
+
*/
|
|
59
|
+
function resolvePackageImport(specifier, parentPath) {
|
|
60
|
+
const key = `${path.dirname(parentPath)}\0${specifier}`
|
|
61
|
+
let cached = packageResolveCache.get(key)
|
|
62
|
+
if (!cached) {
|
|
63
|
+
try {
|
|
64
|
+
cached = { url: resolveImport(specifier, pathToFileURL(parentPath).href) }
|
|
65
|
+
} catch (error) {
|
|
66
|
+
cached = { error: /** @type {Error} */ (error) }
|
|
67
|
+
}
|
|
68
|
+
packageResolveCache.set(key, cached)
|
|
69
|
+
}
|
|
70
|
+
if ('error' in cached) throw cached.error
|
|
71
|
+
return cached.url
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Drop memoized parses and resolutions (e.g. between runs in a long-lived process). */
|
|
75
|
+
function clearDepGraphCache() {
|
|
76
|
+
parseCache.clear()
|
|
77
|
+
relativeResolveCache.clear()
|
|
78
|
+
packageResolveCache.clear()
|
|
79
|
+
realpathCache.clear()
|
|
80
|
+
}
|
|
81
|
+
|
|
9
82
|
function fsExistsSync(path) {
|
|
10
83
|
try {
|
|
11
84
|
fs.accessSync(path, fs.constants.F_OK)
|
|
@@ -30,6 +103,21 @@ function isFileSync(filePath) {
|
|
|
30
103
|
* @returns {string|null} - Resolved file path or null if not found
|
|
31
104
|
*/
|
|
32
105
|
function resolveImportPath(baseDir, importPath) {
|
|
106
|
+
const key = `${baseDir}\0${importPath}`
|
|
107
|
+
let resolved = relativeResolveCache.get(key)
|
|
108
|
+
if (resolved === undefined) {
|
|
109
|
+
resolved = resolveImportPathUncached(baseDir, importPath)
|
|
110
|
+
relativeResolveCache.set(key, resolved)
|
|
111
|
+
}
|
|
112
|
+
return resolved
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} baseDir
|
|
117
|
+
* @param {string} importPath
|
|
118
|
+
* @returns {string|null}
|
|
119
|
+
*/
|
|
120
|
+
function resolveImportPathUncached(baseDir, importPath) {
|
|
33
121
|
const possibleExtensions = ['.js', '.mjs', '.ts', '.json', '']
|
|
34
122
|
|
|
35
123
|
// Try direct file resolution (must be a file, not directory)
|
|
@@ -198,7 +286,7 @@ function buildGraphRecursively(
|
|
|
198
286
|
visited = new Set(),
|
|
199
287
|
) {
|
|
200
288
|
// Avoid infinite loops
|
|
201
|
-
const normalizedPath =
|
|
289
|
+
const normalizedPath = realpath(filePath)
|
|
202
290
|
if (visited.has(normalizedPath)) {
|
|
203
291
|
return
|
|
204
292
|
}
|
|
@@ -210,8 +298,7 @@ function buildGraphRecursively(
|
|
|
210
298
|
}
|
|
211
299
|
|
|
212
300
|
// Read and extract deps from this file
|
|
213
|
-
const
|
|
214
|
-
const deps = extractDeps(content)
|
|
301
|
+
const deps = readDeps(filePath)
|
|
215
302
|
|
|
216
303
|
// Collect all relative dependencies
|
|
217
304
|
const relativeDeps = []
|
|
@@ -255,15 +342,14 @@ function buildGraphRecursively(
|
|
|
255
342
|
|
|
256
343
|
// Add edge to graph if building
|
|
257
344
|
if (graph !== null) {
|
|
258
|
-
const normalizedResolvedPath =
|
|
345
|
+
const normalizedResolvedPath = realpath(resolvedPath)
|
|
259
346
|
graph.get(normalizedPath).push(normalizedResolvedPath)
|
|
260
347
|
}
|
|
261
348
|
|
|
262
349
|
// Verify imports if verification is enabled
|
|
263
350
|
if (verify) {
|
|
264
351
|
// Extract deps from the imported file
|
|
265
|
-
const
|
|
266
|
-
const targetDeps = extractDeps(targetContent)
|
|
352
|
+
const targetDeps = readDeps(resolvedPath)
|
|
267
353
|
|
|
268
354
|
// Verify the imported values are exported
|
|
269
355
|
verifyImportedValuesExist(dep, targetDeps, resolvedPath, filePath, errors, throwOnFirstError)
|
|
@@ -282,8 +368,6 @@ function buildGraphRecursively(
|
|
|
282
368
|
|
|
283
369
|
// Verify node_modules dependencies if enabled (skip built-ins)
|
|
284
370
|
if (verifyNodeModules && nodeModuleDeps.length > 0) {
|
|
285
|
-
const parentURL = pathToFileURL(filePath).href
|
|
286
|
-
|
|
287
371
|
for (const dep of nodeModuleDeps) {
|
|
288
372
|
const { from, line } = dep
|
|
289
373
|
|
|
@@ -295,7 +379,7 @@ function buildGraphRecursively(
|
|
|
295
379
|
let resolvedPath
|
|
296
380
|
try {
|
|
297
381
|
// resolveImport throws if file doesn't exist
|
|
298
|
-
const resolved =
|
|
382
|
+
const resolved = resolvePackageImport(from, filePath)
|
|
299
383
|
resolvedPath = fileURLToPath(resolved)
|
|
300
384
|
} catch (error) {
|
|
301
385
|
const err = new Error(
|
|
@@ -313,8 +397,7 @@ function buildGraphRecursively(
|
|
|
313
397
|
|
|
314
398
|
// Verify import/export styles match (only needed for ESM imports)
|
|
315
399
|
if (dep.isESM) {
|
|
316
|
-
const
|
|
317
|
-
const targetDeps = extractDeps(targetContent)
|
|
400
|
+
const targetDeps = readDeps(resolvedPath)
|
|
318
401
|
verifyImportedValuesExist(dep, targetDeps, resolvedPath, filePath, errors, throwOnFirstError)
|
|
319
402
|
}
|
|
320
403
|
}
|
|
@@ -470,5 +553,6 @@ if (require.main === module) {
|
|
|
470
553
|
|
|
471
554
|
module.exports = {
|
|
472
555
|
depGraph,
|
|
473
|
-
resolveDepPaths
|
|
556
|
+
resolveDepPaths,
|
|
557
|
+
clearDepGraphCache
|
|
474
558
|
}
|
package/src/get-cjs-exports.js
CHANGED
|
@@ -41,9 +41,11 @@ function getCjsExports(code) {
|
|
|
41
41
|
// values) keeps working off the same indices.
|
|
42
42
|
const sanitizedCode = stripCommentsAndStrings(code)
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
44
|
+
// Their regexes need `module.exports` / `exports` in the sanitized text.
|
|
45
|
+
const hasModuleExports = sanitizedCode.includes('module.exports')
|
|
46
|
+
const assignmentExports = hasModuleExports ? getModuleExportsAssignment(code, sanitizedCode) : []
|
|
47
|
+
const modulePropertyExports = hasModuleExports ? getModuleExportsProperty(code, sanitizedCode) : []
|
|
48
|
+
const propertyExports = sanitizedCode.includes('exports') ? getExportsProperty(code, sanitizedCode) : []
|
|
47
49
|
|
|
48
50
|
// Quick pre-checks to avoid expensive regex operations when not needed
|
|
49
51
|
const objectAssignExports = sanitizedCode.includes('Object.assign')
|
package/src/get-exports.js
CHANGED
|
@@ -3,7 +3,7 @@ const { getNames, extractExportName, stripComments, stripStringContents } = requ
|
|
|
3
3
|
// https://regex101.com/r/example/1
|
|
4
4
|
const EXPORT_DEFAULT_REGEX = /export\s+default\s+(?:(?:async\s+)?function\s+([\w$]+)|class\s+([\w$]+)|([\w$]+|\([^)]*\)\s*=>|{[\s\S]*?}|\[[\s\S]*?\]|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|[\w$]+(?:\s*[+\-*/%]\s*[\w$]+)*))/gm
|
|
5
5
|
// https://regex101.com/r/example/2
|
|
6
|
-
const EXPORT_NAMED_REGEX = /export\s+(?:const|let|var|function|class|async\s+function)\s+([\w$]+)/gm
|
|
6
|
+
const EXPORT_NAMED_REGEX = /export\s+(?:const\s+enum|enum|const|let|var|function|class|async\s+function)\s+([\w$]+)/gm
|
|
7
7
|
// https://regex101.com/r/example/3
|
|
8
8
|
const EXPORT_NAMED_DECLARATION_REGEX = /export\s*{([^}]+)}/gm
|
|
9
9
|
// https://regex101.com/r/example/4
|
|
@@ -16,6 +16,8 @@ const EXPORT_TYPE_REGEX = /export\s+(?:type|interface)\s+([\w$]+)/gm
|
|
|
16
16
|
const EXPORT_ASSERTION_REGEX = /export\s+(?:["'\s]*([\w*{}\n\r\t, ]+)from\s*)?["'\s](.*[@\w_-]+)["'\s]+(assert|with)\s*\{([^}]+)\};?$/gm
|
|
17
17
|
|
|
18
18
|
function getExports(code = '', options = {}) {
|
|
19
|
+
// Every pattern below needs the word `export`; stripping never adds one.
|
|
20
|
+
if (!code.includes('export')) return []
|
|
19
21
|
// Comments first, then strings — order matters. Running stripStringContents
|
|
20
22
|
// alone would mis-pair an apostrophe inside a JSDoc comment (`Netlify's …`)
|
|
21
23
|
// with a later quote and shift subsequent backtick pairing, which could
|
|
@@ -303,4 +305,4 @@ module.exports = {
|
|
|
303
305
|
getNamespaceExports,
|
|
304
306
|
getTypeExports,
|
|
305
307
|
getAssertionExports
|
|
306
|
-
}
|
|
308
|
+
}
|
package/src/get-imports.js
CHANGED
|
@@ -37,6 +37,8 @@ const DYNAMIC_IMPORT_REGEX = /(?:(const|var|let)\s+([\w*{}\n\r\t,:= $]+)\s*=\s*)
|
|
|
37
37
|
const ASSERTION_IMPORT_REGEX = /(?:^|\n)\s*import\s+(?:["'\s]*([\w*{}\n\r\t, $]+)from\s*)?["'\s]+([^"'`\n;\s]+)\s*["']\s+(assert|with)\s*\{([^}]+)\}/gm
|
|
38
38
|
|
|
39
39
|
function getImports(code = '', options = {}) {
|
|
40
|
+
// Every pattern below needs the word `import`; stripping never adds one.
|
|
41
|
+
if (!code.includes('import')) return []
|
|
40
42
|
// Strip comments FIRST so apostrophes/backticks inside JSDoc text get
|
|
41
43
|
// removed before `stripStringContents` looks for string literals.
|
|
42
44
|
// (Running the two in the opposite order let comment chars masquerade as
|
package/src/get-requires.js
CHANGED
|
@@ -38,11 +38,51 @@ function getDynamicRequires(code) {
|
|
|
38
38
|
return results
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Sticky copy of REQUIRE_REGEX: matches only at an exact start position.
|
|
42
|
+
const REQUIRE_REGEX_AT = new RegExp(REQUIRE_REGEX.source, 'my')
|
|
43
|
+
// Every char REQUIRE_REGEX can consume before its literal `require(`.
|
|
44
|
+
const REQUIRE_PREFIX_CHAR = /[\w\s*{}:=,$]/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Same result as `REQUIRE_REGEX.exec` with `lastIndex = from`, but only
|
|
48
|
+
* attempts start positions that can reach a `require(` literal. Scanning
|
|
49
|
+
* every position is quadratic on long lines because of the leading
|
|
50
|
+
* `(?!.*await\s+import)` lookahead.
|
|
51
|
+
*
|
|
52
|
+
* A match starting at `s` must contain `require(` at some `p >= s` with
|
|
53
|
+
* every char in [s, p) matching REQUIRE_PREFIX_CHAR. `(` is not in that set,
|
|
54
|
+
* so the match uses the first `require(` at or after `s`. For each
|
|
55
|
+
* `require(` at `p`, the only viable starts are therefore [q, p], where q
|
|
56
|
+
* walks back from p over prefix chars. Trying those in ascending order finds
|
|
57
|
+
* the same leftmost match.
|
|
58
|
+
* @param {string} text
|
|
59
|
+
* @param {number} from
|
|
60
|
+
*/
|
|
61
|
+
function execRequireFrom(text, from) {
|
|
62
|
+
let searchFrom = from
|
|
63
|
+
let tried = from
|
|
64
|
+
while (true) {
|
|
65
|
+
const requireIndex = text.indexOf('require(', searchFrom)
|
|
66
|
+
if (requireIndex === -1) return null
|
|
67
|
+
let start = requireIndex
|
|
68
|
+
while (start > tried && REQUIRE_PREFIX_CHAR.test(text[start - 1])) start--
|
|
69
|
+
for (; start <= requireIndex; start++) {
|
|
70
|
+
REQUIRE_REGEX_AT.lastIndex = start
|
|
71
|
+
const match = REQUIRE_REGEX_AT.exec(text)
|
|
72
|
+
if (match) return match
|
|
73
|
+
}
|
|
74
|
+
tried = requireIndex + 1
|
|
75
|
+
searchFrom = requireIndex + 1
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
41
79
|
// https://regex101.com/r/sR1TIP/1
|
|
42
80
|
function getStaticRequires(targetText = '') {
|
|
43
81
|
let requireStatements = []
|
|
44
82
|
var result;
|
|
45
|
-
|
|
83
|
+
let searchStart = 0
|
|
84
|
+
while((result = execRequireFrom(targetText, searchStart)) !== null) {
|
|
85
|
+
searchStart = result.index + result[0].length
|
|
46
86
|
const [ line, varType, names, from ] = result
|
|
47
87
|
// console.log('getStaticRequires result', result)
|
|
48
88
|
const importLocation = from
|
|
@@ -65,6 +105,8 @@ function getStaticRequires(targetText = '') {
|
|
|
65
105
|
}
|
|
66
106
|
|
|
67
107
|
function getRequires(code) {
|
|
108
|
+
// Both scanners need the word `require`; stripping never adds one.
|
|
109
|
+
if (!code.includes('require')) return []
|
|
68
110
|
// Strip comments first, then strings — order matters. `stripComments`
|
|
69
111
|
// alone would mis-eat `//` inside strings; running it first instead means
|
|
70
112
|
// comment chars (incl. apostrophes/backticks inside JSDoc) disappear
|
package/src/index.js
CHANGED
|
@@ -16,7 +16,35 @@ const {
|
|
|
16
16
|
// https://github.com/ganeshkbhat/get-imports/blob/main/index.js
|
|
17
17
|
// Alt get imports https://github.com/beenotung/fix-esm-import-path/blob/master/fix-esm-import-path.js#L186
|
|
18
18
|
|
|
19
|
+
// Parse results keyed by source text. Extraction is a pure function of the
|
|
20
|
+
// code, and dep-graph walks re-visit the same shared modules once per entry
|
|
21
|
+
// point, so repeat parses dominate runtime without this. Bounded FIFO so a
|
|
22
|
+
// long-lived process scanning many files doesn't grow without limit.
|
|
23
|
+
const PARSE_CACHE_MAX = 2000
|
|
24
|
+
const parseCache = new Map()
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Cached extraction. Returns a shared object — callers must treat it as
|
|
28
|
+
* read-only. Use `extractDeps` for a private copy.
|
|
29
|
+
* @param {string} code
|
|
30
|
+
*/
|
|
31
|
+
function extractDepsShared(code) {
|
|
32
|
+
let result = parseCache.get(code)
|
|
33
|
+
if (result === undefined) {
|
|
34
|
+
result = parseDeps(code)
|
|
35
|
+
if (parseCache.size >= PARSE_CACHE_MAX) {
|
|
36
|
+
parseCache.delete(parseCache.keys().next().value)
|
|
37
|
+
}
|
|
38
|
+
parseCache.set(code, result)
|
|
39
|
+
}
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
function extractDeps(code, filePath = '') {
|
|
44
|
+
return structuredClone(extractDepsShared(code))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseDeps(code) {
|
|
20
48
|
const imports = getImports(code)
|
|
21
49
|
//console.log('imports', imports)
|
|
22
50
|
const requires = getRequires(code)
|
|
@@ -94,6 +122,7 @@ const { execSync, spawnSync } = require('child_process')
|
|
|
94
122
|
|
|
95
123
|
module.exports = {
|
|
96
124
|
extractDeps,
|
|
125
|
+
extractDepsShared,
|
|
97
126
|
/* syntax detection */
|
|
98
127
|
hasESMSyntax,
|
|
99
128
|
hasCJSSyntax,
|
package/src/utils.js
CHANGED
|
@@ -1,22 +1,6 @@
|
|
|
1
1
|
const { inspectParameters, getParametersNames } = require('./vendor/inspect-parameters-declaration')
|
|
2
2
|
const builtinModules = require('module').builtinModules
|
|
3
3
|
|
|
4
|
-
const COMMENT_RE = /\/\*[\s\S]*?\*\/|\/\/.*(?:\r?\n|$)/g
|
|
5
|
-
const PROTOCOL_PLACEHOLDER = '__PROTO_SLASH__'
|
|
6
|
-
|
|
7
|
-
// Single-quoted and double-quoted strings can't contain unescaped newlines
|
|
8
|
-
// (per JS spec — they'd need `\n` escapes or `\<NL>` line continuations). The
|
|
9
|
-
// `[^"\\\n]` / `[^'\\\n]` char classes enforce that, so an unbalanced quote
|
|
10
|
-
// followed by a newline doesn't sweep up the next several lines as "string".
|
|
11
|
-
// Template literals (`` `...` ``) DO span newlines, so their inner class only
|
|
12
|
-
// excludes backtick and backslash. Escape sequences (`\.`) are allowed in all
|
|
13
|
-
// three so escaped quotes don't prematurely end the match.
|
|
14
|
-
// Note: this treats `${...}` interpolations as part of the string body — code
|
|
15
|
-
// inside `${...}` (rare, but possible: `${import('./x.js')}`) gets blanked too.
|
|
16
|
-
// Acceptable for the change-detection use case; a full tokenizer would be
|
|
17
|
-
// needed to preserve interpolations.
|
|
18
|
-
const STRING_LITERAL_RE = /"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'|`(?:[^`\\]|\\[\s\S])*`/g
|
|
19
|
-
|
|
20
4
|
// Strings whose bodies contain these tokens are treated as embedded code
|
|
21
5
|
// samples (template strings, generator helpers, fenced docs) and get blanked.
|
|
22
6
|
// Plain module specifiers like `'lodash'` or `'./foo.js'` don't contain any
|
|
@@ -38,8 +22,9 @@ const EMBEDDED_CODE_RE = /\b(?:import|export|require|module\.exports)\b/
|
|
|
38
22
|
* Without this pass, regex-based extractors falsely match `import`, `export`,
|
|
39
23
|
* and `require` keywords that appear inside code samples embedded in strings.
|
|
40
24
|
*
|
|
41
|
-
* Run
|
|
42
|
-
*
|
|
25
|
+
* Run after `stripComments` in the import/require/export extractors. The
|
|
26
|
+
* comment scanner already skips strings and regex literals, so this pass can
|
|
27
|
+
* focus on embedded-code strings without risking `//` in URLs or regexes.
|
|
43
28
|
*
|
|
44
29
|
* Caveat: `${...}` interpolations inside template literals are treated as
|
|
45
30
|
* part of the body, so a runtime `${import('./x.js')}` inside a template
|
|
@@ -50,32 +35,64 @@ const EMBEDDED_CODE_RE = /\b(?:import|export|require|module\.exports)\b/
|
|
|
50
35
|
* @returns {string}
|
|
51
36
|
*/
|
|
52
37
|
function stripStringContents(code) {
|
|
53
|
-
|
|
38
|
+
let output = ''
|
|
39
|
+
let i = 0
|
|
40
|
+
const special = /[/"'`]/g
|
|
41
|
+
|
|
42
|
+
while (i < code.length) {
|
|
43
|
+
// Copy the run of ordinary characters up to the next / or quote in one go.
|
|
44
|
+
special.lastIndex = i
|
|
45
|
+
const next = special.exec(code)
|
|
46
|
+
const stop = next ? next.index : code.length
|
|
47
|
+
if (stop > i) {
|
|
48
|
+
output += code.slice(i, stop)
|
|
49
|
+
i = stop
|
|
50
|
+
if (i >= code.length) break
|
|
51
|
+
}
|
|
52
|
+
const char = code[i]
|
|
53
|
+
|
|
54
|
+
if (char === '/' && isRegexLiteralStart(code, i)) {
|
|
55
|
+
const end = readRegexLiteral(code, i)
|
|
56
|
+
output += code.slice(i, end)
|
|
57
|
+
i = end
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (char !== '"' && char !== "'" && char !== '`') {
|
|
62
|
+
output += char
|
|
63
|
+
i++
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const end = readQuoted(code, i, char)
|
|
68
|
+
const match = code.slice(i, end)
|
|
69
|
+
if (match.length < 2 || match[match.length - 1] !== char) {
|
|
70
|
+
output += char
|
|
71
|
+
i++
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
const body = match.slice(1, -1)
|
|
55
76
|
if (!EMBEDDED_CODE_RE.test(body)) {
|
|
56
77
|
// Plain string — leave it alone so module specifiers stay extractable.
|
|
57
|
-
|
|
78
|
+
output += match
|
|
79
|
+
i = end
|
|
80
|
+
continue
|
|
58
81
|
}
|
|
59
82
|
const opener = match[0]
|
|
60
83
|
const closer = match[match.length - 1]
|
|
61
84
|
const middle = body.replace(/[^\n]/g, ' ')
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
85
|
+
output += opener + middle + closer
|
|
86
|
+
i = end
|
|
87
|
+
}
|
|
65
88
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// template literal. Matching left-to-right means a comment that starts
|
|
69
|
-
// first consumes its body — including any apostrophes or backticks inside
|
|
70
|
-
// — before they can start a spurious string match a few lines later. That
|
|
71
|
-
// solves the JSDoc-apostrophe-shifts-backtick-pairing class of bugs that
|
|
72
|
-
// arose when comments and strings were stripped in two separate passes.
|
|
73
|
-
const COMMENT_OR_STRING_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'|`(?:[^`\\]|\\[\s\S])*`/g
|
|
89
|
+
return output
|
|
90
|
+
}
|
|
74
91
|
|
|
75
92
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
93
|
+
* Blank comment bodies and code-bearing string/template bodies together.
|
|
94
|
+
* Solves two bugs that occurred when the string-strip and comment-strip ran
|
|
95
|
+
* as naive regex passes:
|
|
79
96
|
*
|
|
80
97
|
* - Apostrophes inside JSDoc comments (e.g. `* Netlify's \`...`) were
|
|
81
98
|
* mis-paired with later quotes, which shifted subsequent backtick
|
|
@@ -83,6 +100,8 @@ const COMMENT_OR_STRING_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\[\s\S])
|
|
|
83
100
|
* later in the same file.
|
|
84
101
|
* - `//` and `/* *\/` sequences inside string bodies were mis-stripped
|
|
85
102
|
* as comments.
|
|
103
|
+
* - `//` inside regex literals such as `/^https?:\/\//i` was mis-stripped
|
|
104
|
+
* as a line comment.
|
|
86
105
|
*
|
|
87
106
|
* Length is preserved (each consumed char replaced by a space or newline
|
|
88
107
|
* kept verbatim) so downstream character indices stay valid.
|
|
@@ -99,38 +118,177 @@ const COMMENT_OR_STRING_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\[\s\S])
|
|
|
99
118
|
* @returns {string}
|
|
100
119
|
*/
|
|
101
120
|
function stripCommentsAndStrings(code) {
|
|
102
|
-
return code
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
121
|
+
return stripStringContents(stripCommentsImpl(code, { preserveLength: true }))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const REGEX_PREFIX_KEYWORDS = new Set([
|
|
125
|
+
'case',
|
|
126
|
+
'delete',
|
|
127
|
+
'do',
|
|
128
|
+
'else',
|
|
129
|
+
'in',
|
|
130
|
+
'instanceof',
|
|
131
|
+
'of',
|
|
132
|
+
'return',
|
|
133
|
+
'throw',
|
|
134
|
+
'typeof',
|
|
135
|
+
'void',
|
|
136
|
+
'yield',
|
|
137
|
+
'await',
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
function isRegexLiteralStart(code, slashIndex) {
|
|
141
|
+
let i = slashIndex - 1
|
|
142
|
+
while (i >= 0 && /\s/.test(code[i])) i--
|
|
143
|
+
if (i < 0) return true
|
|
144
|
+
|
|
145
|
+
const previousChar = code[i]
|
|
146
|
+
if ('([{=,:;!&|?+-*~^<>'.includes(previousChar)) return true
|
|
147
|
+
|
|
148
|
+
// Same result as `code.slice(0, i + 1).match(/([A-Za-z_$][\w$]*)$/)[1]`
|
|
149
|
+
// without copying and re-scanning the whole prefix on every `/` (quadratic
|
|
150
|
+
// on large files): the leftmost match starts at the first non-digit of the
|
|
151
|
+
// trailing run of [\w$] chars.
|
|
152
|
+
let start = i
|
|
153
|
+
while (start >= 0 && isIdentifierChar(code.charCodeAt(start))) start--
|
|
154
|
+
start++
|
|
155
|
+
while (start <= i && isDigit(code.charCodeAt(start))) start++
|
|
156
|
+
if (start > i) return false
|
|
157
|
+
return REGEX_PREFIX_KEYWORDS.has(code.slice(start, i + 1))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isDigit(charCode) {
|
|
161
|
+
return charCode >= 48 && charCode <= 57
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// [\w$] — ASCII letters, digits, underscore, dollar
|
|
165
|
+
function isIdentifierChar(charCode) {
|
|
166
|
+
return (
|
|
167
|
+
(charCode >= 97 && charCode <= 122) ||
|
|
168
|
+
(charCode >= 65 && charCode <= 90) ||
|
|
169
|
+
isDigit(charCode) ||
|
|
170
|
+
charCode === 95 ||
|
|
171
|
+
charCode === 36
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readQuoted(code, start, quote) {
|
|
176
|
+
let i = start + 1
|
|
177
|
+
while (i < code.length) {
|
|
178
|
+
if (code[i] === '\\') {
|
|
179
|
+
i += 2
|
|
180
|
+
continue
|
|
108
181
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
182
|
+
if (code[i] === quote) return i + 1
|
|
183
|
+
if (quote !== '`' && (code[i] === '\n' || code[i] === '\r')) return i
|
|
184
|
+
i++
|
|
185
|
+
}
|
|
186
|
+
return i
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readRegexLiteral(code, start) {
|
|
190
|
+
let i = start + 1
|
|
191
|
+
let inCharacterClass = false
|
|
192
|
+
|
|
193
|
+
while (i < code.length) {
|
|
194
|
+
const char = code[i]
|
|
195
|
+
if (char === '\\') {
|
|
196
|
+
i += 2
|
|
197
|
+
continue
|
|
114
198
|
}
|
|
115
|
-
|
|
116
|
-
|
|
199
|
+
if (char === '[') {
|
|
200
|
+
inCharacterClass = true
|
|
201
|
+
i++
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (char === ']') {
|
|
205
|
+
inCharacterClass = false
|
|
206
|
+
i++
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (char === '/' && !inCharacterClass) {
|
|
210
|
+
i++
|
|
211
|
+
while (i < code.length && /[a-z]/i.test(code[i])) i++
|
|
212
|
+
return i
|
|
213
|
+
}
|
|
214
|
+
if (char === '\n' || char === '\r') return i
|
|
215
|
+
i++
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return i
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function appendSkippedComment(output, commentText, preserveLength) {
|
|
222
|
+
if (!preserveLength) {
|
|
223
|
+
return output + commentText.replace(/[^\n\r]/g, '')
|
|
224
|
+
}
|
|
225
|
+
return output + commentText.replace(/[^\n\r]/g, ' ')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stripCommentsImpl(code, { preserveLength = false } = {}) {
|
|
229
|
+
let output = ''
|
|
230
|
+
let i = 0
|
|
231
|
+
const special = /[/"'`]/g
|
|
232
|
+
|
|
233
|
+
while (i < code.length) {
|
|
234
|
+
// Copy the run of ordinary characters up to the next / or quote in one go.
|
|
235
|
+
special.lastIndex = i
|
|
236
|
+
const found = special.exec(code)
|
|
237
|
+
const stop = found ? found.index : code.length
|
|
238
|
+
if (stop > i) {
|
|
239
|
+
output += code.slice(i, stop)
|
|
240
|
+
i = stop
|
|
241
|
+
if (i >= code.length) break
|
|
242
|
+
}
|
|
243
|
+
const char = code[i]
|
|
244
|
+
const next = code[i + 1]
|
|
245
|
+
|
|
246
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
247
|
+
const end = readQuoted(code, i, char)
|
|
248
|
+
output += code.slice(i, end)
|
|
249
|
+
i = end
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (char === '/' && next === '/') {
|
|
254
|
+
let end = i + 2
|
|
255
|
+
while (end < code.length && code[end] !== '\n' && code[end] !== '\r') end++
|
|
256
|
+
output = appendSkippedComment(output, code.slice(i, end), preserveLength)
|
|
257
|
+
i = end
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (char === '/' && next === '*') {
|
|
262
|
+
let end = code.indexOf('*/', i + 2)
|
|
263
|
+
end = end === -1 ? code.length : end + 2
|
|
264
|
+
output = appendSkippedComment(output, code.slice(i, end), preserveLength)
|
|
265
|
+
i = end
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (char === '/' && isRegexLiteralStart(code, i)) {
|
|
270
|
+
const end = readRegexLiteral(code, i)
|
|
271
|
+
output += code.slice(i, end)
|
|
272
|
+
i = end
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
output += char
|
|
277
|
+
i++
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return output
|
|
117
281
|
}
|
|
118
282
|
|
|
119
283
|
/**
|
|
120
|
-
* Strip comments from code,
|
|
284
|
+
* Strip comments from code while skipping strings, templates, and regex
|
|
285
|
+
* literals. Regex literals can legally contain `//`, for example `/https?:\/\//`,
|
|
286
|
+
* and must not be treated as line comments.
|
|
121
287
|
* @param {string} code
|
|
122
288
|
* @returns {string}
|
|
123
289
|
*/
|
|
124
290
|
function stripComments(code) {
|
|
125
|
-
|
|
126
|
-
if (hasProtocol) {
|
|
127
|
-
code = code.replace(/:\/\//g, PROTOCOL_PLACEHOLDER)
|
|
128
|
-
}
|
|
129
|
-
code = code.replace(COMMENT_RE, '')
|
|
130
|
-
if (hasProtocol) {
|
|
131
|
-
code = code.replace(/__PROTO_SLASH__/g, '://')
|
|
132
|
-
}
|
|
133
|
-
return code
|
|
291
|
+
return stripCommentsImpl(code)
|
|
134
292
|
}
|
|
135
293
|
|
|
136
294
|
/**
|
|
@@ -351,4 +509,4 @@ module.exports = {
|
|
|
351
509
|
isRelativeImportWithQuotes,
|
|
352
510
|
getNames,
|
|
353
511
|
extractExportName
|
|
354
|
-
}
|
|
512
|
+
}
|