pkgbld 2.0.0 → 2.1.0

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/README.md CHANGED
@@ -43,6 +43,30 @@ For TypeScript or TSX sources, also install
43
43
 
44
44
  `pkgbld` expects the name field to be filled in the package.json file. `exports` field defines what entries/outputs should be built for this package.
45
45
 
46
+ ### Private package imports
47
+
48
+ When `package.json` has an `imports` map, `pkgbld` builds its local JavaScript targets alongside the public `exports` entries. For example:
49
+
50
+ ```json
51
+ {
52
+ "name": "example",
53
+ "type": "module",
54
+ "imports": {
55
+ "#utils": "./dist/utils.mjs",
56
+ "#env": {
57
+ "node": "./dist/env.node.mjs",
58
+ "default": "./dist/env.browser.mjs"
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ With the default directories, provide `src/utils.js`, `src/env.node.js`, and `src/env.browser.js` (or supported TypeScript sources with a transform plugin). Both `#env` branches are built at the declared paths. An import of `#utils` or `#env` from this package stays as a `#` specifier in the output, so the runtime selects the matching branch. The authored `imports` map is preserved.
65
+
66
+ Exact and wildcard keys, nested conditions, and fallback arrays are supported. Repeated local targets are built once. `null` and package-specifier targets are left to runtime resolution. Local `.mjs` targets require `es`; `.cjs` targets require `cjs`; `.js` targets follow the package `type` that pkgbld writes (including an inferred `"module"` for an ESM-only build). Percent-encoded target paths refer to their decoded filenames, and URL query or fragment suffixes remain in the authored map without becoming part of the output filename. A target whose format is excluded by `--formats`, whose source is missing, or whose output path is invalid or conflicts with another entry fails the build with its manifest location. Local assets and `.d.ts` targets are not generated by this JavaScript entry process; supply them through their own producer.
67
+
68
+ Keys beginning `#/` work on Node.js 24.14+ and 25.4+; older supported Node.js versions reject those specifiers at runtime. Use names such as `#utils` when supporting Node.js 20.
69
+
46
70
  ## CLI options
47
71
 
48
72
  ### umd
@@ -83,7 +107,9 @@ Supported targets for this option: `es`, `cjs` and `umd`.
83
107
  pkgbld --formats=es
84
108
  ```
85
109
 
86
- Defines what formats to build, only supports `es` and `cjs` at the moment. Use `umd` flag to build umd target.
110
+ Defines what formats to build: `es`, `cjs`, and `umd`. Use `--umd` to select UMD entry points.
111
+
112
+ Private import targets must have their required format included here. They are emitted at their declared paths independently of the output filename patterns below.
87
113
 
88
114
  ### preprocess
89
115
 
@@ -131,6 +157,24 @@ pkgbld --include-externals=lodash
131
157
 
132
158
  Bundles all or specified externals into a package.
133
159
 
160
+ This does not inline this package's own `#` imports when package-import handling is enabled. A bundled dependency's `#` imports are resolved against that dependency's map.
161
+
162
+ ### no-imports
163
+
164
+ ```
165
+ pkgbld --no-imports
166
+ ```
167
+
168
+ Disables local target discovery and `#` externalization for this package. The authored `imports` map is preserved. This restores the earlier source-import behavior and does not affect `--conditions`.
169
+
170
+ ### conditions
171
+
172
+ ```
173
+ pkgbld --conditions=node,development
174
+ ```
175
+
176
+ Adds build-time conditions when resolving bundled dependencies, including their `#` imports. Comma-separated values supplement the resolver's built-in conditions. With no flag, the resolver keeps its existing implicit `production` condition, or `development` when selected by `NODE_ENV`. The order of keys in a dependency's map determines which matching branch wins. These conditions do not choose which branches of this package's own `imports` map are built.
177
+
134
178
  ### eject
135
179
 
136
180
  ```
@@ -200,7 +244,7 @@ pkgbld --no-exports
200
244
  Do not add exports field in package.json.
201
245
 
202
246
  This also disables entry-point discovery from an existing `exports` field. Only the top-level `src/index` entry point is built
203
- unless a plugin provides additional inputs.
247
+ unless a plugin provides additional inputs or enabled package imports declare local JavaScript targets.
204
248
 
205
249
  ## Build plugin interface
206
250
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0",
2
+ "version": "2.1.0",
3
3
  "name": "pkgbld",
4
4
  "license": "MIT",
5
5
  "author": "Konstantin Shutkin",
@@ -101,6 +101,10 @@ function createDefaultDraft() {
101
101
  includeExternals: defaults.includeExternals,
102
102
  removeLegalComments: false,
103
103
  },
104
+ resolution: {
105
+ imports: defaults.imports,
106
+ conditions: [...defaults.conditions],
107
+ },
104
108
  packageJson: {
105
109
  update: true,
106
110
  format: defaults.formatPackageJson,
@@ -124,6 +128,9 @@ function createDefaultDraft() {
124
128
  * @param {PackageJson} packageJson
125
129
  */
126
130
  function applyPackageMetadata(draft, packageJson) {
131
+ if (packageJson.imports !== undefined) {
132
+ draft.resolution.imports = true;
133
+ }
127
134
  if (typeof packageJson.umd === 'string') {
128
135
  draft.outputs.umdEntries.push('index');
129
136
  draft.outputs.formats.push('umd');
@@ -186,6 +193,8 @@ function applyCli(draft, flags, provided) {
186
193
  if (provided.includeExternals) {
187
194
  draft.transforms.includeExternals = /** @type {boolean | string[]} */ (flags.includeExternals);
188
195
  }
196
+ if (provided.imports) draft.resolution.imports = /** @type {boolean} */ (flags.imports);
197
+ if (provided.conditions) draft.resolution.conditions = [.../** @type {string[]} */ (flags.conditions)];
189
198
  if (provided.eject) draft.execution.eject = /** @type {boolean} */ (flags.eject);
190
199
  if (provided.tsConfig) draft.typescript.updateConfig = /** @type {boolean} */ (flags.tsConfig);
191
200
  if (provided.updatePackageJson) draft.packageJson.update = /** @type {boolean} */ (flags.updatePackageJson);
@@ -209,6 +218,7 @@ function normalize(draft) {
209
218
  if (Array.isArray(draft.outputs?.sourcemaps)) draft.outputs.sourcemaps = unique(draft.outputs.sourcemaps);
210
219
  if (Array.isArray(draft.transforms?.compress)) draft.transforms.compress = unique(draft.transforms.compress);
211
220
  if (Array.isArray(draft.transforms?.preprocess)) draft.transforms.preprocess = unique(draft.transforms.preprocess);
221
+ if (Array.isArray(draft.resolution?.conditions)) draft.resolution.conditions = unique(draft.resolution.conditions);
212
222
  if (Array.isArray(draft.transforms.includeExternals)) {
213
223
  draft.transforms.includeExternals = unique(draft.transforms.includeExternals);
214
224
  }
@@ -240,6 +250,7 @@ function validate(draft, packageJson) {
240
250
  validateFormats(draft.transforms.compress, 'transforms.compress', issues);
241
251
  validateStrings(draft.outputs.umdEntries, 'outputs.umdEntries', issues);
242
252
  validateStrings(draft.transforms.preprocess, 'transforms.preprocess', issues);
253
+ validateStrings(draft.resolution.conditions, 'resolution.conditions', issues);
243
254
  if (draft.outputs.umdEntries.length > 0 && typeof packageJson.name !== 'string') {
244
255
  issues.push({ code: 'PACKAGE_NAME_REQUIRED', path: 'package.name', message: 'a package name is required for UMD entries' });
245
256
  }
@@ -10,9 +10,10 @@ import { isExists } from './helpers.js';
10
10
  * @typedef {import('./types.js').BuildEntryContributions} BuildEntryContributions
11
11
  * @typedef {import('./types.js').BuildEntryIssue} BuildEntryIssue
12
12
  * @typedef {import('./types.js').BuildFormat} BuildFormat
13
+ * @typedef {import('./types.js').ImportTarget} ImportTarget
13
14
  */
14
15
 
15
- const sourceFileExtensions = /** @type {const} */ (['ts', 'tsx', 'js', 'jsx', 'cjs', 'mjs']);
16
+ export const sourceFileExtensions = /** @type {const} */ (['ts', 'tsx', 'js', 'jsx', 'cjs', 'mjs']);
16
17
 
17
18
  export class BuildEntryError extends Error {
18
19
  /** @param {BuildEntryIssue[]} issues */
@@ -28,18 +29,25 @@ export class BuildEntryError extends Error {
28
29
  * one immutable catalog.
29
30
  *
30
31
  * @param {readonly string[]} packageEntryNames
32
+ * @param {readonly ImportTarget[]} importTargets
31
33
  * @param {BuildConfiguration} configuration
32
34
  * @param {(contributions: BuildEntryContributions) => void} contribute
33
35
  * @returns {Promise<BuildEntries>}
34
36
  */
35
- export async function resolveBuildEntries(packageEntryNames, configuration, contribute) {
36
- /** @type {(BuildEntryContribution & { issuePath: string })[]} */
37
- const specifications = packageEntryNames.map((name, index) => ({ name, issuePath: `package.entries[${index}]` }));
37
+ export async function resolveBuildEntries(packageEntryNames, importTargets, configuration, contribute) {
38
+ /** @type {(BuildEntryContribution & { issuePath: string; origin: 'export' | 'plugin'; manifestPath: string })[]} */
39
+ const specifications = packageEntryNames.map((name, index) => ({
40
+ name,
41
+ issuePath: `package.entries[${index}]`,
42
+ origin: 'export',
43
+ manifestPath: `package.exports[${JSON.stringify(name === 'index' ? '.' : `./${name}`)}]`,
44
+ }));
38
45
  let contributionIndex = 0;
39
46
  const contributions = {
40
47
  /** @param {BuildEntryContribution} contribution */
41
48
  add(contribution) {
42
- specifications.push({ ...contribution, issuePath: `plugins.entries[${contributionIndex}]` });
49
+ const issuePath = `plugins.entries[${contributionIndex}]`;
50
+ specifications.push({ ...contribution, issuePath, origin: 'plugin', manifestPath: issuePath });
43
51
  contributionIndex += 1;
44
52
  },
45
53
  };
@@ -87,6 +95,8 @@ export async function resolveBuildEntries(packageEntryNames, configuration, cont
87
95
  }
88
96
  const entry = Object.freeze({
89
97
  name,
98
+ origin: specification.origin,
99
+ manifestPath: specification.manifestPath,
90
100
  sourcePath: source.sourcePath,
91
101
  extension: source.extension,
92
102
  outputPaths: Object.freeze(outputPaths),
@@ -95,13 +105,47 @@ export async function resolveBuildEntries(packageEntryNames, configuration, cont
95
105
  byName.set(name, entry);
96
106
  }
97
107
 
108
+ for (const target of importTargets) {
109
+ const name = `@imports/${target.outputPath.slice(2)}`;
110
+ if (byName.has(name)) {
111
+ issues.push({
112
+ code: 'DUPLICATE_BUILD_ENTRY',
113
+ path: target.issuePath,
114
+ name,
115
+ message: `Build entry ${JSON.stringify(name)} is declared more than once`,
116
+ });
117
+ continue;
118
+ }
119
+ const source = await resolveSource(target.sourceName, undefined, configuration.paths.sourceDir);
120
+ if (!source) {
121
+ issues.push({
122
+ code: 'SOURCE_NOT_FOUND',
123
+ path: target.issuePath,
124
+ name,
125
+ message: `Import target ${JSON.stringify(target.outputPath)} has no supported source file`,
126
+ });
127
+ continue;
128
+ }
129
+ const entry = Object.freeze({
130
+ name,
131
+ origin: /** @type {const} */ ('import'),
132
+ manifestPath: target.issuePath,
133
+ sourcePath: source.sourcePath,
134
+ extension: source.extension,
135
+ outputPaths: Object.freeze({ [target.format]: target.outputPath }),
136
+ });
137
+ values.push(entry);
138
+ byName.set(name, entry);
139
+ }
140
+
98
141
  validateSelections(byName, configuration.outputs.umdEntries, 'outputs.umdEntries', issues);
99
142
  validateSelections(byName, configuration.transforms.preprocess, 'transforms.preprocess', issues);
100
- validateOutputPaths(values, issues);
143
+ const shared = validateOutputPaths(values, issues);
101
144
 
102
145
  if (issues.length > 0) throw new BuildEntryError(issues);
103
146
 
104
- const frozenValues = Object.freeze(values);
147
+ for (const entry of shared) byName.delete(entry.name);
148
+ const frozenValues = Object.freeze(values.filter(entry => !shared.has(entry)));
105
149
  return Object.freeze({
106
150
  values: frozenValues,
107
151
  /** @param {string} name */
@@ -113,7 +157,7 @@ export async function resolveBuildEntries(packageEntryNames, configuration, cont
113
157
  code: 'SELECTED_BUILD_ENTRY_NOT_FOUND',
114
158
  path: 'entries',
115
159
  name,
116
- message: `Build entry ${JSON.stringify(name)} was not discovered; available entries: ${values.map(entry => entry.name).join(', ')}`,
160
+ message: `Build entry ${JSON.stringify(name)} was not discovered; available entries: ${frozenValues.map(entry => entry.name).join(', ')}`,
117
161
  },
118
162
  ]);
119
163
  }
@@ -195,20 +239,33 @@ function validateSelections(byName, names, selectionPath, issues) {
195
239
  * @param {BuildEntryIssue[]} issues
196
240
  */
197
241
  function validateOutputPaths(entries, issues) {
242
+ /** @type {Map<string, { entry: BuildEntry; format: string }>} */
198
243
  const owners = new Map();
244
+ /** @type {Set<BuildEntry>} */
245
+ const shared = new Set();
199
246
  for (const entry of entries) {
200
247
  for (const [format, outputPath] of Object.entries(entry.outputPaths)) {
201
- const owner = owners.get(outputPath);
248
+ const normalizedPath = path.resolve(outputPath);
249
+ const owner = owners.get(normalizedPath);
202
250
  if (owner) {
203
- issues.push({
204
- code: 'OUTPUT_PATH_COLLISION',
205
- path: `entries.${entry.name}.outputPaths.${format}`,
206
- name: entry.name,
207
- message: `Output path ${JSON.stringify(outputPath)} is also produced by Build entry ${JSON.stringify(owner)}`,
208
- });
251
+ if (
252
+ entry.origin === 'import' &&
253
+ owner.format === format &&
254
+ path.resolve(entry.sourcePath) === path.resolve(owner.entry.sourcePath)
255
+ ) {
256
+ shared.add(entry);
257
+ } else {
258
+ issues.push({
259
+ code: 'OUTPUT_PATH_COLLISION',
260
+ path: entry.manifestPath,
261
+ name: entry.name,
262
+ message: `Output path ${JSON.stringify(outputPath)} conflicts with ${owner.entry.manifestPath}`,
263
+ });
264
+ }
209
265
  } else {
210
- owners.set(outputPath, entry.name);
266
+ owners.set(normalizedPath, { entry, format });
211
267
  }
212
268
  }
213
269
  }
270
+ return shared;
214
271
  }
@@ -26,7 +26,7 @@ export function curry(fn, ...args) {
26
26
  * @param {PackageProcessingResult} packageResult
27
27
  */
28
28
  export default async function (provider, configuration, packageResult) {
29
- const inputs = packageResult.entries.values.map(entry => entry.sourcePath);
29
+ const inputs = packageResult.entries.values.filter(entry => entry.origin !== 'import').map(entry => entry.sourcePath);
30
30
  if (configuration.transforms.includeExternals === true) {
31
31
  return;
32
32
  }
@@ -42,7 +42,7 @@ export default async function (provider, configuration, packageResult) {
42
42
  provider.provide(
43
43
  () =>
44
44
  pluginExternals(
45
- configuration.transforms.includeExternals === false
45
+ configuration.transforms.includeExternals === false && !configuration.resolution.imports
46
46
  ? {}
47
47
  : (/** @type {string} */ id, /** @type {boolean} */ external, /** @type {string} */ importer) =>
48
48
  includeExternals(importer, external, id, configuration)
@@ -86,6 +86,7 @@ export default async function (provider, configuration, packageResult) {
86
86
  * @param {BuildConfiguration} configuration
87
87
  */
88
88
  function includeExternals(_importer, external, id, configuration) {
89
+ if (configuration.resolution.imports && id.startsWith('#')) return false;
89
90
  if (configuration.transforms.includeExternals === false) return external;
90
91
  if (!external) return false;
91
92
  const internals = /** @type {readonly string[]} */ (configuration.transforms.includeExternals);
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { Priority } from '../priorities.js';
5
+
6
+ /**
7
+ * @typedef {import('../types.js').BuildConfiguration} BuildConfiguration
8
+ * @typedef {import('../types.js').Provider} Provider
9
+ */
10
+
11
+ /**
12
+ * @param {Provider} provider
13
+ * @param {BuildConfiguration} configuration
14
+ */
15
+ export default function (provider, configuration) {
16
+ if (!configuration.resolution.imports) return;
17
+
18
+ provider.globalImport('node:fs/promises', 'fs');
19
+ provider.globalImport('path', 'path');
20
+ const createPlugin = /** @type {typeof createPackageImportsPlugin} */ (
21
+ provider.globalSetup(createPackageImportsPlugin) ?? createPackageImportsPlugin
22
+ );
23
+ provider.provide(() => createPlugin(), Priority.packageImports);
24
+ }
25
+
26
+ /**
27
+ * Keep the package's private specifiers for runtime resolution. Each importer
28
+ * is assigned to its nearest real package boundary, so linked dependencies and
29
+ * nested workspace packages retain ownership of their own private imports.
30
+ */
31
+ export function createPackageImportsPlugin() {
32
+ const packageRoot = fs.realpath(process.cwd());
33
+ /** @type {Map<string, string | null>} */
34
+ const owners = new Map();
35
+ return {
36
+ name: 'pkgbld:package-imports',
37
+ /** @param {string} id @param {string | undefined} importer */
38
+ async resolveId(id, importer) {
39
+ if (!id.startsWith('#') || !importer || importer.includes('\0')) return null;
40
+ let realImporter;
41
+ try {
42
+ realImporter = await fs.realpath(importer.split('?', 1)[0]);
43
+ } catch (error) {
44
+ if (['ENOENT', 'EINVAL'].includes(/** @type {NodeJS.ErrnoException} */ (error).code ?? '')) return null;
45
+ throw error;
46
+ }
47
+ const owner = await findOwner(path.dirname(realImporter));
48
+ return owner === (await packageRoot) ? { id, external: true } : null;
49
+ },
50
+ };
51
+
52
+ /** @param {string} start */
53
+ async function findOwner(start) {
54
+ let directory = start;
55
+ /** @type {string[]} */
56
+ const visited = [];
57
+ while (true) {
58
+ if (owners.has(directory)) {
59
+ const owner = /** @type {string | null} */ (owners.get(directory));
60
+ for (const visitedDirectory of visited) owners.set(visitedDirectory, owner);
61
+ return owner;
62
+ }
63
+ visited.push(directory);
64
+ try {
65
+ await fs.access(path.join(directory, 'package.json'));
66
+ for (const visitedDirectory of visited) owners.set(visitedDirectory, directory);
67
+ return directory;
68
+ } catch (error) {
69
+ if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'ENOENT') throw error;
70
+ }
71
+ const parent = path.dirname(directory);
72
+ if (parent === directory) {
73
+ for (const visitedDirectory of visited) owners.set(visitedDirectory, null);
74
+ return null;
75
+ }
76
+ directory = parent;
77
+ }
78
+ }
79
+ }
@@ -2,13 +2,21 @@ import { Priority } from '../priorities.js';
2
2
 
3
3
  /**
4
4
  * @typedef {import('../types.js').Provider} Provider
5
+ * @typedef {import('../types.js').BuildConfiguration} BuildConfiguration
5
6
  */
6
7
 
7
8
  /**
8
9
  * @param {Provider} provider
10
+ * @param {BuildConfiguration} configuration
9
11
  */
10
- export default async function (provider) {
12
+ export default async function (provider, configuration) {
11
13
  const pluginResolve = await provider.import('@rollup/plugin-node-resolve');
12
14
 
13
- provider.provide(() => pluginResolve(), Priority.resolve);
15
+ provider.provide(
16
+ () =>
17
+ configuration.resolution.conditions.length > 0
18
+ ? pluginResolve({ exportConditions: [...configuration.resolution.conditions] })
19
+ : pluginResolve(),
20
+ Priority.resolve
21
+ );
14
22
  }
@@ -3,6 +3,7 @@ import clean from './builtin-plugins/clean.js';
3
3
  import commonjs from './builtin-plugins/commonjs.js';
4
4
  import externals from './builtin-plugins/externals.js';
5
5
  import json from './builtin-plugins/json.js';
6
+ import packageImports from './builtin-plugins/package-imports.js';
6
7
  import preprocess from './builtin-plugins/preprocess.js';
7
8
  import resolve from './builtin-plugins/resolve.js';
8
9
  import terser from './builtin-plugins/terser.js';
@@ -12,7 +13,7 @@ import terser from './builtin-plugins/terser.js';
12
13
  * @typedef {import('./types.js').Provider} Provider
13
14
  */
14
15
 
15
- export const plugins = [clean, commonjs, externals, preprocess, resolve, terser, binify, json];
16
+ export const plugins = [clean, commonjs, externals, preprocess, packageImports, resolve, terser, binify, json];
16
17
 
17
18
  const noop = () => undefined;
18
19
 
@@ -1,3 +1,5 @@
1
+ import path from 'node:path';
2
+
1
3
  import refiner from '@slimlib/refine-partition';
2
4
 
3
5
  import { plugins as pluginFactories } from './get-plugins.js';
@@ -21,8 +23,11 @@ import { areSetsEqual, toArray } from './helpers.js';
21
23
  * @param {BuildPluginLifecycle} pluginLifecycle
22
24
  */
23
25
  export async function getRollupConfigs([provider, plugins], packageResult, configuration, helpers, pluginLifecycle) {
24
- const inputs = packageResult.entries.values.map(entry => entry.sourcePath);
25
- const entriesBySourcePath = new Map(packageResult.entries.values.map(entry => [entry.sourcePath, entry]));
26
+ const publicEntries = packageResult.entries.values.filter(entry => entry.origin !== 'import');
27
+ const privateEntries = packageResult.entries.values.filter(entry => entry.origin === 'import');
28
+ const inputs = publicEntries.map(entry => entry.sourcePath);
29
+ const publicInputSet = new Set(inputs);
30
+ const entriesBySourcePath = new Map(publicEntries.map(entry => [entry.sourcePath, entry]));
26
31
  const factoryInProgress = [];
27
32
 
28
33
  const fileNamePatterns = /** @type {{ [key in InternalModuleFormat]: string }} */ ({
@@ -43,7 +48,7 @@ export async function getRollupConfigs([provider, plugins], packageResult, confi
43
48
  const expandInputs = new Set();
44
49
 
45
50
  for (const plugin of plugins) {
46
- if (plugin.format && plugin.inputs?.length && !plugin.outputPlugin) {
51
+ if (plugin.format && plugin.inputs?.some(input => publicInputSet.has(input)) && !plugin.outputPlugin) {
47
52
  for (const format of toArray(plugin.format)) {
48
53
  expandInputs.add(format);
49
54
  }
@@ -56,6 +61,8 @@ export async function getRollupConfigs([provider, plugins], packageResult, confi
56
61
 
57
62
  for (const plugin of plugins) {
58
63
  if (plugin.format && !plugin.outputPlugin) {
64
+ const publicPluginInputs = plugin.inputs?.filter(input => publicInputSet.has(input));
65
+ if (plugin.inputs && publicPluginInputs?.length === 0) continue;
59
66
  const formats = toArray(plugin.format);
60
67
  if (!plugin.inputs || plugin.inputs.length === 0) {
61
68
  refineNext(doExpandInputs(formats));
@@ -64,7 +71,7 @@ export async function getRollupConfigs([provider, plugins], packageResult, confi
64
71
  } else {
65
72
  const expanded = [];
66
73
  for (const format of formats) {
67
- for (const input of plugin.inputs) {
74
+ for (const input of /** @type {string[]} */ (publicPluginInputs)) {
68
75
  expanded.push(`${format}.${input}`);
69
76
  }
70
77
  }
@@ -124,7 +131,7 @@ export async function getRollupConfigs([provider, plugins], packageResult, confi
124
131
  partitions.push({ formats: [...mapFormatInputs.keys()], inputs: [.../** @type {Set<string>} */ (prevInputs)] });
125
132
  }
126
133
 
127
- return partitions.map(({ formats, inputs }) => {
134
+ const publicConfigs = partitions.map(({ formats, inputs }) => {
128
135
  return {
129
136
  input: Object.fromEntries(
130
137
  inputs.map(input => [/** @type {import('./types.js').BuildEntry} */ (entriesBySourcePath.get(input)).name, input])
@@ -143,6 +150,30 @@ export async function getRollupConfigs([provider, plugins], packageResult, confi
143
150
  };
144
151
  });
145
152
 
153
+ const privateConfigs = privateEntries.map(entry => {
154
+ const [[format, outputPath]] = Object.entries(entry.outputPaths);
155
+ const input = entry.sourcePath;
156
+ const selectedFormat = /** @type {InternalModuleFormat} */ (format);
157
+ return {
158
+ input,
159
+ output: [
160
+ {
161
+ format: selectedFormat,
162
+ dir: configuration.paths.outputDir,
163
+ entryFileNames: path
164
+ .relative(path.resolve(configuration.paths.outputDir), path.resolve(outputPath))
165
+ .replaceAll('\\', '/'),
166
+ plugins: getPlugins([selectedFormat], [input], true, true),
167
+ sourcemap: configuration.outputs.sourcemaps.includes(/** @type {import('./types.js').BuildFormat} */ (selectedFormat)),
168
+ ...getExtraOutputSettings(selectedFormat, [input]),
169
+ },
170
+ ],
171
+ plugins: getPlugins([selectedFormat], [input], false, true),
172
+ };
173
+ });
174
+
175
+ return [...publicConfigs, ...privateConfigs];
176
+
146
177
  /**
147
178
  * @param {InternalModuleFormat} format
148
179
  * @param {string[]} inputs
@@ -176,14 +207,20 @@ export async function getRollupConfigs([provider, plugins], packageResult, confi
176
207
  * @param {InternalModuleFormat[]} formats
177
208
  * @param {string[]} inputs
178
209
  * @param {boolean} outputPlugin
210
+ * @param {boolean} [privateOutput]
179
211
  */
180
- function getPlugins(formats, inputs, outputPlugin) {
212
+ function getPlugins(formats, inputs, outputPlugin, privateOutput = false) {
181
213
  const filteredPlugins = [];
182
214
  for (const plugin of plugins) {
183
215
  if (!!plugin.outputPlugin === outputPlugin) {
216
+ const publicPluginInputs = plugin.inputs?.filter(input => publicInputSet.has(input));
184
217
  if (
185
218
  (!plugin.format || toArray(plugin.format).some(format => formats.includes(format))) &&
186
- (!plugin.inputs || plugin.inputs.every(input => inputs.includes(input)))
219
+ (!plugin.inputs ||
220
+ plugin.inputs.length === 0 ||
221
+ (privateOutput
222
+ ? plugin.inputs.some(input => inputs.includes(input))
223
+ : publicPluginInputs?.length > 0 && publicPluginInputs.every(input => inputs.includes(input))))
187
224
  ) {
188
225
  filteredPlugins.push({
189
226
  instance: plugin.plugin(),
@@ -59,6 +59,8 @@ export const cliFlagsDefaults = {
59
59
  src: 'src',
60
60
  bin: /** @type {string[] | undefined} */ (undefined),
61
61
  includeExternals: /** @type {boolean | string[]} */ (false),
62
+ imports: false,
63
+ conditions: /** @type {string[]} */ ([]),
62
64
  eject: false,
63
65
  tsConfig: false,
64
66
  updatePackageJson: true,
@@ -118,6 +120,15 @@ export const cliFlags = {
118
120
  description: 'Include all/specified externals into the result bundle(s)',
119
121
  optionalValue: true,
120
122
  },
123
+ imports: {
124
+ type: /** @type {'boolean'} */ ('boolean'),
125
+ description: 'Build and preserve package imports',
126
+ default: cliFlagsDefaults.imports,
127
+ },
128
+ conditions: {
129
+ type: /** @type {(value: string) => string[]} */ (CommaSeparatedString),
130
+ description: 'Additional conditions for resolving bundled dependencies',
131
+ },
121
132
  eject: {
122
133
  type: /** @type {'boolean'} */ ('boolean'),
123
134
  description: 'Eject config',
@@ -0,0 +1,240 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+
5
+ import { BuildEntryError, sourceFileExtensions } from './build-entries.js';
6
+
7
+ /**
8
+ * @typedef {import('./types.js').BuildConfiguration} BuildConfiguration
9
+ * @typedef {import('./types.js').BuildEntryIssue} BuildEntryIssue
10
+ * @typedef {import('./types.js').ImportTarget} ImportTarget
11
+ * @typedef {import('./types.js').BuildFormat} BuildFormat
12
+ */
13
+
14
+ const javascriptExtensions = new Set(['.js', '.mjs', '.cjs']);
15
+
16
+ /**
17
+ * Find every local JavaScript leaf of an authored package imports map.
18
+ * Non-JavaScript and package-specifier leaves remain the responsibility of
19
+ * their runtime resolver or producer.
20
+ *
21
+ * @param {unknown} imports
22
+ * @param {BuildConfiguration} configuration
23
+ * @param {unknown} packageType
24
+ * @returns {Promise<ImportTarget[]>}
25
+ */
26
+ export async function collectPackageImportTargets(imports, configuration, packageType) {
27
+ if (imports === undefined || !configuration.resolution.imports) return [];
28
+
29
+ /** @type {BuildEntryIssue[]} */
30
+ const issues = [];
31
+ /** @type {Map<string, ImportTarget>} */
32
+ const targets = new Map();
33
+ const leaves = parseImportMap(imports, issues);
34
+ for (const leaf of leaves) {
35
+ for (const target of await expandLocalTarget(leaf, configuration, packageType, issues)) {
36
+ if (!targets.has(target.outputPath)) targets.set(target.outputPath, target);
37
+ }
38
+ }
39
+
40
+ if (issues.length > 0) throw new BuildEntryError(issues);
41
+ return [...targets.values()];
42
+ }
43
+
44
+ /**
45
+ * Walk the authored map without selecting runtime conditions. Every local
46
+ * branch may need its own output, while package specifiers remain mappings.
47
+ * @param {unknown} imports
48
+ * @param {BuildEntryIssue[]} issues
49
+ */
50
+ function parseImportMap(imports, issues) {
51
+ /** @type {{ value: string; issuePath: string; wildcardKey: boolean }[]} */
52
+ const leaves = [];
53
+ if (!isRecord(imports)) {
54
+ issues.push({ code: 'INVALID_IMPORT_MAP', path: 'package.imports', message: 'must be an object' });
55
+ return leaves;
56
+ }
57
+
58
+ for (const [key, value] of Object.entries(imports)) {
59
+ const issuePath = `package.imports[${JSON.stringify(key)}]`;
60
+ const stars = key.match(/\*/g)?.length ?? 0;
61
+ if (!key.startsWith('#') || key === '#' || key.endsWith('/') || stars > 1) {
62
+ issues.push({ code: 'INVALID_IMPORT_KEY', path: issuePath, message: `Invalid package import key ${JSON.stringify(key)}` });
63
+ continue;
64
+ }
65
+ visit(value, issuePath, stars > 0);
66
+ }
67
+ return leaves;
68
+
69
+ /** @param {unknown} value @param {string} issuePath @param {boolean} wildcardKey */
70
+ function visit(value, issuePath, wildcardKey) {
71
+ if (value === null) return;
72
+ if (Array.isArray(value)) {
73
+ for (const [index, leaf] of value.entries()) visit(leaf, `${issuePath}[${index}]`, wildcardKey);
74
+ return;
75
+ }
76
+ if (isRecord(value)) {
77
+ for (const [condition, leaf] of Object.entries(value)) {
78
+ if (isArrayIndex(condition)) {
79
+ issues.push({
80
+ code: 'INVALID_IMPORT_CONDITION',
81
+ path: `${issuePath}.${condition}`,
82
+ message: `Integer condition key ${JSON.stringify(condition)} is invalid in a package imports map`,
83
+ });
84
+ } else {
85
+ visit(leaf, `${issuePath}.${condition}`, wildcardKey);
86
+ }
87
+ }
88
+ return;
89
+ }
90
+ if (typeof value !== 'string') {
91
+ issues.push({ code: 'INVALID_IMPORT_TARGET', path: issuePath, message: 'must be a string, null, array, or condition object' });
92
+ return;
93
+ }
94
+ if (!value.startsWith('./')) {
95
+ if (value.startsWith('.') || value.startsWith('/') || value.includes('\\') || value.length === 0 || URL.canParse(value)) {
96
+ issues.push({
97
+ code: 'INVALID_IMPORT_TARGET',
98
+ path: issuePath,
99
+ message: `Invalid package import target ${JSON.stringify(value)}`,
100
+ });
101
+ }
102
+ return;
103
+ }
104
+ leaves.push({ value, issuePath, wildcardKey });
105
+ }
106
+ }
107
+
108
+ /** @param {string} key */
109
+ function isArrayIndex(key) {
110
+ return /^(0|[1-9]\d*)$/.test(key) && Number(key) < 2 ** 32 - 1;
111
+ }
112
+
113
+ /**
114
+ * Validate a local URL target, then expand its output pattern against source
115
+ * names. Final source-extension selection happens in resolveBuildEntries.
116
+ * @param {{ value: string; issuePath: string; wildcardKey: boolean }} leaf
117
+ * @param {BuildConfiguration} configuration
118
+ * @param {unknown} packageType
119
+ * @param {BuildEntryIssue[]} issues
120
+ * @returns {Promise<ImportTarget[]>}
121
+ */
122
+ async function expandLocalTarget({ value, issuePath, wildcardKey }, configuration, packageType, issues) {
123
+ const pathPart = value.split(/[?#]/, 1)[0];
124
+ const rawSegments = pathPart.slice(2).split('/');
125
+ let outputPath;
126
+ try {
127
+ if (
128
+ pathPart.includes('\\') ||
129
+ rawSegments.some(segment => {
130
+ const decoded = decodeURIComponent(segment);
131
+ return (
132
+ decoded === '' ||
133
+ decoded === '.' ||
134
+ decoded === '..' ||
135
+ decoded.toLowerCase() === 'node_modules' ||
136
+ decoded.includes('/') ||
137
+ decoded.includes('\\')
138
+ );
139
+ })
140
+ ) {
141
+ throw new Error('invalid path segment');
142
+ }
143
+ outputPath = fileURLToPath(new URL(value, pathToFileURL(path.join(process.cwd(), 'package.json'))));
144
+ } catch {
145
+ issues.push({
146
+ code: 'INVALID_IMPORT_TARGET',
147
+ path: issuePath,
148
+ message: `Invalid local package import target ${JSON.stringify(value)}`,
149
+ });
150
+ return [];
151
+ }
152
+
153
+ const extension = path.extname(outputPath);
154
+ if (!javascriptExtensions.has(extension)) return [];
155
+
156
+ const outputDir = path.resolve(configuration.paths.outputDir);
157
+ if (!outputPath.startsWith(`${outputDir}${path.sep}`) || (pathPart.includes('*') && !wildcardKey)) {
158
+ issues.push({
159
+ code: 'INVALID_IMPORT_TARGET',
160
+ path: issuePath,
161
+ message: `Local JavaScript target ${JSON.stringify(value)} must be a safe path beneath ${configuration.paths.outputDir}`,
162
+ });
163
+ return [];
164
+ }
165
+
166
+ const format = /** @type {BuildFormat} */ (
167
+ extension === '.mjs' ? 'es' : extension === '.cjs' ? 'cjs' : packageType === 'module' ? 'es' : 'cjs'
168
+ );
169
+ if (!configuration.outputs.formats.includes(format)) {
170
+ issues.push({
171
+ code: 'EXCLUDED_IMPORT_FORMAT',
172
+ path: issuePath,
173
+ message: `Local target ${JSON.stringify(value)} requires ${format}, which is excluded by outputs.formats`,
174
+ });
175
+ return [];
176
+ }
177
+
178
+ const relativeOutput = path.relative(outputDir, outputPath).replaceAll('\\', '/');
179
+ const sourceName = relativeOutput.slice(0, -extension.length);
180
+ if (pathPart.includes('*')) {
181
+ const sourceNames = await findMatchingSourceNames(configuration.paths.sourceDir, sourceName);
182
+ if (sourceNames.length === 0) {
183
+ issues.push({
184
+ code: 'SOURCE_NOT_FOUND',
185
+ path: issuePath,
186
+ message: `Import target ${JSON.stringify(value)} has no supported source file`,
187
+ });
188
+ }
189
+ return sourceNames.map(matchedName => ({
190
+ sourceName: matchedName,
191
+ outputPath: `./${configuration.paths.outputDir}/${matchedName}${extension}`,
192
+ format,
193
+ issuePath,
194
+ }));
195
+ }
196
+ return [{ sourceName, outputPath: `./${configuration.paths.outputDir}/${relativeOutput}`, format, issuePath }];
197
+ }
198
+
199
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
200
+ function isRecord(value) {
201
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
202
+ }
203
+
204
+ /** @param {string} sourceDir @param {string} pattern */
205
+ async function findMatchingSourceNames(sourceDir, pattern) {
206
+ const parts = pattern.split('*').map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
207
+ const matcher = new RegExp(
208
+ `^${parts[0]}(.+?)${parts[1]}${parts
209
+ .slice(2)
210
+ .map(part => `\\1${part}`)
211
+ .join('')}$`
212
+ );
213
+ /** @type {Set<string>} */
214
+ const names = new Set();
215
+ await walk(sourceDir, '');
216
+ return [...names].sort();
217
+
218
+ /** @param {string} directory @param {string} relative */
219
+ async function walk(directory, relative) {
220
+ let entries;
221
+ try {
222
+ entries = await fs.readdir(directory, { withFileTypes: true });
223
+ } catch (error) {
224
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return;
225
+ throw error;
226
+ }
227
+ for (const entry of entries) {
228
+ const nextRelative = relative ? `${relative}/${entry.name}` : entry.name;
229
+ if (entry.isDirectory()) {
230
+ await walk(path.join(directory, entry.name), nextRelative);
231
+ } else if (entry.isFile()) {
232
+ const extension = path.posix.extname(nextRelative).slice(1);
233
+ if (sourceFileExtensions.includes(/** @type {typeof sourceFileExtensions[number]} */ (extension))) {
234
+ const name = nextRelative.slice(0, -(extension.length + 1));
235
+ if (matcher.test(name)) names.add(name);
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
package/src/priorities.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export const Priority = /** @type {const} */ ({
2
2
  preprocess: 1000,
3
3
  cleanup: 1000,
4
+ packageImports: 1500,
4
5
  externals: 2000,
5
6
  resolve: 3000,
6
7
  commonjs: 4000,
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { createLogger, LogLevel } from '@niceties/logger';
4
4
 
5
5
  import { resolveBuildEntries } from './build-entries.js';
6
+ import { collectPackageImportTargets } from './package-imports.js';
6
7
 
7
8
  /**
8
9
  * @typedef {import('type-fest').JsonObject} JsonObject
@@ -42,6 +43,9 @@ export async function processPackage(pkg, configuration, pluginLifecycle) {
42
43
  process.exit(-1);
43
44
  }
44
45
 
46
+ const finalPackageType = allowEsm && !allowCjs && typeof pkg.type !== 'string' ? 'module' : pkg.type;
47
+ const importTargets = await collectPackageImportTargets(pkg.imports, configuration, finalPackageType);
48
+
45
49
  if (!Array.isArray(pkg.files)) {
46
50
  pkg.files = [];
47
51
  }
@@ -71,7 +75,7 @@ export async function processPackage(pkg, configuration, pluginLifecycle) {
71
75
  entryNames.push(indexId);
72
76
  }
73
77
 
74
- const entries = await resolveBuildEntries(entryNames, configuration, contributions =>
78
+ const entries = await resolveBuildEntries(entryNames, importTargets, configuration, contributions =>
75
79
  pluginLifecycle.contributeEntries(contributions, configuration)
76
80
  );
77
81
  const indexEntry = entries.require(indexId);
@@ -171,14 +175,15 @@ export async function processPackage(pkg, configuration, pluginLifecycle) {
171
175
  pkg.bin = /** @type {string} */ (executableOutputs[0]);
172
176
  }
173
177
  } else if (packageJson.executables.mode === 'infer' && allowCjs && entries.values.length > 0) {
178
+ const executableEntries = entries.values.filter(entry => entry.origin !== 'import');
174
179
  if (typeof pkg.bin === 'string') {
175
- if (entries.values.some(entry => pkg.bin === entry.outputPaths.cjs)) {
180
+ if (executableEntries.some(entry => pkg.bin === entry.outputPaths.cjs)) {
176
181
  executableOutputs = [pkg.bin];
177
182
  }
178
183
  } else if (typeof pkg.bin === 'object' && pkg.bin !== null) {
179
184
  executableOutputs = /** @type {string[]} */ (
180
185
  Object.values(pkg.bin).filter(
181
- value => typeof value === 'string' && entries.values.some(entry => value === entry.outputPaths.cjs)
186
+ value => typeof value === 'string' && executableEntries.some(entry => value === entry.outputPaths.cjs)
182
187
  )
183
188
  );
184
189
  }
@@ -189,7 +194,9 @@ export async function processPackage(pkg, configuration, pluginLifecycle) {
189
194
  typeof pkg.directories.bin === 'string'
190
195
  ) {
191
196
  if (path.resolve(pkg.directories.bin) === path.resolve(paths.outputDir)) {
192
- executableOutputs.push(...entries.values.flatMap(entry => (entry.outputPaths.cjs == null ? [] : [entry.outputPaths.cjs])));
197
+ executableOutputs.push(
198
+ ...executableEntries.flatMap(entry => (entry.outputPaths.cjs == null ? [] : [entry.outputPaths.cjs]))
199
+ );
193
200
  executableOutputs = Array.from(new Set(executableOutputs));
194
201
  }
195
202
  }
package/src/types.js CHANGED
@@ -60,6 +60,7 @@
60
60
  * includeExternals: boolean | string[];
61
61
  * removeLegalComments: boolean;
62
62
  * };
63
+ * resolution: { imports: boolean; conditions: string[] };
63
64
  * packageJson: {
64
65
  * update: boolean;
65
66
  * format: boolean;
@@ -87,6 +88,7 @@
87
88
  * includeExternals: boolean | readonly string[];
88
89
  * removeLegalComments: boolean;
89
90
  * }>;
91
+ * resolution: Readonly<{ imports: boolean; conditions: readonly string[] }>;
90
92
  * packageJson: Readonly<{
91
93
  * update: boolean;
92
94
  * format: boolean;
@@ -117,6 +119,8 @@
117
119
  /**
118
120
  * @typedef {Readonly<{
119
121
  * name: string;
122
+ * origin: 'export' | 'import' | 'plugin';
123
+ * manifestPath: string;
120
124
  * sourcePath: string;
121
125
  * extension: BuildEntryExtension;
122
126
  * outputPaths: Readonly<Partial<Record<BuildFormat, string>>>;
@@ -125,7 +129,8 @@
125
129
 
126
130
  /** @typedef {{ name: string; sourcePath?: string }} BuildEntryContribution */
127
131
  /** @typedef {{ add(contribution: BuildEntryContribution): void }} BuildEntryContributions */
128
- /** @typedef {{ code: 'INVALID_BUILD_ENTRY_NAME' | 'DUPLICATE_BUILD_ENTRY' | 'SOURCE_NOT_FOUND' | 'SELECTED_BUILD_ENTRY_NOT_FOUND' | 'OUTPUT_PATH_COLLISION'; path: string; message: string; name?: string }} BuildEntryIssue */
132
+ /** @typedef {{ sourceName: string; outputPath: string; format: BuildFormat; issuePath: string }} ImportTarget */
133
+ /** @typedef {{ code: 'INVALID_BUILD_ENTRY_NAME' | 'DUPLICATE_BUILD_ENTRY' | 'SOURCE_NOT_FOUND' | 'SELECTED_BUILD_ENTRY_NOT_FOUND' | 'OUTPUT_PATH_COLLISION' | 'INVALID_IMPORT_MAP' | 'INVALID_IMPORT_KEY' | 'INVALID_IMPORT_TARGET' | 'EXCLUDED_IMPORT_FORMAT'; path: string; message: string; name?: string }} BuildEntryIssue */
129
134
 
130
135
  /**
131
136
  * @typedef {Readonly<{
package/types/index.d.ts CHANGED
@@ -78,6 +78,10 @@ declare module 'pkgbld' {
78
78
  includeExternals: boolean | string[];
79
79
  removeLegalComments: boolean;
80
80
  };
81
+ resolution: {
82
+ imports: boolean;
83
+ conditions: string[];
84
+ };
81
85
  packageJson: {
82
86
  update: boolean;
83
87
  format: boolean;
@@ -118,6 +122,10 @@ declare module 'pkgbld' {
118
122
  includeExternals: boolean | readonly string[];
119
123
  removeLegalComments: boolean;
120
124
  }>;
125
+ resolution: Readonly<{
126
+ imports: boolean;
127
+ conditions: readonly string[];
128
+ }>;
121
129
  packageJson: Readonly<{
122
130
  update: boolean;
123
131
  format: boolean;
@@ -149,6 +157,8 @@ declare module 'pkgbld' {
149
157
  type PluginSharedState_1 = Map<unknown, unknown>;
150
158
  type BuildEntry_1 = Readonly<{
151
159
  name: string;
160
+ origin: "export" | "import" | "plugin";
161
+ manifestPath: string;
152
162
  sourcePath: string;
153
163
  extension: BuildEntryExtension;
154
164
  outputPaths: Readonly<Partial<Record<BuildFormat_1, string>>>;
@@ -161,7 +171,7 @@ declare module 'pkgbld' {
161
171
  add(contribution: BuildEntryContribution_1): void;
162
172
  };
163
173
  type BuildEntryIssue_1 = {
164
- code: "INVALID_BUILD_ENTRY_NAME" | "DUPLICATE_BUILD_ENTRY" | "SOURCE_NOT_FOUND" | "SELECTED_BUILD_ENTRY_NOT_FOUND" | "OUTPUT_PATH_COLLISION";
174
+ code: "INVALID_BUILD_ENTRY_NAME" | "DUPLICATE_BUILD_ENTRY" | "SOURCE_NOT_FOUND" | "SELECTED_BUILD_ENTRY_NOT_FOUND" | "OUTPUT_PATH_COLLISION" | "INVALID_IMPORT_MAP" | "INVALID_IMPORT_KEY" | "INVALID_IMPORT_TARGET" | "EXCLUDED_IMPORT_FORMAT";
165
175
  path: string;
166
176
  message: string;
167
177
  name?: string;
@@ -245,6 +255,8 @@ declare module 'pkgbld/options' {
245
255
  let src: string;
246
256
  let bin: string[] | undefined;
247
257
  let includeExternals: boolean | string[];
258
+ let imports: boolean;
259
+ let conditions: string[];
248
260
  let eject: boolean;
249
261
  let tsConfig: boolean;
250
262
  let updatePackageJson: boolean;