@bobfrankston/importgen 0.1.35 → 0.1.37

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 +202 -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,40 @@ 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
+ // Transitive dep that npm placed in a NESTED node_modules
215
+ // (not hoisted to an ancestor of htmlDir). nodeModulesPrefix
216
+ // tracks that nested location through the recursion. Use it
217
+ // when the package physically resolves there — this is a
218
+ // path under the app's node_modules tree that the browser
219
+ // can actually fetch (junctions traverse transparently).
220
+ const nestedCandidate = `${nodeModulesPrefix}/${depName}`;
221
+ if (fs.existsSync(path.join(htmlDir, nestedCandidate))) {
222
+ depPrefix = nestedCandidate;
223
+ }
224
+ }
225
+ if (!depPrefix) {
226
+ // Last resort: not found in any node_modules at all (e.g. a
227
+ // file: dep with no junction). Use the resolved real path —
228
+ // which may escape the app dir; the RED FLAG below catches that.
229
+ const rel = path.relative(htmlDir, depPath).split(path.sep).join('/');
230
+ depPrefix = rel.startsWith('.') ? rel : `./${rel}`;
231
+ }
232
+ // RED FLAG: a `../`-escaping path leaves the HTML file's
233
+ // directory. The dev server's served root rarely extends
234
+ // above the app dir, so the browser will almost certainly
235
+ // 404 on it and the page breaks. We still emit the entry
236
+ // (it's the only real location we found), but flag it in
237
+ // the generated HTML so the breakage is not silent.
238
+ if (depPrefix.startsWith('../')) {
239
+ const warning = `${depName} -> ${depPrefix}/${entryFile} escapes the app directory; the browser will likely fail to load it unless the dev server serves a high-enough root. Add ${depName} to the app's node_modules.`;
240
+ console.warn(`${ts()} [generate-importmap] Warning: ${warning}`);
241
+ warnings.push(warning);
143
242
  }
144
243
  dependencies.set(depName, `${depPrefix}/${entryFile}`);
244
+ // Bug 3: trailing-slash variant for deep imports
245
+ // (`import "pkg/sub.js"` requires `"pkg/": "<root>/"`).
246
+ // Harmless when unused — browsers only fetch what's imported.
247
+ dependencies.set(`${depName}/`, `${depPrefix}/`);
145
248
  // Recursively process this dependency's dependencies
146
249
  collectDependencies(depPackageJsonPath, visited, dependencies, htmlDir, `${depPrefix}/node_modules`, warnings, depPaths);
147
250
  }
@@ -241,71 +344,113 @@ if (import.meta.main) {
241
344
  const unknownArgs = args.filter(a => a.startsWith('-') && !knownFlags.has(a));
242
345
  if (unknownArgs.length > 0) {
243
346
  console.error(`${ts()} [importgen] Error: unrecognized argument(s): ${unknownArgs.join(', ')}`);
244
- console.error(` Usage: importgen [htmlfile] [-w|--watch] [-v|--version]`);
347
+ console.error(` Usage: importgen [htmlfile...] [-w|--watch] [-v|--version]`);
245
348
  process.exit(1);
246
349
  }
247
350
  const watchMode = args.includes('-w') || args.includes('--watch');
248
351
  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');
352
+ // Bug 5: accept multiple HTML targets. Each gets its own import map
353
+ // with paths rewritten relative to that file's directory. Useful for
354
+ // multi-page apps where each html has its own document/module context
355
+ // (e.g. mailx: index.html, compose/compose.html, …).
256
356
  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`);
357
+ const htmlFileNames = positionalArgs.length > 0
358
+ ? positionalArgs
359
+ : (() => {
360
+ const found = possibleHtmlFiles.find(f => fs.existsSync(path.join(process.cwd(), f)));
361
+ return found ? [found] : [];
362
+ })();
363
+ const htmlFilePaths = htmlFileNames.map(n => path.resolve(process.cwd(), n));
364
+ // Find the right package.json for resolving deps. Walking up to the
365
+ // FIRST one is wrong when the html lives in a workspace child (e.g.
366
+ // `app/client/index.html` — `client/package.json` declares only the
367
+ // local workspace's direct deps; the runtime deps the html actually
368
+ // uses are declared in the workspace ROOT's package.json one level up).
369
+ //
370
+ // Strategy: walk up; prefer the OUTERMOST package.json that has a
371
+ // `workspaces` field (= monorepo root). Fall back to the nearest one
372
+ // when no workspace ancestor exists. Both cases pick correctly.
373
+ function findEffectivePackageJson(startDir) {
374
+ let nearest = null;
375
+ let workspaceRoot = null;
376
+ let dir = startDir;
377
+ while (true) {
378
+ const candidate = path.join(dir, 'package.json');
379
+ if (fs.existsSync(candidate)) {
380
+ if (!nearest)
381
+ nearest = candidate;
382
+ try {
383
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
384
+ if (pkg.workspaces)
385
+ workspaceRoot = candidate;
386
+ }
387
+ catch { /* malformed — skip */ }
388
+ }
389
+ const parent = path.dirname(dir);
390
+ if (parent === dir)
391
+ break;
392
+ dir = parent;
393
+ }
394
+ return workspaceRoot ?? nearest;
395
+ }
396
+ if (htmlFilePaths.length === 0) {
397
+ console.error(`${ts()} [generate-importmap] Error: No HTML file found. Looking for: ${possibleHtmlFiles.join(', ')}`);
261
398
  process.exit(1);
262
399
  }
263
- if (!htmlFilePath || !fs.existsSync(htmlFilePath)) {
264
- if (htmlArg) {
265
- console.error(`${ts()} [generate-importmap] Error: ${htmlArg} not found in current directory`);
400
+ for (const p of htmlFilePaths) {
401
+ if (!fs.existsSync(p)) {
402
+ console.error(`${ts()} [generate-importmap] Error: ${p} not found`);
403
+ process.exit(1);
266
404
  }
267
- else {
268
- console.error(`${ts()} [generate-importmap] Error: No HTML file found. Looking for: ${possibleHtmlFiles.join(', ')}`);
269
- }
270
- process.exit(1);
271
405
  }
406
+ // Resolve effective package.json per html file (typically the same
407
+ // for all targets in a single project, but compute per-file to handle
408
+ // mixed cases).
409
+ const targets = htmlFilePaths.map(htmlPath => {
410
+ const pkgPath = findEffectivePackageJson(path.dirname(htmlPath)) ?? path.join(process.cwd(), 'package.json');
411
+ if (!fs.existsSync(pkgPath)) {
412
+ console.error(`${ts()} [generate-importmap] Error: no package.json found from ${path.dirname(htmlPath)} or any ancestor`);
413
+ process.exit(1);
414
+ }
415
+ return { htmlPath, pkgPath };
416
+ });
417
+ const uniquePkgPaths = Array.from(new Set(targets.map(t => t.pkgPath)));
272
418
  if (watchMode) {
273
- let result = generateImportMap(packageJsonPath, htmlFilePath);
274
419
  let watchedDepDirs = new Set();
275
- const watcher = chokidar.watch([packageJsonPath], {
420
+ const watcher = chokidar.watch(uniquePkgPaths, {
276
421
  persistent: true,
277
422
  ignoreInitial: true
278
423
  });
279
- function syncWatchedDeps(depDirs) {
280
- const newDirs = new Set(depDirs);
424
+ function regenerateAll() {
425
+ const newDirs = new Set();
426
+ for (const t of targets) {
427
+ const r = generateImportMap(t.pkgPath, t.htmlPath);
428
+ for (const d of r.depDirs)
429
+ newDirs.add(d);
430
+ }
281
431
  for (const dir of watchedDepDirs) {
282
- if (!newDirs.has(dir)) {
432
+ if (!newDirs.has(dir))
283
433
  watcher.unwatch(dir);
284
- }
285
434
  }
286
435
  for (const dir of newDirs) {
287
- if (!watchedDepDirs.has(dir)) {
436
+ if (!watchedDepDirs.has(dir))
288
437
  watcher.add(dir);
289
- }
290
438
  }
291
439
  watchedDepDirs = newDirs;
292
440
  }
293
- syncWatchedDeps(result.depDirs);
441
+ regenerateAll();
294
442
  let debounceTimer = null;
295
443
  const onChange = () => {
296
444
  if (debounceTimer)
297
445
  clearTimeout(debounceTimer);
298
- debounceTimer = setTimeout(() => {
299
- result = generateImportMap(packageJsonPath, htmlFilePath);
300
- syncWatchedDeps(result.depDirs);
301
- }, 100);
446
+ debounceTimer = setTimeout(regenerateAll, 100);
302
447
  };
303
448
  watcher.on('change', onChange);
304
449
  watcher.on('add', onChange);
305
450
  watcher.on('unlink', onChange);
306
451
  watcher.on('ready', () => {
307
- const total = 1 + watchedDepDirs.size;
308
- console.log(`${ts()} [generate-importmap] Watching ${total} directories for changes...`);
452
+ const total = uniquePkgPaths.length + watchedDepDirs.size;
453
+ console.log(`${ts()} [generate-importmap] Watching ${total} directories for changes (${targets.length} HTML target${targets.length === 1 ? '' : 's'})...`);
309
454
  if (watchedDepDirs.size > 0) {
310
455
  for (const dir of watchedDepDirs) {
311
456
  console.log(` ${dir}`);
@@ -317,7 +462,9 @@ if (import.meta.main) {
317
462
  });
318
463
  }
319
464
  else {
320
- generateImportMap(packageJsonPath, htmlFilePath);
465
+ for (const t of targets) {
466
+ generateImportMap(t.pkgPath, t.htmlPath);
467
+ }
321
468
  }
322
469
  }
323
470
  //# 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.37",
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",