@savvy-web/bundler 0.4.2 → 0.6.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
@@ -200,7 +200,7 @@ const config = defineBuild({
200
200
  });
201
201
  ```
202
202
 
203
- Each override carries the same `format`, `bundle`, `externals`, `bundleNodeModules`, `bundledPackages` and `dtsExternals` fields as the base config. An override does not inherit the base `externals` — list what that partition needs. The build errors if an override names an export path the package does not declare.
203
+ Each override carries the same `format`, `bundle`, `externals`, `bundleNodeModules`, `bundledPackages` and `dtsExternals` fields as the base config, plus three partition-only fields: `platform` (the JS-pass target, `"node"` by default or `"browser"` for a client runtime), `css` (forwarded to tsdown's `css` option to enable `@tsdown/css`) and `outSubdir` (builds the partition into an isolated `<group>/pkg/<subdir>/` sub-package, for which exactly one export path may be pinned). An override does not inherit the base `externals` — list what that partition needs. The build errors if an override names an export path the package does not declare. These partition fields are what [`@savvy-web/rspress-builder`](https://www.npmjs.com/package/@savvy-web/rspress-builder) composes to build an RSPress plugin's browser runtime bundle.
204
204
 
205
205
  ## Loose files
206
206
 
@@ -277,7 +277,7 @@ const config = defineBuild({
277
277
  - **JSX, config-first** — JSX transform is inherited from `tsconfig.json` and overridable via the `jsx` field, feeding both the dts tsconfig and the tsdown transform.
278
278
  - **Dual-format output** — esm-only by default; set `format` to `["esm", "cjs"]` for a require-able CJS output with default-export interop, `.d.cts` declarations and dual `import`/`require` export conditions.
279
279
  - **Dependency bundling** — declared dependencies stay external by default; `bundle`, `bundleNodeModules`, `bundledPackages` and `dtsExternals` force-inline specific packages or all node_modules into the output, inline select declarations into the `.d.ts` or hold a package out of the declaration bundle when its types cannot be inlined.
280
- - **Per-entry overrides** — `overrides` pins a subset of export entries to their own format and bundling, so one entry can ship dual-format CJS in an otherwise ESM-only package without changing the rest.
280
+ - **Per-entry overrides** — `overrides` pins a subset of export entries to their own format and bundling, so one entry can ship dual-format CJS in an otherwise ESM-only package without changing the rest; partition-only `platform`, `css` and `outSubdir` fields also let an entry build for the browser with CSS modules into its own sub-package.
281
281
  - **Loose files** — `looseFiles` emits standalone bundled files at literal output paths outside the exports/declaration/api-model graph, with the format inferred from the key extension; pair with `bundleNodeModules` for self-contained pnpm config-dependency pnpmfiles.
282
282
  - **Readable prod output** — prod output is unminified by default to keep stack traces legible and pass security scanners; `minify` opts back in.
283
283
  - **Default manifest stripping** — the published `package.json` drops build- and dev-only fields automatically; a custom `transform` replaces the default and can re-apply it via `defaultManifestTransform`.
package/config.js CHANGED
@@ -26,14 +26,17 @@ function defineBuild(input = {}) {
26
26
  function parseArgs(argv) {
27
27
  let target = "dev";
28
28
  let watch = false;
29
+ let noExe = false;
29
30
  for (let i = 0; i < argv.length; i++) if (argv[i] === "--target") {
30
31
  const v = argv[i + 1];
31
32
  if (v === "dev" || v === "prod" || v === "meta" || v === "exe") target = v;
32
33
  i++;
33
34
  } else if (argv[i] === "--watch") watch = true;
35
+ else if (argv[i] === "--no-exe") noExe = true;
34
36
  return {
35
37
  target,
36
- watch
38
+ watch,
39
+ noExe
37
40
  };
38
41
  }
39
42
 
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BuildFormat, BuildTargetGroupsOptions, ExeConfig, GenerateMetaOptions, Json, JsxConfig, LooseFiles, MetaOptions, MetaResult, PublishTargets, RenderedOutput, RunExeBuildOptions, TargetGroupRef, TargetResolution, TsconfigJsx, defaultManifestTransform } from "@savvy-web/tsdown-plugins";
1
+ import { BuildFormat, BuildPlatform, BuildTargetGroupsOptions, CssOptions, ExeConfig, GenerateMetaOptions, Json, JsxConfig, LooseFiles, MetaOptions, MetaResult, PublishTargets, RenderedOutput, RunExeBuildOptions, TargetGroupRef, TargetResolution, TsconfigJsx, defaultManifestTransform } from "@savvy-web/tsdown-plugins";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  interface BuildEntryOverride {
@@ -10,6 +10,16 @@ interface BuildEntryOverride {
10
10
  readonly bundleNodeModules?: boolean | undefined;
11
11
  readonly bundledPackages?: ReadonlyArray<string> | undefined;
12
12
  readonly dtsExternals?: ReadonlyArray<string> | undefined;
13
+ /** JS-pass platform for this partition (default "node"). Use "browser" for an RSPress runtime. */
14
+ readonly platform?: BuildPlatform | undefined;
15
+ /** CSS handling forwarded to tsdown's `css` option (JS pass only). Enables `@tsdown/css`. */
16
+ readonly css?: CssOptions | undefined;
17
+ /**
18
+ * Build this entry's partition into a `<group>/pkg/<outSubdir>/` subdir as an isolated sub-package
19
+ * (e.g. an RSPress `./runtime`). The export's built path becomes `./<outSubdir>/index.{js,d.ts}`.
20
+ * Exactly ONE export path may be pinned per `outSubdir` override.
21
+ */
22
+ readonly outSubdir?: string | undefined;
13
23
  }
14
24
  interface OutputConfig {
15
25
  readonly console?: {
@@ -163,6 +173,8 @@ declare function defineBuild(input?: BuildConfigInput): BuildConfig;
163
173
  interface ParsedArgs {
164
174
  readonly target: "dev" | "prod" | "meta" | "exe";
165
175
  readonly watch: boolean;
176
+ /** Skip the SEA compile step of a dev/prod build (the manifest is still programmed). Used by `prepare`. */
177
+ readonly noExe: boolean;
166
178
  }
167
179
  declare function parseArgs(argv: ReadonlyArray<string>): ParsedArgs;
168
180
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/bundler",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Zero-config tsdown-based bundler for Silk Suite TypeScript packages",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/bundler",
@@ -25,10 +25,11 @@
25
25
  "import": "./index.js"
26
26
  },
27
27
  "./ecma.json": "./public/ecma.json",
28
+ "./tsconfig/ecma.json": "./public/ecma.json",
28
29
  "./package.json": "./package.json"
29
30
  },
30
31
  "dependencies": {
31
- "@savvy-web/tsdown-plugins": "0.4.2",
32
+ "@savvy-web/tsdown-plugins": "0.6.0",
32
33
  "@tsdown/exe": "^0.22.1",
33
34
  "tsdown": "^0.22.2"
34
35
  },
package/public/ecma.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "sourceMap": false,
26
26
  "strict": true,
27
27
  "strictNullChecks": true,
28
- "target": "es2023",
28
+ "target": "es2025",
29
29
  "tsBuildInfoFile": "${configDir}/dist/.tsbuildinfo.lib",
30
30
  "typeRoots": ["${configDir}/node_modules/@types", "${configDir}/types"],
31
31
  "verbatimModuleSyntax": true,
package/run.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "./config.js";
2
- import { ConfigValidator, ConfigValidatorLive, ReportPipelineLive, buildTargetGroups, createEntryName, generateMeta, normalizeExeOptions, normalizeLooseFiles, normalizeMetaOptions, packageJsonEntries, readTsconfigJsx, removeDeclarationMaps, renderReport, resolveJsxConfig, resolveTargets, runExeBuild, writeResolvedTsconfig, writeTargetsBinding } from "@savvy-web/tsdown-plugins";
3
- import { readFileSync } from "node:fs";
2
+ import { ConfigValidator, ConfigValidatorLive, ReportPipelineLive, buildEmittedManifest, buildTargetGroups, computeExeFileName, createEntryName, generateMeta, normalizeExeOptions, normalizeLooseFiles, normalizeMetaOptions, packageJsonEntries, readTsconfigJsx, removeDeclarationMaps, renderReport, resolveJsxConfig, resolveTargets, runExeBuild, writeResolvedTsconfig, writeTargetsBinding } from "@savvy-web/tsdown-plugins";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { Effect } from "effect";
6
6
 
@@ -35,9 +35,46 @@ function deriveExportPaths(entries, exportsMap) {
35
35
  for (const [entryName, src] of Object.entries(entries)) out[entryName] = sourceToKey.get(src) ?? (entryName === "index" ? "." : `./${entryName}`);
36
36
  return out;
37
37
  }
38
+ /**
39
+ * Fast-fail validation for `outSubdir` overrides, run on EVERY target path. The dev/prod override-partition
40
+ * loop already validates these (and all other overrides) before building, but `--target meta` returns early
41
+ * (before that loop) and remaps meta dts basenames via `applySubdirMetaEntries` — which assumes validated
42
+ * input. Without this guard, a malformed `outSubdir` override (more than one entry, a non-canonical export
43
+ * path, or an export path that is not a real build entry) would silently remap a wrong/nonexistent key on the
44
+ * meta path. Mirrors the override loop's conditions and messages verbatim so every target path fast-fails
45
+ * identically. No-op when there are no overrides or none set `outSubdir`.
46
+ */
47
+ function validateSubdirOverrides(overrides, entries, packageName) {
48
+ if (overrides === void 0) return;
49
+ for (const ov of overrides) {
50
+ if (ov.outSubdir === void 0) continue;
51
+ if (ov.entries.length !== 1) throw new Error(`overrides: outSubdir "${ov.outSubdir}" must pin exactly one export path (got ${ov.entries.length})`);
52
+ const exportPath = ov.entries[0];
53
+ if (exportPath !== "." && !exportPath.startsWith("./")) throw new Error(`overrides: entry "${exportPath}" must be a canonical export path — use "." for the root or a "./"-prefixed subpath (e.g. "./changesets/markdownlint")`);
54
+ const flatName = createEntryName(exportPath, false);
55
+ if (entries[flatName] === void 0) throw new Error(`overrides: export path "${exportPath}" (entry "${flatName}") is not a build entry of ${packageName}`);
56
+ }
57
+ }
58
+ /**
59
+ * For each `outSubdir` override, point its meta entry at the isolated sub-package barrel: the dts lives
60
+ * at `<subdir>/index.d.ts` (not `<flatName>.d.ts`). Keyed by the stable flattened entry name so it
61
+ * overwrites the default `dtsBasenames[flatName] = flatName` set from the full entry map. No-op when no
62
+ * override sets `outSubdir`.
63
+ */
64
+ function applySubdirMetaEntries(overrides, dtsBasenames, exportPaths) {
65
+ if (overrides === void 0) return;
66
+ for (const ov of overrides) {
67
+ if (ov.outSubdir === void 0) continue;
68
+ const exportPath = ov.entries[0];
69
+ if (exportPath === void 0) continue;
70
+ const flatName = createEntryName(exportPath, false);
71
+ dtsBasenames[flatName] = `${ov.outSubdir}/index`;
72
+ exportPaths[flatName] = exportPath;
73
+ }
74
+ }
38
75
  /** Run a build from a normalized config. Pure orchestration; all IO injectable. */
39
76
  async function runBuild(config, options) {
40
- const { target } = parseArgs(options.argv);
77
+ const { target, noExe } = parseArgs(options.argv);
41
78
  const build = options.buildTargetGroups ?? buildTargetGroups;
42
79
  const cwd = options.cwd;
43
80
  const pkg = readPackageJson(cwd);
@@ -52,10 +89,6 @@ async function runBuild(config, options) {
52
89
  } : {},
53
90
  ...jsx?.runtime === "classic" ? { jsx: "react" } : {}
54
91
  })))(cwd);
55
- const entries = packageJsonEntries({
56
- pkg,
57
- cwd
58
- });
59
92
  const exportsMap = options.readExports ? options.readExports() : pkg.exports;
60
93
  const runGenerateMeta = options.generateMeta ?? generateMeta;
61
94
  const publishTargets = (options.readPublishTargets ?? (() => {
@@ -76,6 +109,24 @@ async function runBuild(config, options) {
76
109
  ...config.meta !== void 0 && config.meta !== false ? { meta: config.meta } : {},
77
110
  ...config.looseFiles !== void 0 ? { looseFiles: config.looseFiles } : {}
78
111
  })).pipe(Effect.provide(ConfigValidatorLive)));
112
+ const exeSpecs = config.exe !== void 0 ? normalizeExeOptions(config.exe, osCpuForValidate) : [];
113
+ if (config.exe !== void 0 && (exeSpecs.length !== 1 || (exeSpecs[0]?.targets.length ?? 0) !== 1)) throw new Error(`exe build requires exactly one binary with one target (got ${exeSpecs.length} spec(s), ${exeSpecs[0]?.targets.length ?? 0} target(s) on the first). A package's exports["."] resolves to a single SEA — cross-platform binaries must each ship as their own per-platform package.`);
114
+ const exeSpec = exeSpecs[0];
115
+ const exeTarget = exeSpec?.targets[0];
116
+ const exeFileName = exeSpec && exeTarget ? computeExeFileName(exeSpec.fileName, exeTarget) : void 0;
117
+ const exeEntrySource = exeSpec?.entry ?? "./src/bin.ts";
118
+ const exeRewrite = config.exe !== void 0 && exeFileName !== void 0 ? {
119
+ source: exeEntrySource,
120
+ fileName: exeFileName,
121
+ dir: "bin"
122
+ } : void 0;
123
+ const entries = packageJsonEntries({
124
+ pkg,
125
+ cwd,
126
+ ...config.exe !== void 0 ? { excludeSources: [exeEntrySource] } : {}
127
+ });
128
+ const hasJsEntries = Object.keys(entries).length > 0;
129
+ validateSubdirOverrides(config.overrides, entries, packageName);
79
130
  if (target === "meta") {
80
131
  if (config.meta === false) {
81
132
  (options.writeOutput ?? ((o) => process.stdout.write(`${o.content}\n`)))({
@@ -88,13 +139,15 @@ async function runBuild(config, options) {
88
139
  const norm = normalizeMetaOptions(config.meta ?? {});
89
140
  const dtsBasenames = {};
90
141
  for (const name of Object.keys(entries)) dtsBasenames[name] = name;
142
+ const exportPaths = deriveExportPaths(entries, exportsMap);
143
+ applySubdirMetaEntries(config.overrides, dtsBasenames, exportPaths);
91
144
  await runGenerateMeta({
92
145
  cwd,
93
146
  packageName,
94
147
  tsconfigPath,
95
148
  dtsDir: join(cwd, "dist", "dev", "pkg"),
96
149
  entries: dtsBasenames,
97
- exportPaths: deriveExportPaths(entries, exportsMap),
150
+ exportPaths,
98
151
  outMetaDir: join(cwd, "dist", "dev", "meta"),
99
152
  localPaths: norm.localPaths,
100
153
  tsdoc: norm.tsdoc
@@ -108,22 +161,22 @@ async function runBuild(config, options) {
108
161
  }
109
162
  if (target === "exe") {
110
163
  if (config.exe === void 0) throw new Error("`savvy build --target exe` requires an `exe` option in the build config");
111
- const specs = normalizeExeOptions(config.exe, osCpuForValidate);
112
164
  await (options.runExeBuild ?? runExeBuild)({
113
165
  cwd,
114
166
  outDir: join(cwd, "dist", "dev", "pkg", "bin"),
115
- specs
167
+ specs: exeSpecs
116
168
  });
117
169
  (options.writeOutput ?? ((o) => process.stdout.write(`${o.content}\n`)))({
118
170
  target: "stdout",
119
171
  contentType: "text/plain",
120
- content: `exe: compiled ${specs.length} binary/binaries for ${packageName}`
172
+ content: `exe: compiled ${exeSpecs.length} binary/binaries for ${packageName}`
121
173
  });
122
174
  return;
123
175
  }
124
176
  let overridePartitions = [];
125
177
  let baseEntries = entries;
126
178
  let dualExports;
179
+ const subdirExports = /* @__PURE__ */ new Set();
127
180
  if (config.overrides) {
128
181
  const partitions = [];
129
182
  const overriddenEntryNames = /* @__PURE__ */ new Set();
@@ -131,14 +184,17 @@ async function runBuild(config, options) {
131
184
  const dualExportKeys = /* @__PURE__ */ new Set();
132
185
  const exportPathByEntry = deriveExportPaths(entries, exportsMap);
133
186
  for (const ov of config.overrides) {
187
+ if (ov.outSubdir !== void 0 && ov.entries.length !== 1) throw new Error(`overrides: outSubdir "${ov.outSubdir}" must pin exactly one export path (got ${ov.entries.length})`);
134
188
  const partEntry = {};
135
189
  for (const exportPath of ov.entries) {
136
190
  if (exportPath !== "." && !exportPath.startsWith("./")) throw new Error(`overrides: entry "${exportPath}" must be a canonical export path — use "." for the root or a "./"-prefixed subpath (e.g. "./changesets/markdownlint")`);
137
- const entryName = createEntryName(exportPath, false);
138
- const src = entries[entryName];
139
- if (src === void 0) throw new Error(`overrides: export path "${exportPath}" (entry "${entryName}") is not a build entry of ${packageName}`);
191
+ const flatName = createEntryName(exportPath, false);
192
+ const src = entries[flatName];
193
+ if (src === void 0) throw new Error(`overrides: export path "${exportPath}" (entry "${flatName}") is not a build entry of ${packageName}`);
194
+ const entryName = ov.outSubdir !== void 0 ? "index" : flatName;
140
195
  partEntry[entryName] = src;
141
- overriddenEntryNames.add(entryName);
196
+ overriddenEntryNames.add(flatName);
197
+ if (ov.outSubdir !== void 0) subdirExports.add(exportPath);
142
198
  if ((ov.format ?? config.format ?? ["esm"]).includes("cjs")) dualExportKeys.add(exportPath);
143
199
  }
144
200
  partitions.push({
@@ -148,7 +204,10 @@ async function runBuild(config, options) {
148
204
  ...ov.bundle !== void 0 ? { bundle: ov.bundle } : {},
149
205
  ...ov.bundleNodeModules !== void 0 ? { bundleNodeModules: ov.bundleNodeModules } : {},
150
206
  ...ov.bundledPackages !== void 0 ? { bundledPackages: ov.bundledPackages } : {},
151
- ...ov.dtsExternals !== void 0 ? { dtsExternals: ov.dtsExternals } : {}
207
+ ...ov.dtsExternals !== void 0 ? { dtsExternals: ov.dtsExternals } : {},
208
+ ...ov.platform !== void 0 ? { platform: ov.platform } : {},
209
+ ...ov.css !== void 0 ? { css: ov.css } : {},
210
+ ...ov.outSubdir !== void 0 ? { outSubdir: ov.outSubdir } : {}
152
211
  });
153
212
  }
154
213
  const onlyBase = {};
@@ -170,7 +229,7 @@ async function runBuild(config, options) {
170
229
  }],
171
230
  resolution: void 0
172
231
  } : deriveProdGroups(publishTargets, packageName);
173
- await build({
232
+ if (hasJsEntries || config.exe === void 0) await build({
174
233
  cwd,
175
234
  version,
176
235
  entry: config.overrides !== void 0 ? baseEntries : entries,
@@ -189,27 +248,65 @@ async function runBuild(config, options) {
189
248
  ...config.define !== void 0 ? { define: config.define } : {},
190
249
  ...overridePartitions.length > 0 ? { overrides: overridePartitions } : {},
191
250
  ...dualExports !== void 0 ? { dualExports } : {},
192
- ...looseFiles !== void 0 ? { looseFiles } : {}
251
+ ...subdirExports.size > 0 ? { subdirExports } : {},
252
+ ...looseFiles !== void 0 ? { looseFiles } : {},
253
+ ...exeRewrite !== void 0 ? { exeRewrite } : {}
193
254
  });
194
255
  if (target === "prod" && resolution !== void 0) writeBinding(cwd, resolution);
195
- if (target === "prod" && config.meta !== false) {
256
+ if (target === "prod" && config.meta !== false && (config.exe === void 0 || hasJsEntries)) {
196
257
  const metaGroupId = (groups.find((g) => g.name === packageName) ?? groups[0])?.id ?? "npm";
197
258
  const norm = normalizeMetaOptions(config.meta ?? {});
198
259
  const dtsBasenames = {};
199
260
  for (const name of Object.keys(entries)) dtsBasenames[name] = name;
261
+ const exportPaths = deriveExportPaths(entries, exportsMap);
262
+ applySubdirMetaEntries(config.overrides, dtsBasenames, exportPaths);
200
263
  await runGenerateMeta({
201
264
  cwd,
202
265
  packageName,
203
266
  tsconfigPath,
204
267
  dtsDir: join(cwd, "dist", "prod", metaGroupId, "pkg"),
205
268
  entries: dtsBasenames,
206
- exportPaths: deriveExportPaths(entries, exportsMap),
269
+ exportPaths,
207
270
  outMetaDir: join(cwd, "dist", "prod", metaGroupId, "meta"),
208
271
  localPaths: [],
209
272
  tsdoc: norm.tsdoc
210
273
  });
211
274
  }
212
275
  if (target === "prod") for (const g of groups) removeDeclarationMaps(join(cwd, "dist", "prod", g.id, "pkg"));
276
+ if (config.exe !== void 0 && exeSpec !== void 0 && exeRewrite !== void 0 && exeFileName !== void 0) {
277
+ const runExe = options.runExeBuild ?? runExeBuild;
278
+ const groupOutDir = (g) => target === "dev" ? join(cwd, "dist", "dev", "pkg") : join(cwd, "dist", "prod", g.id, "pkg");
279
+ for (const g of groups) {
280
+ const outDir = groupOutDir(g);
281
+ if (!hasJsEntries) {
282
+ const manifest = await buildEmittedManifest({
283
+ pkg,
284
+ targetGroup: {
285
+ id: g.id,
286
+ name: g.name,
287
+ isProd: target === "prod"
288
+ },
289
+ devManifest: config.devManifest,
290
+ ...config.transform !== void 0 ? { transform: config.transform } : {},
291
+ exeRewrite
292
+ });
293
+ mkdirSync(outDir, { recursive: true });
294
+ writeFileSync(join(outDir, "package.json"), `${JSON.stringify(manifest, null, " ")}\n`);
295
+ for (const file of ["LICENSE", "README.md"]) try {
296
+ copyFileSync(join(cwd, file), join(outDir, file));
297
+ } catch {}
298
+ }
299
+ if (!noExe) {
300
+ const binDir = join(outDir, "bin");
301
+ await runExe({
302
+ cwd,
303
+ outDir: binDir,
304
+ specs: exeSpecs
305
+ });
306
+ if (options.runExeBuild === void 0 && !existsSync(join(binDir, exeFileName))) throw new Error(`exe: expected compiled binary at ${join(binDir, exeFileName)} but it is missing — tsdown's emitted name may have drifted from computeExeFileName`);
307
+ }
308
+ }
309
+ }
213
310
  const totalMs = Date.now() - startMs;
214
311
  const reportEntries = Object.keys(entries);
215
312
  const report = {