Skip to content

Commit e3bce9c

Browse files
committed
feat: add inlineOnly option
1 parent 9ad89f0 commit e3bce9c

File tree

8 files changed

+186
-52
lines changed

8 files changed

+186
-52
lines changed

src/features/external.ts

Lines changed: 81 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,105 @@
11
import { builtinModules } from 'node:module'
2+
import path from 'node:path'
3+
import { blue, underline } from 'ansis'
24
import Debug from 'debug'
5+
import { RE_DTS, RE_NODE_MODULES } from 'rolldown-plugin-dts/filename'
36
import { shimFile } from '../index'
4-
import { toArray } from '../utils/general'
7+
import { matchPattern } from '../utils/general'
58
import type { ResolvedOptions } from '../options'
69
import type { PackageJson } from 'pkg-types'
7-
import type { Plugin } from 'rolldown'
10+
import type { Plugin, PluginContext, ResolveIdExtraOptions } from 'rolldown'
811

912
const debug = Debug('tsdown:external')
1013

11-
export function ExternalPlugin(options: ResolvedOptions): Plugin {
12-
const deps = options.pkg && Array.from(getProductionDeps(options.pkg))
14+
export function ExternalPlugin({
15+
pkg,
16+
noExternal,
17+
inlineOnly,
18+
skipNodeModulesBundle,
19+
}: ResolvedOptions): Plugin {
20+
const deps = pkg && Array.from(getProductionDeps(pkg))
21+
1322
return {
1423
name: 'tsdown:external',
1524
async resolveId(id, importer, extraOptions) {
16-
if (extraOptions.isEntry) return
17-
if (id === shimFile) return
25+
if (extraOptions.isEntry || !importer) return
1826

19-
const { noExternal } = options
20-
if (typeof noExternal === 'function' && noExternal(id, importer)) {
21-
return
22-
}
23-
if (noExternal) {
24-
const noExternalPatterns = toArray(noExternal)
25-
if (
26-
noExternalPatterns.some((pattern) => {
27-
if (pattern instanceof RegExp) {
28-
pattern.lastIndex = 0
29-
return pattern.test(id)
30-
}
31-
return id === pattern
32-
})
33-
)
34-
return
35-
}
27+
const shouldExternal = await externalStrategy(
28+
this,
29+
id,
30+
importer,
31+
extraOptions,
32+
)
33+
const nodeBuiltinModule =
34+
id.startsWith('node:') || builtinModules.includes(id)
3635

37-
let shouldExternal: boolean | 'absolute' = false
38-
if (options.skipNodeModulesBundle) {
39-
const resolved = await this.resolve(id, importer, extraOptions)
40-
if (!resolved) return resolved
41-
shouldExternal =
42-
resolved.external || /[\\/]node_modules[\\/]/.test(resolved.id)
43-
}
44-
if (deps) {
45-
shouldExternal ||= deps.some(
46-
(dep) => id === dep || id.startsWith(`${dep}/`),
47-
)
48-
}
36+
debug('shouldExternal: %s = %s', id, shouldExternal)
4937

50-
if (shouldExternal) {
51-
debug('External dependency:', id)
38+
if (shouldExternal === true || shouldExternal === 'absolute') {
5239
return {
5340
id,
5441
external: shouldExternal,
55-
moduleSideEffects:
56-
id.startsWith('node:') || builtinModules.includes(id)
57-
? false
58-
: undefined,
42+
moduleSideEffects: nodeBuiltinModule ? false : undefined,
43+
}
44+
}
45+
46+
if (
47+
inlineOnly &&
48+
!RE_DTS.test(importer) && // skip dts files
49+
!nodeBuiltinModule && // skip node built-in modules
50+
id[0] !== '.' && // skip relative imports
51+
!path.isAbsolute(id) // skip absolute imports
52+
) {
53+
const shouldInline =
54+
shouldExternal === 'no-external' || // force inline
55+
matchPattern(id, inlineOnly)
56+
debug('shouldInline: %s = %s', id, shouldInline)
57+
if (shouldInline) return
58+
59+
const resolved = await this.resolve(id, importer, extraOptions)
60+
if (!resolved) return
61+
62+
if (RE_NODE_MODULES.test(resolved.id)) {
63+
throw new Error(
64+
`${underline(id)} is located in node_modules but is not included in ${blue`inlineOnly`} option.
65+
To fix this, either add it to ${blue`inlineOnly`}, declare it as a production or peer dependency in your package.json, or externalize it manually.
66+
Imported by ${underline(importer)}`,
67+
)
5968
}
6069
}
6170
},
6271
}
72+
73+
/**
74+
* - `true`: always external
75+
* - `false`: skip, let other plugins handle it
76+
* - `'absolute'`: external as absolute path
77+
* - `'no-external'`: skip, but mark as non-external for inlineOnly check
78+
*/
79+
async function externalStrategy(
80+
context: PluginContext,
81+
id: string,
82+
importer: string | undefined,
83+
extraOptions: ResolveIdExtraOptions,
84+
): Promise<boolean | 'absolute' | 'no-external'> {
85+
if (id === shimFile) return false
86+
87+
if (noExternal?.(id, importer)) {
88+
return 'no-external'
89+
}
90+
91+
if (skipNodeModulesBundle) {
92+
const resolved = await context.resolve(id, importer, extraOptions)
93+
if (!resolved) return false
94+
return resolved.external || RE_NODE_MODULES.test(resolved.id)
95+
}
96+
97+
if (deps) {
98+
return deps.some((dep) => id === dep || id.startsWith(`${dep}/`))
99+
}
100+
101+
return false
102+
}
63103
}
64104

65105
/*

src/features/watch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { blue } from 'ansis'
2+
import { RE_NODE_MODULES } from 'rolldown-plugin-dts/filename'
23
import {
34
globalContext,
45
invalidateContextFile,
@@ -38,7 +39,7 @@ export async function watchBuild(
3839
ignorePermissionErrors: true,
3940
ignored: [
4041
/[\\/]\.git[\\/]/,
41-
/[\\/]node_modules[\\/]/,
42+
RE_NODE_MODULES,
4243
options.outDir,
4344
...options.ignoreWatch,
4445
],

src/options/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { resolveEntry } from '../features/entry'
88
import { hasExportsTypes } from '../features/exports'
99
import { resolveTarget } from '../features/target'
1010
import { resolveTsconfig } from '../features/tsconfig'
11-
import { resolveRegex, slash, toArray } from '../utils/general'
11+
import { matchPattern, resolveRegex, slash, toArray } from '../utils/general'
1212
import { createLogger } from '../utils/logger'
1313
import { normalizeFormat, readPackageJson } from '../utils/package'
1414
import type { Awaitable } from '../utils/types'
@@ -182,7 +182,7 @@ async function resolveConfig(
182182
dts,
183183
unused = false,
184184
watch = false,
185-
ignoreWatch = [],
185+
ignoreWatch,
186186
shims = false,
187187
skipNodeModulesBundle = false,
188188
publint = false,
@@ -208,6 +208,7 @@ async function resolveConfig(
208208
nodeProtocol,
209209
cjsDefault = true,
210210
globImport = true,
211+
inlineOnly,
211212
} = userConfig
212213

213214
const logger = createLogger(logLevel, { customLogger, failOnWarn })
@@ -296,6 +297,14 @@ async function resolveConfig(
296297
return ignore
297298
})
298299

300+
if (noExternal != null && typeof noExternal !== 'function') {
301+
const noExternalPatterns = toArray(noExternal)
302+
noExternal = (id) => matchPattern(id, noExternalPatterns)
303+
}
304+
if (inlineOnly != null) {
305+
inlineOnly = toArray(inlineOnly)
306+
}
307+
299308
const config: ResolvedOptions = {
300309
...userConfig,
301310
entry,
@@ -332,6 +341,7 @@ async function resolveConfig(
332341
nodeProtocol,
333342
cjsDefault,
334343
globImport,
344+
inlineOnly,
335345
}
336346

337347
return config

src/options/types.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ export interface Workspace {
8484
config?: boolean | string
8585
}
8686

87+
export type NoExternalFn = (
88+
id: string,
89+
importer: string | undefined,
90+
) => boolean | null | undefined | void
91+
8792
/**
8893
* Options for tsdown.
8994
*/
@@ -95,12 +100,13 @@ export interface Options {
95100
entry?: InputOption
96101

97102
external?: ExternalOption
98-
noExternal?:
99-
| Arrayable<string | RegExp>
100-
| ((
101-
id: string,
102-
importer: string | undefined,
103-
) => boolean | null | undefined | void)
103+
noExternal?: Arrayable<string | RegExp> | NoExternalFn
104+
/**
105+
* Bundle only the dependencies listed here; throw an error if any others are missing.
106+
*
107+
* Note: Be sure to include all required sub-dependencies as well.
108+
*/
109+
inlineOnly?: Arrayable<string | RegExp>
104110
/**
105111
* Skip bundling `node_modules`.
106112
* @default false
@@ -486,7 +492,6 @@ export type ResolvedOptions = Omit<
486492
| 'define'
487493
| 'alias'
488494
| 'external'
489-
| 'noExternal'
490495
| 'onSuccess'
491496
| 'fixedExtension'
492497
| 'outExtensions'
@@ -511,6 +516,8 @@ export type ResolvedOptions = Omit<
511516
nodeProtocol: 'strip' | boolean
512517
logger: Logger
513518
ignoreWatch: Array<string | RegExp>
519+
noExternal?: NoExternalFn
520+
inlineOnly?: Array<string | RegExp>
514521
}
515522
>,
516523
'config' | 'fromVite'

src/utils/general.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,16 @@ export function slash(string: string): string {
4747
}
4848

4949
export const noop = <T>(v: T): T => v
50+
51+
export function matchPattern(
52+
id: string,
53+
patterns: (string | RegExp)[],
54+
): boolean {
55+
return patterns.some((pattern) => {
56+
if (pattern instanceof RegExp) {
57+
pattern.lastIndex = 0
58+
return pattern.test(id)
59+
}
60+
return id === pattern
61+
})
62+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
## index.js
2+
3+
```js
4+
//#region ../../../../node_modules/.pnpm/cac@6.7.14/node_modules/cac/dist/index.mjs
5+
const cac = 42;
6+
7+
//#endregion
8+
//#region ../../../../node_modules/.pnpm/bumpp@10.2.3/node_modules/bumpp/dist/index.mjs
9+
const bumpp = 42;
10+
11+
//#endregion
12+
export { bumpp, cac };
13+
```

tests/index.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
import path from 'node:path'
2-
import { beforeEach, expect, test, vi } from 'vitest'
2+
import { RE_NODE_MODULES } from 'rolldown-plugin-dts'
3+
import { beforeEach, describe, expect, test, vi } from 'vitest'
34
import { resolveOptions, type Options } from '../src/options'
45
import { fsRemove } from '../src/utils/fs'
6+
import { slash } from '../src/utils/general'
57
import { chdir, getTestDir, testBuild, writeFixtures } from './utils'
8+
import type { Plugin } from 'rolldown'
69

710
beforeEach(async (context) => {
811
const dir = getTestDir(context.task)
912
await fsRemove(dir)
1013
})
1114

15+
const pluginMockDepCode: Plugin = {
16+
name: 'mock-dep-code',
17+
load: {
18+
filter: { id: RE_NODE_MODULES },
19+
handler(id) {
20+
const name = slash(id).split('/node_modules/').at(-1)!.split('/')[0]
21+
return `export const ${name} = 42`
22+
},
23+
},
24+
}
25+
1226
test('basic', async (context) => {
1327
const content = `console.log("Hello, world!")`
1428
const { snapshot } = await testBuild({
@@ -167,6 +181,41 @@ test('noExternal', async (context) => {
167181
})
168182
})
169183

184+
describe('inlineOnly', () => {
185+
test('work', async (context) => {
186+
const files = {
187+
'index.ts': `export * from 'cac'; export * from 'bumpp'`,
188+
}
189+
await testBuild({
190+
context,
191+
files,
192+
options: {
193+
noExternal: ['cac'],
194+
inlineOnly: ['bumpp'],
195+
plugins: [pluginMockDepCode],
196+
},
197+
})
198+
})
199+
200+
test('throw error', async (context) => {
201+
const files = {
202+
'index.ts': `export * from 'bumpp'`,
203+
}
204+
await expect(() =>
205+
testBuild({
206+
context,
207+
files,
208+
options: {
209+
inlineOnly: [],
210+
plugins: [pluginMockDepCode],
211+
},
212+
}),
213+
).rejects.toThrow(
214+
'declare it as a production or peer dependency in your package.json',
215+
)
216+
})
217+
})
218+
170219
test('fromVite', async (context) => {
171220
const files = {
172221
'index.ts': `export default 10`,

tsdown.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defineConfig } from './src/config.ts'
33

44
export default defineConfig({
55
entry: ['./src/{index,run,plugins,config}.ts'],
6+
inlineOnly: [],
67
platform: 'node',
78
dts: true,
89
fixedExtension: true,

0 commit comments

Comments
 (0)