@bobfrankston/importgen 0.1.35 → 0.1.36

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.
Files changed (2) hide show
  1. package/index.js +180 -55
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -37,49 +37,122 @@ function resolveDependencyPath(packageDir, depName, depVersion) {
37
37
  }
38
38
  }
39
39
  /**
40
- * Resolve entry point from package.json
40
+ * Resolve entry point from package.json. Handles modern conditional
41
+ * exports (`{ ".": { "import": { "browser": "...", "default": "..." } } }`)
42
+ * by recursively walking the conditions until a string-valued leaf is
43
+ * found, preferring `import` / `browser` / `default` for our use case.
41
44
  */
42
45
  function resolveEntryPoint(pkg, packageDir) {
46
+ void packageDir;
43
47
  // 1. Check exports field (modern)
44
48
  if (pkg.exports) {
45
49
  if (typeof pkg.exports === 'string') {
46
50
  return pkg.exports;
47
51
  }
48
- else if (pkg.exports['.']) {
49
- const dotExport = pkg.exports['.'];
50
- if (typeof dotExport === 'string') {
51
- return dotExport;
52
- }
53
- else if (dotExport.import) {
54
- return dotExport.import;
55
- }
56
- else if (dotExport.default) {
57
- return dotExport.default;
58
- }
59
- }
52
+ const dotExport = pkg.exports['.'] ?? pkg.exports;
53
+ const found = pickConditionalExport(dotExport);
54
+ if (found)
55
+ return found;
60
56
  }
61
57
  // 2. Check module field (ESM)
62
- if (pkg.module) {
58
+ if (typeof pkg.module === 'string')
63
59
  return pkg.module;
64
- }
65
60
  // 3. Fall back to main field
66
- if (pkg.main) {
61
+ if (typeof pkg.main === 'string')
67
62
  return pkg.main;
68
- }
69
63
  // 4. Default to index.js
70
64
  return './index.js';
71
65
  }
66
+ /** Walk a conditional-exports node looking for a string entry point.
67
+ * Conditions traversed in priority order: `import`, `browser`, `module`,
68
+ * `default`, then any other keys. Recurses through nested condition
69
+ * objects. Returns null if no string leaf is found. */
70
+ function pickConditionalExport(node) {
71
+ if (typeof node === 'string')
72
+ return node;
73
+ if (!node || typeof node !== 'object')
74
+ return null;
75
+ const conditions = ['import', 'browser', 'module', 'default'];
76
+ for (const cond of conditions) {
77
+ if (cond in node) {
78
+ const found = pickConditionalExport(node[cond]);
79
+ if (found)
80
+ return found;
81
+ }
82
+ }
83
+ // Fall back to any other key (rarely hit; covers exotic shapes).
84
+ for (const key of Object.keys(node)) {
85
+ if (conditions.includes(key))
86
+ continue;
87
+ const found = pickConditionalExport(node[key]);
88
+ if (found)
89
+ return found;
90
+ }
91
+ return null;
92
+ }
72
93
  /**
73
94
  * Recursively collect all dependencies, avoiding circular references.
74
95
  * nodeModulesPrefix tracks the import map path prefix for nested deps
75
96
  * (e.g., './node_modules' for root, './node_modules/@scope/pkg/node_modules' for nested).
76
97
  */
98
+ /** Expand a single npm `workspaces` glob pattern under `rootDir` to a list
99
+ * of absolute workspace member directories. Supports literal paths and a
100
+ * trailing single `*` segment (the common npm-workspaces shapes:
101
+ * `packages/*`, `apps/*`, `client`). Other glob shapes are not handled. */
102
+ function expandWorkspacePattern(rootDir, pattern) {
103
+ const segs = pattern.split(/[\\/]/).filter(Boolean);
104
+ const globIdx = segs.findIndex(s => /[*?[]/.test(s));
105
+ if (globIdx === -1) {
106
+ const full = path.join(rootDir, ...segs);
107
+ return fs.existsSync(full) ? [full] : [];
108
+ }
109
+ if (globIdx === segs.length - 1 && segs[globIdx] === '*') {
110
+ const parentDir = path.join(rootDir, ...segs.slice(0, globIdx));
111
+ if (!fs.existsSync(parentDir))
112
+ return [];
113
+ return fs.readdirSync(parentDir, { withFileTypes: true })
114
+ .filter(e => e.isDirectory() || e.isSymbolicLink())
115
+ .map(e => path.join(parentDir, e.name));
116
+ }
117
+ return [];
118
+ }
77
119
  function collectDependencies(packageJsonPath, visited, dependencies, htmlDir, nodeModulesPrefix, warnings, depPaths) {
78
120
  try {
79
121
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
80
122
  const packageDir = path.dirname(packageJsonPath);
81
- const deps = packageJson.dependencies || {};
123
+ const declaredDeps = packageJson.dependencies || {};
82
124
  const dotDeps = packageJson['.dependencies'] || {};
125
+ // Bug 4: synthesize deps from the `workspaces` field. npm/pnpm
126
+ // workspaces convention is that sibling packages are reachable by
127
+ // package name without re-declaring them as deps. Each member's
128
+ // package.json `name` is added as a `file:`-protocol dep so the
129
+ // existing resolver finds it (via the junction npm puts in
130
+ // node_modules, or via the workspace path directly).
131
+ const workspaces = packageJson.workspaces;
132
+ let workspacePatterns = [];
133
+ if (Array.isArray(workspaces)) {
134
+ workspacePatterns = workspaces;
135
+ }
136
+ else if (workspaces && typeof workspaces === 'object' && Array.isArray(workspaces.packages)) {
137
+ workspacePatterns = workspaces.packages;
138
+ }
139
+ const syntheticDeps = {};
140
+ for (const pattern of workspacePatterns) {
141
+ for (const wsDir of expandWorkspacePattern(packageDir, pattern)) {
142
+ const wsPkgPath = path.join(wsDir, 'package.json');
143
+ if (!fs.existsSync(wsPkgPath))
144
+ continue;
145
+ try {
146
+ const wsPkg = JSON.parse(fs.readFileSync(wsPkgPath, 'utf-8'));
147
+ if (typeof wsPkg.name === 'string' && !(wsPkg.name in declaredDeps)) {
148
+ const rel = path.relative(packageDir, wsDir).split(path.sep).join('/');
149
+ syntheticDeps[wsPkg.name] = `file:${rel}`;
150
+ }
151
+ }
152
+ catch { /* malformed workspace pkg — skip */ }
153
+ }
154
+ }
155
+ const deps = { ...syntheticDeps, ...declaredDeps };
83
156
  for (const [depName, depVersion] of Object.entries(deps)) {
84
157
  // Skip if already processed (circular dependency protection)
85
158
  if (visited.has(depName)) {
@@ -138,10 +211,18 @@ function collectDependencies(packageJsonPath, visited, dependencies, htmlDir, no
138
211
  searchDir = parent;
139
212
  }
140
213
  if (!depPrefix) {
141
- // Fallback for deps not in any ancestor node_modules
142
- depPrefix = `${nodeModulesPrefix}/${depName}`;
214
+ // Not in any ancestor node_modules (e.g. file: dep outside
215
+ // the workspace, no junction yet). Use the resolved real
216
+ // path relative to htmlDir — strictly more correct than
217
+ // the old `nodeModulesPrefix/depName` guess.
218
+ const rel = path.relative(htmlDir, depPath).split(path.sep).join('/');
219
+ depPrefix = rel.startsWith('.') ? rel : `./${rel}`;
143
220
  }
144
221
  dependencies.set(depName, `${depPrefix}/${entryFile}`);
222
+ // Bug 3: trailing-slash variant for deep imports
223
+ // (`import "pkg/sub.js"` requires `"pkg/": "<root>/"`).
224
+ // Harmless when unused — browsers only fetch what's imported.
225
+ dependencies.set(`${depName}/`, `${depPrefix}/`);
145
226
  // Recursively process this dependency's dependencies
146
227
  collectDependencies(depPackageJsonPath, visited, dependencies, htmlDir, `${depPrefix}/node_modules`, warnings, depPaths);
147
228
  }
@@ -241,71 +322,113 @@ if (import.meta.main) {
241
322
  const unknownArgs = args.filter(a => a.startsWith('-') && !knownFlags.has(a));
242
323
  if (unknownArgs.length > 0) {
243
324
  console.error(`${ts()} [importgen] Error: unrecognized argument(s): ${unknownArgs.join(', ')}`);
244
- console.error(` Usage: importgen [htmlfile] [-w|--watch] [-v|--version]`);
325
+ console.error(` Usage: importgen [htmlfile...] [-w|--watch] [-v|--version]`);
245
326
  process.exit(1);
246
327
  }
247
328
  const watchMode = args.includes('-w') || args.includes('--watch');
248
329
  const positionalArgs = args.filter(a => !a.startsWith('-'));
249
- if (positionalArgs.length > 1) {
250
- console.error(`${ts()} [importgen] Error: too many arguments: ${positionalArgs.join(', ')}`);
251
- console.error(` Usage: importgen [htmlfile] [-w|--watch] [-v|--version]`);
252
- process.exit(1);
253
- }
254
- const htmlArg = positionalArgs[0];
255
- const packageJsonPath = path.join(process.cwd(), 'package.json');
330
+ // Bug 5: accept multiple HTML targets. Each gets its own import map
331
+ // with paths rewritten relative to that file's directory. Useful for
332
+ // multi-page apps where each html has its own document/module context
333
+ // (e.g. mailx: index.html, compose/compose.html, …).
256
334
  const possibleHtmlFiles = ['index.html', 'default.html', 'default.htm'];
257
- const htmlFileName = htmlArg || possibleHtmlFiles.find(f => fs.existsSync(path.join(process.cwd(), f)));
258
- const htmlFilePath = htmlFileName ? path.join(process.cwd(), htmlFileName) : null;
259
- if (!fs.existsSync(packageJsonPath)) {
260
- console.error(`${ts()} [generate-importmap] Error: package.json not found in current directory`);
335
+ const htmlFileNames = positionalArgs.length > 0
336
+ ? positionalArgs
337
+ : (() => {
338
+ const found = possibleHtmlFiles.find(f => fs.existsSync(path.join(process.cwd(), f)));
339
+ return found ? [found] : [];
340
+ })();
341
+ const htmlFilePaths = htmlFileNames.map(n => path.resolve(process.cwd(), n));
342
+ // Find the right package.json for resolving deps. Walking up to the
343
+ // FIRST one is wrong when the html lives in a workspace child (e.g.
344
+ // `app/client/index.html` — `client/package.json` declares only the
345
+ // local workspace's direct deps; the runtime deps the html actually
346
+ // uses are declared in the workspace ROOT's package.json one level up).
347
+ //
348
+ // Strategy: walk up; prefer the OUTERMOST package.json that has a
349
+ // `workspaces` field (= monorepo root). Fall back to the nearest one
350
+ // when no workspace ancestor exists. Both cases pick correctly.
351
+ function findEffectivePackageJson(startDir) {
352
+ let nearest = null;
353
+ let workspaceRoot = null;
354
+ let dir = startDir;
355
+ while (true) {
356
+ const candidate = path.join(dir, 'package.json');
357
+ if (fs.existsSync(candidate)) {
358
+ if (!nearest)
359
+ nearest = candidate;
360
+ try {
361
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
362
+ if (pkg.workspaces)
363
+ workspaceRoot = candidate;
364
+ }
365
+ catch { /* malformed — skip */ }
366
+ }
367
+ const parent = path.dirname(dir);
368
+ if (parent === dir)
369
+ break;
370
+ dir = parent;
371
+ }
372
+ return workspaceRoot ?? nearest;
373
+ }
374
+ if (htmlFilePaths.length === 0) {
375
+ console.error(`${ts()} [generate-importmap] Error: No HTML file found. Looking for: ${possibleHtmlFiles.join(', ')}`);
261
376
  process.exit(1);
262
377
  }
263
- if (!htmlFilePath || !fs.existsSync(htmlFilePath)) {
264
- if (htmlArg) {
265
- console.error(`${ts()} [generate-importmap] Error: ${htmlArg} not found in current directory`);
266
- }
267
- else {
268
- console.error(`${ts()} [generate-importmap] Error: No HTML file found. Looking for: ${possibleHtmlFiles.join(', ')}`);
378
+ for (const p of htmlFilePaths) {
379
+ if (!fs.existsSync(p)) {
380
+ console.error(`${ts()} [generate-importmap] Error: ${p} not found`);
381
+ process.exit(1);
269
382
  }
270
- process.exit(1);
271
383
  }
384
+ // Resolve effective package.json per html file (typically the same
385
+ // for all targets in a single project, but compute per-file to handle
386
+ // mixed cases).
387
+ const targets = htmlFilePaths.map(htmlPath => {
388
+ const pkgPath = findEffectivePackageJson(path.dirname(htmlPath)) ?? path.join(process.cwd(), 'package.json');
389
+ if (!fs.existsSync(pkgPath)) {
390
+ console.error(`${ts()} [generate-importmap] Error: no package.json found from ${path.dirname(htmlPath)} or any ancestor`);
391
+ process.exit(1);
392
+ }
393
+ return { htmlPath, pkgPath };
394
+ });
395
+ const uniquePkgPaths = Array.from(new Set(targets.map(t => t.pkgPath)));
272
396
  if (watchMode) {
273
- let result = generateImportMap(packageJsonPath, htmlFilePath);
274
397
  let watchedDepDirs = new Set();
275
- const watcher = chokidar.watch([packageJsonPath], {
398
+ const watcher = chokidar.watch(uniquePkgPaths, {
276
399
  persistent: true,
277
400
  ignoreInitial: true
278
401
  });
279
- function syncWatchedDeps(depDirs) {
280
- const newDirs = new Set(depDirs);
402
+ function regenerateAll() {
403
+ const newDirs = new Set();
404
+ for (const t of targets) {
405
+ const r = generateImportMap(t.pkgPath, t.htmlPath);
406
+ for (const d of r.depDirs)
407
+ newDirs.add(d);
408
+ }
281
409
  for (const dir of watchedDepDirs) {
282
- if (!newDirs.has(dir)) {
410
+ if (!newDirs.has(dir))
283
411
  watcher.unwatch(dir);
284
- }
285
412
  }
286
413
  for (const dir of newDirs) {
287
- if (!watchedDepDirs.has(dir)) {
414
+ if (!watchedDepDirs.has(dir))
288
415
  watcher.add(dir);
289
- }
290
416
  }
291
417
  watchedDepDirs = newDirs;
292
418
  }
293
- syncWatchedDeps(result.depDirs);
419
+ regenerateAll();
294
420
  let debounceTimer = null;
295
421
  const onChange = () => {
296
422
  if (debounceTimer)
297
423
  clearTimeout(debounceTimer);
298
- debounceTimer = setTimeout(() => {
299
- result = generateImportMap(packageJsonPath, htmlFilePath);
300
- syncWatchedDeps(result.depDirs);
301
- }, 100);
424
+ debounceTimer = setTimeout(regenerateAll, 100);
302
425
  };
303
426
  watcher.on('change', onChange);
304
427
  watcher.on('add', onChange);
305
428
  watcher.on('unlink', onChange);
306
429
  watcher.on('ready', () => {
307
- const total = 1 + watchedDepDirs.size;
308
- console.log(`${ts()} [generate-importmap] Watching ${total} directories for changes...`);
430
+ const total = uniquePkgPaths.length + watchedDepDirs.size;
431
+ console.log(`${ts()} [generate-importmap] Watching ${total} directories for changes (${targets.length} HTML target${targets.length === 1 ? '' : 's'})...`);
309
432
  if (watchedDepDirs.size > 0) {
310
433
  for (const dir of watchedDepDirs) {
311
434
  console.log(` ${dir}`);
@@ -317,7 +440,9 @@ if (import.meta.main) {
317
440
  });
318
441
  }
319
442
  else {
320
- generateImportMap(packageJsonPath, htmlFilePath);
443
+ for (const t of targets) {
444
+ generateImportMap(t.pkgPath, t.htmlPath);
445
+ }
321
446
  }
322
447
  }
323
448
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/importgen",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Generate ES Module import maps from package.json dependencies for native browser module loading",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",