plgg-bundle 0.0.3 → 0.0.5

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/bin/relocate.mjs CHANGED
@@ -3,14 +3,15 @@
3
3
  // These tools run their `src/**/*.ts` entry directly and rely on Node stripping
4
4
  // types on load. Node refuses to strip types for `.ts` files under
5
5
  // `node_modules`, so a registry-installed tool cannot run in place. This helper
6
- // copies the package to a version-stamped dir OUTSIDE `node_modules` (with the
7
- // tool's own deps reachable via a `node_modules` symlink) and re-execs the copy
8
- // there. On a monorepo `file:` link the package realpath is already outside
9
- // `node_modules`, so it is a no-op.
6
+ // copies the package to a per-(version, install-location) dir OUTSIDE
7
+ // `node_modules` (with the tool's own deps reachable via a `node_modules`
8
+ // symlink) and re-execs the copy there. On a monorepo `file:` link the package
9
+ // realpath is already outside `node_modules`, so it is a no-op.
10
10
  //
11
11
  // Plain `.mjs`, Node built-ins only — it runs at process entry, before any
12
12
  // resolver hook is registered.
13
13
  import { spawnSync } from "node:child_process";
14
+ import { createHash } from "node:crypto";
14
15
  import {
15
16
  cpSync,
16
17
  existsSync,
@@ -54,14 +55,25 @@ export const relocateOutOfNodeModules = (
54
55
  "utf8",
55
56
  ),
56
57
  );
58
+ // The node_modules that CONTAINS this package holds its deps (npm hoists
59
+ // there). Key the relocation dir by that location too — two installs of the
60
+ // same version from different trees (e.g. a publish smoke's scratch install
61
+ // and a real consumer) must NOT share a copy, or one inherits the other's
62
+ // stale (possibly deleted) deps symlink.
63
+ const depsNodeModules = dirname(pkgRoot);
64
+ const tag = createHash("sha1")
65
+ .update(depsNodeModules)
66
+ .digest("hex")
67
+ .slice(0, 12);
57
68
  const dest = join(
58
69
  tmpdir(),
59
- `plgg-relocate-${pkg.name}-${pkg.version}`,
70
+ `plgg-relocate-${pkg.name}-${pkg.version}-${tag}`,
60
71
  );
61
72
  const ready = join(
62
73
  dest,
63
74
  ".plgg-relocate-ready",
64
75
  );
76
+ const link = join(dest, "node_modules");
65
77
 
66
78
  if (!existsSync(ready)) {
67
79
  rmSync(dest, {
@@ -81,19 +93,16 @@ export const relocateOutOfNodeModules = (
81
93
  join(pkgRoot, "package.json"),
82
94
  join(dest, "package.json"),
83
95
  );
84
- // The tool's own deps (typescript; plgg for plgg-test) live in the
85
- // node_modules that CONTAINS this package (npm hoists there). Symlink it so
86
- // resolution from the relocated copy finds them.
87
- try {
88
- symlinkSync(
89
- dirname(pkgRoot),
90
- join(dest, "node_modules"),
91
- "dir",
92
- );
93
- } catch {
94
- // A pre-existing symlink from a concurrent run is fine.
95
- }
96
- writeFileSync(ready, "");
96
+ writeFileSync(ready, depsNodeModules + "\n");
97
+ }
98
+ // (Re)create the deps symlink every run so it always points at the CURRENT
99
+ // node_modules: a cached copy from a prior run may hold a symlink to a tree
100
+ // that has since been removed (a publish smoke's scratch install) or moved.
101
+ rmSync(link, { force: true });
102
+ try {
103
+ symlinkSync(depsNodeModules, link, "dir");
104
+ } catch {
105
+ // A concurrent run created it first; its target is identical.
97
106
  }
98
107
 
99
108
  const child = spawnSync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plgg-bundle",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "In-house minimal library bundler for the plgg monorepo (ESM+CJS dual output, per-file .d.ts tree), zero new dependencies — reuses the project's own TypeScript, no native binding.",
5
5
  "type": "module",
6
6
  "files": [
@@ -23,6 +23,6 @@
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^25.6.0",
26
- "plgg-test": "^0.0.4"
26
+ "plgg-test": "^0.0.5"
27
27
  }
28
28
  }
@@ -0,0 +1,283 @@
1
+ import {
2
+ test,
3
+ check,
4
+ all,
5
+ toBe,
6
+ } from "plgg-test";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { collectModules } from "plgg-bundle/domain/usecase/collectModules";
16
+
17
+ test("collectModules records external specifiers without walking them", () => {
18
+ const root = mkdtempSync(
19
+ join(tmpdir(), "plgg-bundle-collect-ext-"),
20
+ );
21
+ try {
22
+ const entry = join(root, "src", "main.ts");
23
+ mkdirSync(join(root, "src"), { recursive: true });
24
+ writeFileSync(
25
+ entry,
26
+ 'import { readFileSync } from "node:fs";\nexport const read = readFileSync;\n',
27
+ );
28
+ const graph = collectModules({
29
+ entryFile: entry,
30
+ root,
31
+ aliasPrefix: "app",
32
+ aliasSrcRoot: join(root, "src"),
33
+ external: /^node:/,
34
+ });
35
+ return all([
36
+ check(graph.modules.length, toBe(1)),
37
+ check(
38
+ graph.modules[0]?.externals[0],
39
+ toBe("node:fs"),
40
+ ),
41
+ ]);
42
+ } finally {
43
+ rmSync(root, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ test("collectModules throws on an unresolvable non-external specifier", () => {
48
+ const root = mkdtempSync(
49
+ join(tmpdir(), "plgg-bundle-collect-missing-"),
50
+ );
51
+ try {
52
+ const entry = join(root, "src", "main.ts");
53
+ mkdirSync(join(root, "src"), { recursive: true });
54
+ writeFileSync(
55
+ entry,
56
+ 'import "missing";\n',
57
+ );
58
+ return check(
59
+ rejects(() =>
60
+ collectModules({
61
+ entryFile: entry,
62
+ root,
63
+ aliasPrefix: "app",
64
+ aliasSrcRoot: join(root, "src"),
65
+ external: /^node:/,
66
+ }),
67
+ ),
68
+ toBe(true),
69
+ );
70
+ } finally {
71
+ rmSync(root, { recursive: true, force: true });
72
+ }
73
+ });
74
+
75
+ test("collectModules inlines an installed prebundled dist entry without resolving its internal registry requires", () => {
76
+ const root = mkdtempSync(
77
+ join(tmpdir(), "plgg-bundle-collect-"),
78
+ );
79
+ try {
80
+ const entry = join(root, "src", "main.ts");
81
+ const dep = join(
82
+ root,
83
+ "node_modules",
84
+ "dep",
85
+ "dist",
86
+ "index.es.js",
87
+ );
88
+ const plgg = join(
89
+ root,
90
+ "node_modules",
91
+ "plgg",
92
+ "dist",
93
+ "index.es.js",
94
+ );
95
+ mkdirSync(join(root, "src"), { recursive: true });
96
+ mkdirSync(join(root, "node_modules", "dep", "dist"), {
97
+ recursive: true,
98
+ });
99
+ mkdirSync(join(root, "node_modules", "plgg", "dist"), {
100
+ recursive: true,
101
+ });
102
+ writeFileSync(
103
+ entry,
104
+ 'import dep from "dep";\nexport const value = dep.value;\n',
105
+ );
106
+ writeFileSync(
107
+ dep,
108
+ [
109
+ 'import * as __ext0 from "plgg";',
110
+ 'const __externals = { "plgg": __ext0 };',
111
+ "const __modules = {",
112
+ '"src/index.ts": function (module, exports, require) {',
113
+ ' exports.value = require("src/internal.ts").value;',
114
+ "},",
115
+ '"src/internal.ts": function (module, exports, require) {',
116
+ " exports.value = __externals.plgg.external;",
117
+ "}",
118
+ "};",
119
+ 'const __entry = __modules["src/index.ts"];',
120
+ "export default { value: __entry };",
121
+ ].join("\n"),
122
+ );
123
+ writeFileSync(
124
+ plgg,
125
+ "export const external = 1;\n",
126
+ );
127
+
128
+ const graph = collectModules({
129
+ entryFile: entry,
130
+ root,
131
+ aliasPrefix: "app",
132
+ aliasSrcRoot: join(root, "src"),
133
+ external: /^node:/,
134
+ resolve: (specifier, _fromFile) =>
135
+ specifier === "dep"
136
+ ? dep
137
+ : specifier === "plgg"
138
+ ? plgg
139
+ : undefined,
140
+ });
141
+ const depModule = graph.modules.find(
142
+ (m) =>
143
+ m.id ===
144
+ "node_modules/dep/dist/index.es.js",
145
+ );
146
+ return all([
147
+ check(graph.modules.length, toBe(3)),
148
+ check(
149
+ depModule?.code.includes(
150
+ 'require("src/internal.ts")',
151
+ ),
152
+ toBe(true),
153
+ ),
154
+ check(
155
+ depModule?.code.includes(
156
+ 'require("node_modules/plgg/dist/index.es.js")',
157
+ ),
158
+ toBe(true),
159
+ ),
160
+ ]);
161
+ } finally {
162
+ rmSync(root, { recursive: true, force: true });
163
+ }
164
+ });
165
+
166
+ test("collectModules resolves node_modules source files without dist-registry filtering", () => {
167
+ const root = mkdtempSync(
168
+ join(tmpdir(), "plgg-bundle-collect-src-"),
169
+ );
170
+ try {
171
+ const entry = join(root, "src", "main.ts");
172
+ const dep = join(
173
+ root,
174
+ "node_modules",
175
+ "dep",
176
+ "src",
177
+ "index.ts",
178
+ );
179
+ const internal = join(
180
+ root,
181
+ "node_modules",
182
+ "dep",
183
+ "src",
184
+ "internal.ts",
185
+ );
186
+ writeImportFixture(entry, dep, internal);
187
+ const graph = collectModules({
188
+ entryFile: entry,
189
+ root,
190
+ aliasPrefix: "app",
191
+ aliasSrcRoot: join(root, "src"),
192
+ external: /^node:/,
193
+ resolve: importFixtureResolver(dep, internal),
194
+ });
195
+ return check(graph.modules.length, toBe(3));
196
+ } finally {
197
+ rmSync(root, { recursive: true, force: true });
198
+ }
199
+ });
200
+
201
+ test("collectModules resolves non-js dist source files without dist-registry filtering", () => {
202
+ const root = mkdtempSync(
203
+ join(tmpdir(), "plgg-bundle-collect-mjs-"),
204
+ );
205
+ try {
206
+ const entry = join(root, "src", "main.ts");
207
+ const dep = join(
208
+ root,
209
+ "node_modules",
210
+ "dep",
211
+ "dist",
212
+ "index.ts",
213
+ );
214
+ const internal = join(
215
+ root,
216
+ "node_modules",
217
+ "dep",
218
+ "dist",
219
+ "internal.ts",
220
+ );
221
+ writeImportFixture(entry, dep, internal);
222
+ const graph = collectModules({
223
+ entryFile: entry,
224
+ root,
225
+ aliasPrefix: "app",
226
+ aliasSrcRoot: join(root, "src"),
227
+ external: /^node:/,
228
+ resolve: importFixtureResolver(dep, internal),
229
+ });
230
+ return check(graph.modules.length, toBe(3));
231
+ } finally {
232
+ rmSync(root, { recursive: true, force: true });
233
+ }
234
+ });
235
+
236
+ const writeImportFixture = (
237
+ entry: string,
238
+ dep: string,
239
+ internal: string,
240
+ ): void => {
241
+ mkdirSync(join(entry, ".."), { recursive: true });
242
+ mkdirSync(join(dep, ".."), { recursive: true });
243
+ mkdirSync(join(internal, ".."), { recursive: true });
244
+ writeFileSync(
245
+ entry,
246
+ 'import { value } from "dep";\nexport const out = value;\n',
247
+ );
248
+ writeFileSync(
249
+ dep,
250
+ 'import { value } from "src/internal.ts";\nexport { value };\n',
251
+ );
252
+ writeFileSync(
253
+ internal,
254
+ "export const value = 1;\n",
255
+ );
256
+ };
257
+
258
+ const importFixtureResolver =
259
+ (
260
+ dep: string,
261
+ internal: string,
262
+ ): ((
263
+ specifier: string,
264
+ fromFile: string,
265
+ ) => string | undefined) =>
266
+ (specifier, _fromFile) =>
267
+ specifier === "dep"
268
+ ? dep
269
+ : specifier === "src/internal.ts"
270
+ ? internal
271
+ : undefined;
272
+
273
+ const rejects = (f: () => unknown): boolean => {
274
+ try {
275
+ f();
276
+ return false;
277
+ } catch (e) {
278
+ return (
279
+ e instanceof Error &&
280
+ e.message.startsWith("ResolveError")
281
+ );
282
+ }
283
+ };
@@ -135,7 +135,7 @@ const linkModule = (
135
135
  cjs: string,
136
136
  acc: Map<string, Module>,
137
137
  ): ReadonlyArray<string> => {
138
- const specifiers = requireSpecifiers(cjs);
138
+ const specifiers = inlineRequireSpecifiers(file, cjs);
139
139
  const externals: string[] = [];
140
140
  const deps: string[] = [];
141
141
  let rewritten = cjs;
@@ -165,6 +165,59 @@ const linkModule = (
165
165
  return deps;
166
166
  };
167
167
 
168
+ /**
169
+ * Literal requires that belong to the OUTER graph walk.
170
+ * Registry-installed plgg-family packages ship
171
+ * pre-bundled dist entries whose internal module table
172
+ * still contains strings like `require("src/index.ts")`.
173
+ * Those are parameters of the INNER registry runtime and
174
+ * must not be resolved or rewritten by this app bundle.
175
+ * Real top-level externals in that dist file remain, e.g.
176
+ * `require("plgg")`, and are still inlined by the app
177
+ * graph.
178
+ */
179
+ const inlineRequireSpecifiers = (
180
+ file: string,
181
+ cjs: string,
182
+ ): ReadonlyArray<string> => {
183
+ const specifiers = requireSpecifiers(cjs);
184
+ if (!isInstalledDist(file)) {
185
+ return specifiers;
186
+ }
187
+ const internal = bundledModuleIds(cjs);
188
+ return specifiers.filter((spec) => !internal.has(spec));
189
+ };
190
+
191
+ /**
192
+ * Whether a file is a registry-installed built JS entry.
193
+ * Source packages under the monorepo are never matched.
194
+ */
195
+ const isInstalledDist = (file: string): boolean => {
196
+ const normalized = file.split("\\").join("/");
197
+ return (
198
+ normalized.includes("/node_modules/") &&
199
+ normalized.includes("/dist/") &&
200
+ normalized.endsWith(".js")
201
+ );
202
+ };
203
+
204
+ /**
205
+ * Module ids declared inside a plgg-bundle emitted
206
+ * registry object: `"src/x.ts": function (...) { ... }`.
207
+ */
208
+ const bundledModuleIds = (
209
+ cjs: string,
210
+ ): ReadonlySet<string> =>
211
+ new Set(
212
+ [
213
+ ...cjs.matchAll(
214
+ /["']([^"']+)["']\s*:\s*function\s*\(/g,
215
+ ),
216
+ ].flatMap((m) =>
217
+ m[1] === undefined ? [] : [m[1]],
218
+ ),
219
+ );
220
+
168
221
  /**
169
222
  * Read a source file, re-throwing with context.
170
223
  */
@@ -4,6 +4,13 @@ import {
4
4
  all,
5
5
  toBe,
6
6
  } from "plgg-test";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
7
14
  import { join } from "node:path";
8
15
  import { discoverWorkspace } from "plgg-bundle/domain/usecase/discoverWorkspace";
9
16
 
@@ -61,3 +68,107 @@ test("discoverWorkspace sorts longest-name first (so plgg-view beats plgg)", ()
61
68
  );
62
69
  return check(iView < iCore, toBe(true));
63
70
  });
71
+
72
+ test("discoverWorkspace finds dist-only packages installed under node_modules", () => {
73
+ const root = fixtureRoot();
74
+ try {
75
+ const app = join(root, "packages", "app");
76
+ writePackage(app, "app", true);
77
+ writePackage(
78
+ join(app, "node_modules", "plgg"),
79
+ "plgg",
80
+ false,
81
+ );
82
+ const found = discoverWorkspace(app).find(
83
+ (p) => p.name === "plgg",
84
+ );
85
+ return all([
86
+ check(found?.kind, toBe("dist")),
87
+ check(
88
+ found?.exports.get("."),
89
+ toBe("./dist/index.es.js"),
90
+ ),
91
+ ]);
92
+ } finally {
93
+ rmSync(root, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test("discoverWorkspace keeps a sibling source package before an installed duplicate", () => {
98
+ const root = fixtureRoot();
99
+ try {
100
+ const app = join(root, "packages", "app");
101
+ writePackage(app, "app", true);
102
+ writePackage(
103
+ join(root, "packages", "plgg"),
104
+ "plgg",
105
+ true,
106
+ );
107
+ writePackage(
108
+ join(app, "node_modules", "plgg"),
109
+ "plgg",
110
+ false,
111
+ );
112
+ const found = discoverWorkspace(app).find(
113
+ (p) => p.name === "plgg",
114
+ );
115
+ return check(found?.kind, toBe("source"));
116
+ } finally {
117
+ rmSync(root, { recursive: true, force: true });
118
+ }
119
+ });
120
+
121
+ test("discoverWorkspace finds installed deps of a sibling source package", () => {
122
+ const root = fixtureRoot();
123
+ try {
124
+ const app = join(root, "packages", "app");
125
+ writePackage(app, "app", true);
126
+ const local = join(root, "packages", "local");
127
+ writePackage(local, "local", true);
128
+ writePackage(
129
+ join(local, "node_modules", "plgg-ui"),
130
+ "plgg-ui",
131
+ false,
132
+ );
133
+ const found = discoverWorkspace(app).find(
134
+ (p) => p.name === "plgg-ui",
135
+ );
136
+ return check(found?.kind, toBe("dist"));
137
+ } finally {
138
+ rmSync(root, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ const fixtureRoot = (): string =>
143
+ mkdtempSync(
144
+ join(tmpdir(), "plgg-bundle-discover-"),
145
+ );
146
+
147
+ const writePackage = (
148
+ dir: string,
149
+ name: string,
150
+ source: boolean,
151
+ ): void => {
152
+ mkdirSync(source ? join(dir, "src") : join(dir, "dist"), {
153
+ recursive: true,
154
+ });
155
+ writeFileSync(
156
+ join(dir, "package.json"),
157
+ JSON.stringify(
158
+ {
159
+ name,
160
+ type: "module",
161
+ exports: {
162
+ import: {
163
+ default: "./dist/index.es.js",
164
+ },
165
+ require: {
166
+ default: "./dist/index.cjs.js",
167
+ },
168
+ },
169
+ },
170
+ null,
171
+ 2,
172
+ ),
173
+ );
174
+ };
@@ -7,38 +7,65 @@ import {
7
7
  import { join, dirname } from "plgg-bundle/vendors/nodePath";
8
8
 
9
9
  /**
10
- * A workspace sibling the app bundler can INLINE from
11
- * source: its package `name`, its `dir`, and the map from
12
- * each public export subpath (`"."`, `"./client"`, …) to
13
- * the `import.default` dist path declared in its
14
- * `package.json` `exports`. The app resolver reverses
15
- * that dist path to the entry's source file (so the
16
- * `./style` → `dist/styleEntry.es.js` → `src/styleEntry`
17
- * rename is honoured), and falls back to a self-alias
18
- * `name/<path>` → `src/<path>` for non-export internal
19
- * imports.
20
- */
21
- export type WorkspacePackage = Readonly<{
10
+ * A package the app bundler can INLINE. Monorepo
11
+ * siblings are inlined from `src`; registry-installed
12
+ * packages usually ship only `dist`, so the resolver
13
+ * inlines their built ESM entry instead.
14
+ */
15
+ export type WorkspacePackage =
16
+ | SourceWorkspacePackage
17
+ | DistWorkspacePackage;
18
+
19
+ /**
20
+ * A source package, usually a sibling under `packages/`.
21
+ * Its public export subpaths are reversed from
22
+ * `exports` dist paths back to `src` entries.
23
+ */
24
+ export type SourceWorkspacePackage = Readonly<{
25
+ kind: "source";
26
+ name: string;
27
+ dir: string;
28
+ srcDir: string;
29
+ /** export subpath (`"."` | `"./x"`) → dist default. */
30
+ exports: ReadonlyMap<string, string>;
31
+ }>;
32
+
33
+ /**
34
+ * A dist package, usually installed under the app's
35
+ * `node_modules`. It has no source in the consumer tree,
36
+ * so public export subpaths resolve to built ESM files.
37
+ */
38
+ export type DistWorkspacePackage = Readonly<{
39
+ kind: "dist";
22
40
  name: string;
23
41
  dir: string;
42
+ distDir: string;
24
43
  /** export subpath (`"."` | `"./x"`) → dist default. */
25
44
  exports: ReadonlyMap<string, string>;
26
45
  }>;
27
46
 
28
47
  /**
29
- * Discover every sibling package under the directory that
30
- * holds `packageRoot` (the monorepo `packages/` dir) —
31
- * each entry that has a `package.json` with a `name`.
48
+ * Discover inlineable packages visible from `packageRoot`:
49
+ * sibling packages under the directory that holds
50
+ * `packageRoot`, plus packages installed in this package's
51
+ * own `node_modules` and in sibling source packages'
52
+ * `node_modules`. Siblings are listed first and win on
53
+ * duplicate names so monorepo builds keep reading source.
32
54
  * Sorted longest-name-first so a later prefix match picks
33
- * `plgg-view` over `plgg`. Throws on a read/parse failure.
55
+ * `plgg-view` over `plgg`.
34
56
  */
35
57
  export const discoverWorkspace = (
36
58
  packageRoot: string,
37
59
  ): ReadonlyArray<WorkspacePackage> => {
38
60
  const siblingsDir = dirname(packageRoot);
39
- return readdirSync(siblingsDir)
40
- .map((name) => join(siblingsDir, name))
41
- .filter(isPackageDir)
61
+ const siblings = packageDirsIn(siblingsDir);
62
+ return uniqueByName([
63
+ ...siblings,
64
+ ...packageDirsIn(join(packageRoot, "node_modules")),
65
+ ...siblings.flatMap((dir) =>
66
+ packageDirsIn(join(dir, "node_modules")),
67
+ ),
68
+ ])
42
69
  .map(readPackage)
43
70
  .flatMap((p) => (p === undefined ? [] : [p]))
44
71
  .sort(
@@ -47,8 +74,61 @@ export const discoverWorkspace = (
47
74
  };
48
75
 
49
76
  /**
50
- * Whether a path is a directory holding a
51
- * `package.json`.
77
+ * Direct package dirs under `dir`, including scoped
78
+ * `@scope/name` packages. Missing directories simply
79
+ * contribute no packages.
80
+ */
81
+ const packageDirsIn = (
82
+ dir: string,
83
+ ): ReadonlyArray<string> =>
84
+ existsSync(dir)
85
+ ? readdirSync(dir).flatMap((name) =>
86
+ childPackageDirs(join(dir, name)),
87
+ )
88
+ : [];
89
+
90
+ /**
91
+ * The package dirs represented by one child of a package
92
+ * collection directory. A scope directory fans out one
93
+ * level; ordinary package directories are returned as-is.
94
+ */
95
+ const childPackageDirs = (
96
+ dir: string,
97
+ ): ReadonlyArray<string> => {
98
+ if (!statSync(dir).isDirectory()) {
99
+ return [];
100
+ }
101
+ if (isPackageDir(dir)) {
102
+ return [dir];
103
+ }
104
+ return readdirSync(dir)
105
+ .map((name) => join(dir, name))
106
+ .filter(isPackageDir);
107
+ };
108
+
109
+ /**
110
+ * Keep the first directory for each package name. The
111
+ * caller supplies siblings before node_modules, so a local
112
+ * source package beats an installed copy.
113
+ */
114
+ const uniqueByName = (
115
+ dirs: ReadonlyArray<string>,
116
+ ): ReadonlyArray<string> => {
117
+ const seen = new Set<string>();
118
+ const out: string[] = [];
119
+ for (const dir of dirs) {
120
+ const name = packageName(dir);
121
+ if (name === undefined || seen.has(name)) {
122
+ continue;
123
+ }
124
+ seen.add(name);
125
+ out.push(dir);
126
+ }
127
+ return out;
128
+ };
129
+
130
+ /**
131
+ * Whether a path is a directory holding a package.json.
52
132
  */
53
133
  const isPackageDir = (dir: string): boolean =>
54
134
  statSync(dir).isDirectory() &&
@@ -66,9 +146,44 @@ const readPackage = (
66
146
  join(dir, "package.json"),
67
147
  );
68
148
  const name = pkg["name"];
69
- return typeof name === "string"
70
- ? { name, dir, exports: exportMap(pkg) }
71
- : undefined;
149
+ if (typeof name !== "string") {
150
+ return undefined;
151
+ }
152
+ const srcDir = join(dir, "src");
153
+ if (existsSync(srcDir)) {
154
+ return {
155
+ kind: "source",
156
+ name,
157
+ dir,
158
+ srcDir,
159
+ exports: exportMap(pkg),
160
+ };
161
+ }
162
+ const distDir = join(dir, "dist");
163
+ if (existsSync(distDir)) {
164
+ return {
165
+ kind: "dist",
166
+ name,
167
+ dir,
168
+ distDir,
169
+ exports: exportMap(pkg),
170
+ };
171
+ }
172
+ return undefined;
173
+ };
174
+
175
+ /**
176
+ * Read only the package name for de-duplication.
177
+ */
178
+ const packageName = (
179
+ dir: string,
180
+ ): string | undefined => {
181
+ if (!isPackageDir(dir)) {
182
+ return undefined;
183
+ }
184
+ const pkg = parseJson(join(dir, "package.json"));
185
+ const name = pkg["name"];
186
+ return typeof name === "string" ? name : undefined;
72
187
  };
73
188
 
74
189
  /**
@@ -4,6 +4,13 @@ import {
4
4
  all,
5
5
  toBe,
6
6
  } from "plgg-test";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { tmpdir } from "node:os";
7
14
  import { join } from "node:path";
8
15
  import { discoverWorkspace } from "plgg-bundle/domain/usecase/discoverWorkspace";
9
16
  import { resolveWorkspaceSpecifier } from "plgg-bundle/domain/usecase/resolveWorkspaceSpecifier";
@@ -77,3 +84,93 @@ test("returns undefined for a non-workspace specifier", () =>
77
84
  check(resolve("react"), toBe(undefined)),
78
85
  check(resolve("node:fs"), toBe(undefined)),
79
86
  ]));
87
+
88
+ test("resolves an installed dist-only package export to its built ESM file", () => {
89
+ const root = fixtureRoot();
90
+ try {
91
+ const app = join(root, "packages", "app");
92
+ writePackage(app, "app", true);
93
+ writePackage(
94
+ join(app, "node_modules", "plgg"),
95
+ "plgg",
96
+ false,
97
+ );
98
+ const installed = discoverWorkspace(app);
99
+ return check(
100
+ resolveWorkspaceSpecifier({
101
+ specifier: "plgg",
102
+ fromFile: join(app, "src", "main.ts"),
103
+ packages: installed,
104
+ }),
105
+ toBe(
106
+ join(
107
+ app,
108
+ "node_modules",
109
+ "plgg",
110
+ "dist",
111
+ "index.es.js",
112
+ ),
113
+ ),
114
+ );
115
+ } finally {
116
+ rmSync(root, { recursive: true, force: true });
117
+ }
118
+ });
119
+
120
+ test("does not resolve internal paths from a dist-only installed package", () => {
121
+ const root = fixtureRoot();
122
+ try {
123
+ const app = join(root, "packages", "app");
124
+ writePackage(app, "app", true);
125
+ writePackage(
126
+ join(app, "node_modules", "plgg"),
127
+ "plgg",
128
+ false,
129
+ );
130
+ const installed = discoverWorkspace(app);
131
+ return check(
132
+ resolveWorkspaceSpecifier({
133
+ specifier: "plgg/Atomics/Num",
134
+ fromFile: join(app, "src", "main.ts"),
135
+ packages: installed,
136
+ }),
137
+ toBe(undefined),
138
+ );
139
+ } finally {
140
+ rmSync(root, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ const fixtureRoot = (): string =>
145
+ mkdtempSync(join(tmpdir(), "plgg-bundle-resolve-"));
146
+
147
+ const writePackage = (
148
+ dir: string,
149
+ name: string,
150
+ source: boolean,
151
+ ): void => {
152
+ const codeDir = source
153
+ ? join(dir, "src")
154
+ : join(dir, "dist");
155
+ mkdirSync(codeDir, { recursive: true });
156
+ writeFileSync(join(codeDir, "index.es.js"), "");
157
+ writeFileSync(
158
+ join(dir, "package.json"),
159
+ JSON.stringify(
160
+ {
161
+ name,
162
+ type: "module",
163
+ exports: {
164
+ import: {
165
+ default: "./dist/index.es.js",
166
+ },
167
+ require: {
168
+ default: "./dist/index.cjs.js",
169
+ },
170
+ },
171
+ },
172
+ null,
173
+ 2,
174
+ ),
175
+ );
176
+ };
@@ -8,11 +8,12 @@ import { type WorkspacePackage } from "plgg-bundle/domain/usecase/discoverWorksp
8
8
 
9
9
  /**
10
10
  * App-mode resolver: resolve an import specifier to an
11
- * absolute SOURCE file, inlining workspace siblings
12
- * (`plgg`, `plgg-view/client`, ) by walking their `src`
13
- * instead of leaving them external. This is the mirror of
14
- * the library externalization the leaf app is where
15
- * bundling deps is correct.
11
+ * absolute inlineable file. Source packages
12
+ * (`packages/plgg`, local `file:` siblings) resolve to
13
+ * `src`; published packages installed in `node_modules`
14
+ * resolve to their built ESM `dist` export. This is the
15
+ * mirror of the library externalization — the leaf app is
16
+ * where bundling deps is correct.
16
17
  *
17
18
  * Order:
18
19
  * 1. relative (`./x`) → resolve against the importer,
@@ -75,8 +76,8 @@ const matchPackage = (
75
76
 
76
77
  /**
77
78
  * Resolve a specifier already known to belong to `pkg`:
78
- * a declared export subpath via the exports reversal,
79
- * else the internal self-alias path.
79
+ * a declared export subpath through the package kind,
80
+ * else a source package's internal self-alias path.
80
81
  */
81
82
  const resolveInPackage = (
82
83
  pkg: WorkspacePackage,
@@ -89,17 +90,42 @@ const resolveInPackage = (
89
90
  pkg.name.length + 1,
90
91
  )}`;
91
92
  const dist = pkg.exports.get(subpath);
92
- return dist === undefined
93
+ return pkg.kind === "source"
94
+ ? resolveInSourcePackage(pkg, specifier, dist)
95
+ : resolveInDistPackage(pkg, dist);
96
+ };
97
+
98
+ /**
99
+ * Source packages keep the previous monorepo behavior:
100
+ * public exports reverse their emitted dist stem back to
101
+ * source; non-export self-alias paths resolve under `src`.
102
+ */
103
+ const resolveInSourcePackage = (
104
+ pkg: WorkspacePackage & { kind: "source" },
105
+ specifier: string,
106
+ dist: string | undefined,
107
+ ): string | undefined =>
108
+ dist === undefined
93
109
  ? resolveSpecifier({
94
110
  specifier,
95
111
  fromFile: pkg.dir,
96
112
  aliasPrefix: pkg.name,
97
- aliasSrcRoot: join(pkg.dir, "src"),
113
+ aliasSrcRoot: pkg.srcDir,
98
114
  })
99
- : pickExisting(
100
- join(pkg.dir, "src", srcStem(dist)),
101
- );
102
- };
115
+ : pickExisting(join(pkg.srcDir, srcStem(dist)));
116
+
117
+ /**
118
+ * Dist-only packages can safely resolve only declared
119
+ * public exports. Internal self-alias paths are not
120
+ * available in a consumer install.
121
+ */
122
+ const resolveInDistPackage = (
123
+ pkg: WorkspacePackage & { kind: "dist" },
124
+ dist: string | undefined,
125
+ ): string | undefined =>
126
+ dist === undefined
127
+ ? undefined
128
+ : pickExisting(join(pkg.dir, dist));
103
129
 
104
130
  /**
105
131
  * The source stem implied by an export's dist default —