@savvy-web/tsdown-plugins 0.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.
Files changed (49) hide show
  1. package/README.md +61 -0
  2. package/build/build-target-groups.js +133 -0
  3. package/build/cjs-default-interop.js +98 -0
  4. package/build/node-builtin-default-interop.js +74 -0
  5. package/build/strip-maps.js +40 -0
  6. package/build/sync-public.js +67 -0
  7. package/build/target-groups.js +52 -0
  8. package/catalog/resolve-catalogs.js +24 -0
  9. package/config-validation/ConfigValidator.js +8 -0
  10. package/config-validation/ConfigValidatorLive.js +61 -0
  11. package/dts/resolved-tsconfig.js +44 -0
  12. package/entry/extract.js +68 -0
  13. package/entry/package-json-entries.js +12 -0
  14. package/errors.js +36 -0
  15. package/exe/build.js +23 -0
  16. package/exe/config.js +50 -0
  17. package/index.d.ts +1457 -0
  18. package/index.js +44 -0
  19. package/jsx/config.js +40 -0
  20. package/manifest/emit-manifest.js +56 -0
  21. package/manifest/transform.js +123 -0
  22. package/meta/api-extractor.js +59 -0
  23. package/meta/config.js +14 -0
  24. package/meta/generate.js +76 -0
  25. package/meta/merge-models.js +44 -0
  26. package/meta/message-suppressor.js +37 -0
  27. package/meta/tsconfig-resolver.js +260 -0
  28. package/meta/tsdoc-config.js +47 -0
  29. package/package.json +45 -0
  30. package/report/formatters/ci-annotations.js +20 -0
  31. package/report/formatters/json.js +12 -0
  32. package/report/formatters/markdown.js +25 -0
  33. package/report/formatters/silent.js +8 -0
  34. package/report/formatters/terminal.js +29 -0
  35. package/report/layers/EnvironmentDetectorLive.js +15 -0
  36. package/report/layers/ExecutorResolverLive.js +8 -0
  37. package/report/layers/FormatSelectorLive.js +8 -0
  38. package/report/layers/OutputRendererLive.js +23 -0
  39. package/report/pipeline.js +25 -0
  40. package/report/schema-export.js +18 -0
  41. package/report/schema.js +19 -0
  42. package/report/services/EnvironmentDetector.js +7 -0
  43. package/report/services/ExecutorResolver.js +7 -0
  44. package/report/services/FormatSelector.js +7 -0
  45. package/report/services/OutputRenderer.js +7 -0
  46. package/report/timer.js +15 -0
  47. package/targets/binding.js +15 -0
  48. package/targets/config.js +8 -0
  49. package/targets/resolve-targets.js +126 -0
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @savvy-web/tsdown-plugins
2
+
3
+ [![npm](https://img.shields.io/npm/v/@savvy-web%2Ftsdown-plugins?label=npm&color=cb3837)](https://www.npmjs.com/package/@savvy-web/tsdown-plugins)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ The plugin pack behind [`@savvy-web/bundler`](https://www.npmjs.com/package/@savvy-web/bundler). It holds every build behavior the bundler drives — entry detection, manifest emission and catalog resolution, the dts tsconfig port, the per-target build loop and the build-output reporter — as composable helpers and one [rolldown](https://rolldown.rs/) plugin. It is authored against rolldown's plugin _type_ only, so it imports no `tsdown` runtime and declares no `tsdown` peer dependency: bring your own `tsdown`.
7
+
8
+ Most packages should use `@savvy-web/bundler` directly. Reach for this package when you have outgrown the bundler's front door and want to compose the same building blocks in a hand-written `tsdown.config.ts`.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install --save-dev @savvy-web/tsdown-plugins tsdown
14
+ # or
15
+ pnpm add -D @savvy-web/tsdown-plugins tsdown
16
+ ```
17
+
18
+ `tsdown` is a peer of your own choosing — the plugin pack never pins it.
19
+
20
+ ## Quick start
21
+
22
+ Compose the helpers in a `tsdown.config.ts` to reproduce the bundler's front door yourself:
23
+
24
+ ```ts
25
+ // tsdown.config.ts
26
+ import { packageJsonEntries, emitManifest } from "@savvy-web/tsdown-plugins";
27
+ import { defineConfig } from "tsdown";
28
+
29
+ const sourceDir = process.cwd();
30
+
31
+ export default defineConfig({
32
+ entry: packageJsonEntries({ cwd: sourceDir }),
33
+ // emitManifest writes a transformed, catalog-resolved package.json into the output pkg/
34
+ plugins: [emitManifest({ sourceDir, targetGroup: { id: "npm", isProd: true } })],
35
+ });
36
+ ```
37
+
38
+ `packageJsonEntries` reads a package's `exports` and `bin` and returns the `Record<name, path>` that `tsdown` accepts as `entry`. `emitManifest` reads the `package.json` under `sourceDir`, writes the transformed manifest and copies `LICENSE`/`README.md` into the output folder.
39
+
40
+ ## Features
41
+
42
+ - **Entry detection** — `packageJsonEntries` and `extractEntries` derive build entries from a package's `exports` and `bin`, matching the rules used across the Silk Suite builders.
43
+ - **Manifest transforms** — `transformManifest`, `transformExports`, `transformBin` and `normalizeBinPaths` rewrite a source `package.json` into a publishable one; `emitManifest` is the rolldown plugin that writes it. A dual-format build emits both `import` and `require` export conditions.
44
+ - **Catalog resolution** — `resolveManifest` resolves `catalog:` and `workspace:` specifiers against the workspace, delegating to `workspaces-effect`'s `CatalogResolver`.
45
+ - **Multi-target resolution** — `resolveTargets` turns a `publishConfig.targets` map into the distinct byte-variant groups to build and the registry bindings for each; `writeTargetsBinding` persists that resolution as `dist/prod/targets.json` for the release step.
46
+ - **JSX resolution** — `resolveJsxConfig` and `readTsconfigJsx` derive the effective JSX transform from a package's tsconfig, with an explicit override winning.
47
+ - **Executable binaries** — `normalizeExeOptions` fills the SEA defaults and infers targets from the package's `os`/`cpu`; `runExeBuild` drives `@tsdown/exe` to compile the binaries.
48
+ - **Config validation** — the `ConfigValidator` Effect service (with `ConfigValidatorLive`) fast-fails on a bad `publishConfig.targets`, `exe` or `meta` config, raising the typed `ConfigValidationError`.
49
+ - **dts tsconfig port** — `buildResolvedTsconfig` and `writeResolvedTsconfig` write a temp tsconfig with absolute paths so type declarations emit cleanly under pnpm symlinks.
50
+ - **Per-target build loop** — `deriveTargetGroupOptions` and `buildTargetGroups` map a target to its `tsdown` options and run the build once per target, exposed as a helper so the escape hatch gets multi-target builds too. A `format` of `["esm", "cjs"]` (the `BuildFormat` type) derives a dual-format build — a require-able CJS output with default-export interop and `.d.cts` declarations alongside the ESM one. The `bundleNodeModules`, `bundledPackages` and `dtsExternals` options thread the dependency-bundling posture into both the JS and declaration passes.
51
+ - **Bundled declarations** — each target runs two `tsdown` passes: a JavaScript pass that preserves per-module output, then a declaration-only pass that rolls every re-exported type into a single `.d.ts` per public entry (`deriveDtsPassOptions`). Per-module JavaScript stays intact while consumers keep reaching re-exported types through your published subpaths.
52
+ - **API Extractor meta** — `generateMeta` runs [API Extractor](https://api-extractor.com/) over a package's emitted `.d.ts` to write an api-model bundle (`.api.json`, `tsdoc-metadata.json`, resolved `tsconfig.json`); `normalizeMetaOptions` fills the `MetaOptions` defaults that drive it.
53
+ - **Output reporter** — `renderReport` plus the `BuildReport` schema and a set of formatters (terminal, JSON, markdown, CI annotations, silent) render a build report for humans, agents or CI.
54
+
55
+ ## Effect
56
+
57
+ The package is implemented in [Effect](https://effect.website/), but Effect runs behind the plugin boundary: the catalog wrapper returns a `Promise` and the reporter is rendered with `Effect.runPromise` at the call site. The plugin and helper values you compose are plain rolldown-conformant objects. `effect` is a peer dependency.
58
+
59
+ ## License
60
+
61
+ [MIT](LICENSE)
@@ -0,0 +1,133 @@
1
+ import { emitManifest } from "../manifest/emit-manifest.js";
2
+ import { cjsDefaultInterop } from "./cjs-default-interop.js";
3
+ import { nodeBuiltinDefaultInterop } from "./node-builtin-default-interop.js";
4
+ import { syncPublicDir } from "./sync-public.js";
5
+ import { deriveDtsPassOptions, deriveTargetGroupOptions } from "./target-groups.js";
6
+ import { join } from "node:path";
7
+
8
+ //#region src/build/build-target-groups.ts
9
+ /**
10
+ * Run tsdown.build() per TargetGroup. Composable so the escape hatch gets multi-group too.
11
+ *
12
+ * Each group runs TWO passes to the SAME outDir:
13
+ * 1. JS pass — per-module JS (`unbundle: true`, `dts: false`), with the `emitManifest` plugin
14
+ * and the `public/` copy. Default `clean: true` gives it a fresh outDir.
15
+ * 2. dts pass — bundled declarations only (`unbundle: false`, `dts: { emitDtsOnly: true }`,
16
+ * `clean: false`). No manifest plugin, no copy, no sourcemaps. `clean: false` is load-bearing:
17
+ * it must NOT wipe the JS the first pass just wrote.
18
+ *
19
+ * Why two passes: tsdown's `unbundle` maps to rolldown `output.preserveModules` for the whole
20
+ * build (JS and the dts plugin share it), so a single pass cannot give per-module JS + bundled
21
+ * dts. Per-module dts breaks type portability (TS2883); bundling the JS re-bundles workspace
22
+ * consumers. The split keeps per-module JS AND rolled-up, self-contained declarations.
23
+ */
24
+ async function buildTargetGroups(options) {
25
+ const build = options.build ?? (await import("tsdown")).build;
26
+ const publicDir = join(options.cwd, "public");
27
+ for (const group of options.groups) {
28
+ const partitions = [{
29
+ entry: options.entry,
30
+ ...options.format !== void 0 ? { format: options.format } : {},
31
+ ...options.externals !== void 0 ? { externals: options.externals } : {},
32
+ ...options.bundle !== void 0 ? { bundle: options.bundle } : {},
33
+ ...options.bundleNodeModules !== void 0 ? { bundleNodeModules: options.bundleNodeModules } : {},
34
+ ...options.bundledPackages !== void 0 ? { bundledPackages: options.bundledPackages } : {},
35
+ ...options.dtsExternals !== void 0 ? { dtsExternals: options.dtsExternals } : {}
36
+ }, ...options.overrides ?? []];
37
+ for (let p = 0; p < partitions.length; p++) {
38
+ const part = partitions[p];
39
+ if (part === void 0) continue;
40
+ const isBase = p === 0;
41
+ const partExternals = part.externals;
42
+ const partBundle = part.bundle;
43
+ const partBundleNodeModules = part.bundleNodeModules;
44
+ const partBundledPackages = part.bundledPackages;
45
+ const partDtsExternals = part.dtsExternals;
46
+ const deriveInput = {
47
+ group: group.id,
48
+ cwd: options.cwd,
49
+ version: options.version,
50
+ entry: part.entry,
51
+ tsconfigPath: options.tsconfigPath,
52
+ devManifest: options.devManifest,
53
+ ...partExternals !== void 0 ? { externals: partExternals } : {},
54
+ ...partBundledPackages !== void 0 ? { bundledPackages: partBundledPackages } : {},
55
+ ...part.format !== void 0 ? { format: part.format } : {},
56
+ ...options.minify !== void 0 ? { minify: options.minify } : {},
57
+ ...options.jsx !== void 0 ? { jsx: options.jsx } : {}
58
+ };
59
+ const js = deriveTargetGroupOptions(deriveInput);
60
+ const dts = deriveDtsPassOptions(deriveInput);
61
+ const targetGroup = {
62
+ id: group.id,
63
+ name: group.name,
64
+ isProd: js.isProd
65
+ };
66
+ const manifestPlugin = isBase ? emitManifest({
67
+ targetGroup,
68
+ devManifest: options.devManifest,
69
+ transform: options.transform,
70
+ sourceDir: options.cwd,
71
+ dual: options.dualExports ?? js.format.includes("cjs")
72
+ }) : void 0;
73
+ await build({
74
+ config: false,
75
+ cwd: options.cwd,
76
+ entry: js.entry,
77
+ outDir: js.outDir,
78
+ format: js.format,
79
+ platform: js.platform,
80
+ sourcemap: js.sourcemap,
81
+ minify: js.minify,
82
+ unbundle: js.unbundle,
83
+ clean: isBase ? js.clean : false,
84
+ fixedExtension: js.fixedExtension,
85
+ dts: js.dts,
86
+ define: js.define,
87
+ ...partExternals?.length || partBundleNodeModules || partBundle?.length ? { deps: {
88
+ ...partExternals?.length ? { neverBundle: partExternals } : {},
89
+ ...partBundle?.length ? { alwaysBundle: partBundle } : {},
90
+ ...partBundleNodeModules ? { skipNodeModulesBundle: false } : {}
91
+ } } : {},
92
+ ...js.cjsDefault !== void 0 ? { cjsDefault: js.cjsDefault } : {},
93
+ ...js.jsx !== void 0 ? { inputOptions: { jsx: js.jsx } } : {},
94
+ plugins: [
95
+ ...manifestPlugin ? [manifestPlugin] : [],
96
+ ...js.format.includes("cjs") ? [nodeBuiltinDefaultInterop(), cjsDefaultInterop()] : [],
97
+ ...options.extraPlugins ?? []
98
+ ]
99
+ });
100
+ if (isBase) syncPublicDir(publicDir, join(js.outDir, "public"));
101
+ const dtsNeverBundle = [...partExternals ?? [], ...partDtsExternals ?? []];
102
+ await build({
103
+ config: false,
104
+ cwd: options.cwd,
105
+ entry: dts.entry,
106
+ outDir: dts.outDir,
107
+ format: dts.format,
108
+ platform: dts.platform,
109
+ sourcemap: dts.sourcemap,
110
+ unbundle: dts.unbundle,
111
+ clean: false,
112
+ fixedExtension: dts.fixedExtension,
113
+ dts: dts.dts,
114
+ define: dts.define,
115
+ ...dtsNeverBundle.length > 0 || partBundleNodeModules || dts.bundledPackages ? { deps: {
116
+ ...dtsNeverBundle.length > 0 ? { neverBundle: dtsNeverBundle } : {},
117
+ ...partBundleNodeModules ? {
118
+ skipNodeModulesBundle: false,
119
+ ...dts.bundledPackages ? { dts: { alwaysBundle: dts.bundledPackages } } : {}
120
+ } : dts.bundledPackages ? {
121
+ skipNodeModulesBundle: true,
122
+ dts: { alwaysBundle: dts.bundledPackages }
123
+ } : {}
124
+ } } : {},
125
+ ...dts.jsx !== void 0 ? { inputOptions: { jsx: dts.jsx } } : {},
126
+ plugins: [...dts.format.includes("cjs") ? [nodeBuiltinDefaultInterop(), cjsDefaultInterop()] : [], ...options.extraPlugins ?? []]
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ //#endregion
133
+ export { buildTargetGroups };
@@ -0,0 +1,98 @@
1
+ //#region src/build/cjs-default-interop.ts
2
+ /**
3
+ * The interop footer appended to a CJS ENTRY chunk that has a default export
4
+ * alongside named exports. It reassigns `module.exports` to the default value
5
+ * and re-attaches every named export as an own property of it.
6
+ *
7
+ * This is the rslib `cjsInterop: true` equivalent. rolldown's `output.exports`
8
+ * cannot natively produce `module.exports = <default>` while ALSO keeping named
9
+ * exports: for a default+named module both `"auto"` and `"named"` emit
10
+ * `exports.default = <default>` (verified against rolldown 1.1.0), so an
11
+ * ESM consumer doing `import(x).default` receives the `{ default, ...named }`
12
+ * wrapper object rather than the default value. The footer restores the rslib
13
+ * behavior so `import(x).default === <default>` and `require(x) === <default>`
14
+ * (with named exports attached as properties of it).
15
+ *
16
+ * Why this matters concretely: markdownlint-cli2 resolves a `customRules`
17
+ * specifier to a file path, `await import(fileURL)`, and reads `module.default`,
18
+ * expecting it to be the rules ARRAY (it then `.flat()`s it). silk's
19
+ * `./changesets/markdownlint` subpath default-exports the rules array plus five
20
+ * named rule objects; without this footer `import(...).default` is the wrapper
21
+ * object, whose `.names` is undefined, and markdownlint-cli2 aborts with
22
+ * "Property 'names' of custom rule at index 0 is incorrect: 'undefined'".
23
+ *
24
+ * Limitation — PRIMITIVE defaults: promotion only happens when the default value
25
+ * is an object or function, because named exports are re-attached to it as own
26
+ * properties (a primitive cannot carry properties). When a cjs entry chunk has a
27
+ * primitive default (`export default "x"` / `42` / `true`) ALONGSIDE named exports,
28
+ * `module.exports` is left as the `{ default, ...named }` wrapper and the footer
29
+ * emits a one-line `console.warn` so the no-op is observable rather than silent.
30
+ * This case does not occur in the suite today (every promoted default — the
31
+ * markdownlint rules array, the commitlint config objects — is an object), and a
32
+ * default-ONLY primitive is unaffected: rolldown already emits `module.exports =
33
+ * <primitive>` for it (no named exports, so this plugin never fires).
34
+ */
35
+ const INTEROP_FOOTER = [
36
+ "",
37
+ "// __cjs_default_interop__ (rslib cjsInterop parity): make module.exports the",
38
+ "// default export with named exports attached, so import(x).default is the value.",
39
+ "if (typeof module !== \"undefined\" && module.exports && module.exports.default !== void 0) {",
40
+ " const __cjsDefault = module.exports.default;",
41
+ " if (__cjsDefault !== null && (typeof __cjsDefault === \"object\" || typeof __cjsDefault === \"function\")) {",
42
+ " for (const __cjsKey of Object.keys(module.exports)) {",
43
+ " if (__cjsKey === \"default\" || __cjsKey === \"__esModule\") continue;",
44
+ " try {",
45
+ " Object.defineProperty(__cjsDefault, __cjsKey, {",
46
+ " value: module.exports[__cjsKey],",
47
+ " enumerable: true,",
48
+ " configurable: true,",
49
+ " writable: true",
50
+ " });",
51
+ " } catch {}",
52
+ " }",
53
+ " module.exports = __cjsDefault;",
54
+ " } else {",
55
+ " // Primitive default + named exports: cannot attach named props to a primitive,",
56
+ " // so module.exports stays the wrapper. Surface the no-op instead of hiding it.",
57
+ " if (typeof console !== \"undefined\")",
58
+ " console.warn(\"[savvy:cjs-default-interop] primitive default export left as a {default,...named} wrapper; import().default is NOT the primitive value\");",
59
+ " }",
60
+ "}"
61
+ ].join("\n");
62
+ /**
63
+ * Rolldown plugin: append the CJS default-interop footer to ENTRY chunks of the
64
+ * `cjs` format that export a default alongside named exports.
65
+ *
66
+ * Gated tightly so it never touches the wrong chunk:
67
+ * - format must be `cjs` (ESM is untouched; `import().default` on ESM is already correct);
68
+ * - the chunk must be an ENTRY chunk — never a SHARED chunk. Shared chunks are required by
69
+ * entry chunks via their named bindings (e.g. `require_changesets.changesets_exports.X`);
70
+ * reassigning a shared chunk's `module.exports` to its own default would break those reads
71
+ * (many bundled vendor chunks carry an `exports.default`);
72
+ * - the chunk must export a `default` AND at least one named export. A default-only chunk
73
+ * already gets `module.exports = <default>` from rolldown, and a named-only chunk has no
74
+ * default to promote.
75
+ *
76
+ * The emitted footer is also self-guarded (`module.exports.default !== void 0`), so it is a
77
+ * runtime no-op whenever the static gate is ever too generous.
78
+ */
79
+ function cjsDefaultInterop() {
80
+ return {
81
+ name: "savvy:cjs-default-interop",
82
+ renderChunk(code, chunk, outputOptions) {
83
+ if (outputOptions.format !== "cjs") return null;
84
+ if (!chunk.isEntry) return null;
85
+ const exportsList = chunk.exports;
86
+ if (!Array.isArray(exportsList)) return null;
87
+ if (!exportsList.includes("default")) return null;
88
+ if (!exportsList.some((name) => name !== "default")) return null;
89
+ return {
90
+ code: `${code}\n${INTEROP_FOOTER}\n`,
91
+ map: null
92
+ };
93
+ }
94
+ };
95
+ }
96
+
97
+ //#endregion
98
+ export { cjsDefaultInterop };
@@ -0,0 +1,74 @@
1
+ import { builtinModules } from "node:module";
2
+
3
+ //#region src/build/node-builtin-default-interop.ts
4
+ /**
5
+ * The set of Node built-in module names (without the `node:` prefix), e.g. `path`,
6
+ * `process`, `fs`. Used to decide whether a BARE import specifier names a builtin.
7
+ */
8
+ const BUILTIN_NAMES = new Set(builtinModules);
9
+ /** True when `spec` resolves to a Node built-in module (either `node:x` or a bare builtin name). */
10
+ function isNodeBuiltin(spec) {
11
+ if (spec.startsWith("node:")) return true;
12
+ return BUILTIN_NAMES.has(spec);
13
+ }
14
+ /**
15
+ * Rewrite a default import / default re-export of a Node built-in into the
16
+ * equivalent NAMESPACE form, so rolldown's CJS codegen produces correct interop.
17
+ *
18
+ * Why this exists — a rolldown 1.1.0 codegen defect (verified against the latest
19
+ * published rolldown 1.1.0 / tsdown 0.22.2, with no newer release to upgrade to):
20
+ *
21
+ * // SOURCE (e.g. vfile's lib/minproc.js)
22
+ * export {default as minproc} from 'node:process'
23
+ * // ...consumed as minproc.cwd()
24
+ *
25
+ * // rolldown CJS OUTPUT (BROKEN)
26
+ * let node_process = require("node:process");
27
+ * node_process.default.cwd() // <- require("node:process").default is undefined
28
+ *
29
+ * For a default import of an EXTERNAL Node builtin, rolldown emits a bare
30
+ * `require("node:x")` WITHOUT its `__toESM` interop wrapper, yet still accesses
31
+ * `.default` — which is `undefined` on a builtin's CJS export object, so the call
32
+ * throws `Cannot read properties of undefined (reading 'cwd')` at runtime. NAMED
33
+ * imports are unaffected (`(0, node_process.cwd)()` reads a real property), and a
34
+ * NAMESPACE import is handled correctly: rolldown wraps it as
35
+ * `node_process = __toESM(require("node:process"), 1)`, which synthesizes `.default`
36
+ * and copies every own property, so member access works. This transform converts the
37
+ * broken default form into the working namespace form BEFORE codegen, so it is immune
38
+ * to minification and applies identically to per-module and bundled output.
39
+ *
40
+ * rolldown exposes no Rollup-style `output.interop` knob to fix this at the output
41
+ * layer, which is why the correction happens here on the source.
42
+ *
43
+ * Rewrites (the two static forms that occur in practice, anchored to statement start):
44
+ * - `import NAME from "node:x"` -> `import * as NAME from "node:x"`
45
+ * - `export { default as NAME } from "node:x"` -> `export * as NAME from "node:x"`
46
+ * - `import NAME, { a, b } from "node:x"` -> `import * as NAME from "node:x"; import { a, b } from "node:x"`
47
+ *
48
+ * The namespace binding NAME carries the builtin's named exports (`NAME.cwd`,
49
+ * `NAME.join`, ...), which is exactly how a default import of a builtin is consumed
50
+ * in practice. ESM output is unaffected at runtime (a namespace import of a builtin
51
+ * resolves to the same members), so the plugin is safe to attach to dual builds.
52
+ */
53
+ function nodeBuiltinDefaultInterop() {
54
+ const DEFAULT_REEXPORT = /(^|\n)([ \t]*)export\s*\{\s*default\s+as\s+([A-Za-z_$][\w$]*)\s*\}\s*from\s*(["'])([^"']+)\4/g;
55
+ const DEFAULT_WITH_NAMED = /(^|\n)([ \t]*)import\s+([A-Za-z_$][\w$]*)\s*,\s*(\{[^}]*\})\s*from\s*(["'])([^"']+)\5/g;
56
+ const DEFAULT_IMPORT = /(^|\n)([ \t]*)import\s+([A-Za-z_$][\w$]*)\s+from\s*(["'])([^"']+)\4/g;
57
+ return {
58
+ name: "savvy:node-builtin-default-interop",
59
+ transform(code) {
60
+ if (!code.includes("node:") && !code.includes("from")) return null;
61
+ let out = code;
62
+ out = out.replace(DEFAULT_REEXPORT, (match, lead, indent, name, quote, spec) => isNodeBuiltin(spec) ? `${lead}${indent}export * as ${name} from ${quote}${spec}${quote}` : match);
63
+ out = out.replace(DEFAULT_WITH_NAMED, (match, lead, indent, name, named, quote, spec) => isNodeBuiltin(spec) ? `${lead}${indent}import * as ${name} from ${quote}${spec}${quote};\n${indent}import ${named} from ${quote}${spec}${quote}` : match);
64
+ out = out.replace(DEFAULT_IMPORT, (match, lead, indent, name, quote, spec) => isNodeBuiltin(spec) ? `${lead}${indent}import * as ${name} from ${quote}${spec}${quote}` : match);
65
+ return out === code ? null : {
66
+ code: out,
67
+ map: null
68
+ };
69
+ }
70
+ };
71
+ }
72
+
73
+ //#endregion
74
+ export { nodeBuiltinDefaultInterop };
@@ -0,0 +1,40 @@
1
+ import { join } from "node:path";
2
+ import { readdirSync, rmSync } from "node:fs";
3
+
4
+ //#region src/build/strip-maps.ts
5
+ /**
6
+ * Remove declaration source-map files (`.d.ts.map` / `.d.cts.map`) from a built `pkg`
7
+ * directory, returning the removed paths.
8
+ *
9
+ * The dts pass emits these next to each `.d.ts` (the resolved dts tsconfig sets
10
+ * `declarationMap: true`) because API Extractor reads them during meta generation to
11
+ * resolve original-source positions. But they are dead weight in a PUBLISHED package —
12
+ * they reference `.ts` sources the tarball does not ship — and they leak local source
13
+ * paths, so the prod build strips them AFTER meta generation has consumed them. The dev
14
+ * build keeps them (it is never published, and `savvy build --target meta` reads them).
15
+ *
16
+ * Recurses, but skips `node_modules` so it does not traverse a self-contained bundle's
17
+ * vendored tree — only the package's own emitted declarations carry maps worth stripping.
18
+ */
19
+ function removeDeclarationMaps(pkgDir) {
20
+ const removed = [];
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(pkgDir, { withFileTypes: true });
24
+ } catch {
25
+ return removed;
26
+ }
27
+ for (const entry of entries) {
28
+ if (entry.name === "node_modules") continue;
29
+ const full = join(pkgDir, entry.name);
30
+ if (entry.isDirectory()) removed.push(...removeDeclarationMaps(full));
31
+ else if (entry.name.endsWith(".d.ts.map") || entry.name.endsWith(".d.cts.map")) {
32
+ rmSync(full, { force: true });
33
+ removed.push(full);
34
+ }
35
+ }
36
+ return removed;
37
+ }
38
+
39
+ //#endregion
40
+ export { removeDeclarationMaps };
@@ -0,0 +1,67 @@
1
+ import { dirname, join, relative } from "node:path";
2
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
3
+
4
+ //#region src/build/sync-public.ts
5
+ /** Recursively collect every file path under `dir`, relative to `base`. */
6
+ function listFilesRel(dir, base = dir) {
7
+ const out = [];
8
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
9
+ const abs = join(dir, entry.name);
10
+ if (entry.isDirectory()) out.push(...listFilesRel(abs, base));
11
+ else out.push(relative(base, abs));
12
+ }
13
+ return out;
14
+ }
15
+ /** True when both files exist with identical size and bytes. Size is checked first so the byte read is skipped for the common changed case. */
16
+ function sameBytes(a, b) {
17
+ if (statSync(a).size !== statSync(b).size) return false;
18
+ return readFileSync(a).equals(readFileSync(b));
19
+ }
20
+ /** Remove empty directories under `dir` (deepest-first); `dir` itself is left in place. */
21
+ function pruneEmptyDirs(dir) {
22
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
23
+ if (!entry.isDirectory()) continue;
24
+ const sub = join(dir, entry.name);
25
+ pruneEmptyDirs(sub);
26
+ if (readdirSync(sub).length === 0) rmSync(sub, {
27
+ recursive: true,
28
+ force: true
29
+ });
30
+ }
31
+ }
32
+ /**
33
+ * Mirror `sourceDir` into `targetDir`, idempotently.
34
+ *
35
+ * Replaces tsdown's built-in `copy`, whose non-recursive mkdir throws `EEXIST` when the target
36
+ * already exists (re-builds, `prepare`-on-install, concurrent turbo invocations). Behavior:
37
+ *
38
+ * - source absent: no-op.
39
+ * - target absent: copy `sourceDir` wholesale.
40
+ * - target present: copy only files that are new or whose bytes differ, then delete target files
41
+ * that no longer exist in the source and prune the directories left empty.
42
+ *
43
+ * The byte-diff keeps unchanged files (and their timestamps) untouched, so a large copied asset
44
+ * tree — e.g. the mcp markdown corpus — is not rewritten on every build.
45
+ */
46
+ function syncPublicDir(sourceDir, targetDir) {
47
+ if (!existsSync(sourceDir)) return;
48
+ if (!existsSync(targetDir)) {
49
+ cpSync(sourceDir, targetDir, { recursive: true });
50
+ return;
51
+ }
52
+ const sourceFiles = listFilesRel(sourceDir);
53
+ const sourceSet = new Set(sourceFiles);
54
+ for (const rel of sourceFiles) {
55
+ const src = join(sourceDir, rel);
56
+ const dst = join(targetDir, rel);
57
+ if (!existsSync(dst) || !sameBytes(src, dst)) {
58
+ mkdirSync(dirname(dst), { recursive: true });
59
+ copyFileSync(src, dst);
60
+ }
61
+ }
62
+ for (const rel of listFilesRel(targetDir)) if (!sourceSet.has(rel)) rmSync(join(targetDir, rel), { force: true });
63
+ pruneEmptyDirs(targetDir);
64
+ }
65
+
66
+ //#endregion
67
+ export { syncPublicDir };
@@ -0,0 +1,52 @@
1
+ import { join } from "node:path";
2
+
3
+ //#region src/build/target-groups.ts
4
+ const outDirFor = (cwd, group) => group === "dev" ? join(cwd, "dist/dev/pkg") : join(cwd, "dist/prod", group, "pkg");
5
+ /** Derive the JS-pass tsdown options for one TargetGroup (per-module JS, no dts). */
6
+ function deriveTargetGroupOptions(options) {
7
+ const isProd = options.group !== "dev";
8
+ const format = options.format ?? ["esm"];
9
+ const hasCjs = format.includes("cjs");
10
+ return {
11
+ outDir: outDirFor(options.cwd, options.group),
12
+ sourcemap: !isProd,
13
+ minify: isProd && (options.minify ?? false),
14
+ format,
15
+ unbundle: true,
16
+ clean: true,
17
+ platform: "node",
18
+ fixedExtension: false,
19
+ entry: options.entry,
20
+ dts: false,
21
+ define: { __PACKAGE_VERSION__: JSON.stringify(options.version) },
22
+ isProd,
23
+ ...hasCjs ? { cjsDefault: true } : {},
24
+ ...options.jsx !== void 0 ? { jsx: options.jsx } : {}
25
+ };
26
+ }
27
+ /** Derive the dts-pass tsdown options for one TargetGroup (bundled declarations only). */
28
+ function deriveDtsPassOptions(options) {
29
+ const isProd = options.group !== "dev";
30
+ const format = options.format ?? ["esm"];
31
+ return {
32
+ outDir: outDirFor(options.cwd, options.group),
33
+ sourcemap: false,
34
+ format,
35
+ unbundle: false,
36
+ clean: false,
37
+ platform: "node",
38
+ fixedExtension: false,
39
+ entry: options.entry,
40
+ dts: {
41
+ tsconfig: options.tsconfigPath,
42
+ emitDtsOnly: true
43
+ },
44
+ define: { __PACKAGE_VERSION__: JSON.stringify(options.version) },
45
+ isProd,
46
+ ...options.jsx !== void 0 ? { jsx: options.jsx } : {},
47
+ ...options.bundledPackages !== void 0 ? { bundledPackages: options.bundledPackages } : {}
48
+ };
49
+ }
50
+
51
+ //#endregion
52
+ export { deriveDtsPassOptions, deriveTargetGroupOptions };
@@ -0,0 +1,24 @@
1
+ import { CatalogResolver, WorkspacesLive } from "workspaces-effect";
2
+ import { NodeContext } from "@effect/platform-node";
3
+ import { Effect } from "effect";
4
+
5
+ //#region src/catalog/resolve-catalogs.ts
6
+ /**
7
+ * Resolve every `catalog:`/`workspace:` specifier in a manifest to a concrete spec,
8
+ * delegating to workspaces-effect's CatalogResolver. The resolver discovers the
9
+ * workspace root from `process.cwd()` (run this from inside the target workspace)
10
+ * and assembles catalogs durably (inline + config-dependency hook-replay + lockfile),
11
+ * so no transient `.pnpm-workspace-state-v1.json` is required.
12
+ *
13
+ * Rejects with `CatalogResolutionError` on an unresolvable reference, or
14
+ * `CatalogAssemblyError` if the workspace catalog set cannot be assembled.
15
+ */
16
+ function resolveManifest(pkg) {
17
+ const program = Effect.gen(function* () {
18
+ return yield* (yield* CatalogResolver).resolve(pkg);
19
+ });
20
+ return Effect.runPromise(program.pipe(Effect.provide(WorkspacesLive), Effect.provide(NodeContext.layer)));
21
+ }
22
+
23
+ //#endregion
24
+ export { resolveManifest };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/config-validation/ConfigValidator.ts
4
+ /** Fast-fail config validator; runs first in the bundler over the resolved config. */
5
+ var ConfigValidator = class extends Context.Tag("@savvy-web/tsdown-plugins/ConfigValidator")() {};
6
+
7
+ //#endregion
8
+ export { ConfigValidator };
@@ -0,0 +1,61 @@
1
+ import { ConfigValidator } from "./ConfigValidator.js";
2
+ import { ConfigValidationError } from "../errors.js";
3
+ import { normalizeExeOptions } from "../exe/config.js";
4
+ import { resolveTargets } from "../targets/resolve-targets.js";
5
+ import { Effect, Layer } from "effect";
6
+ import { existsSync, statSync } from "node:fs";
7
+
8
+ //#region src/config-validation/ConfigValidatorLive.ts
9
+ const VALID_SYNTAX_KINDS = new Set([
10
+ "block",
11
+ "inline",
12
+ "modifier"
13
+ ]);
14
+ /** Synchronous rule set; throws ConfigValidationError on the first violation. */
15
+ function check(input) {
16
+ if (input.targets !== void 0 && Object.keys(input.targets).length > 0) resolveTargets({
17
+ targets: input.targets,
18
+ baseName: input.baseName
19
+ });
20
+ if (input.exe !== void 0) {
21
+ const specs = normalizeExeOptions(input.exe, input.osCpu ?? {
22
+ os: [],
23
+ cpu: []
24
+ });
25
+ for (const spec of specs) {
26
+ if (spec.fileName.trim() === "") throw new ConfigValidationError({
27
+ path: "exe.fileName",
28
+ reason: "an exe binary needs a non-empty fileName"
29
+ });
30
+ if (spec.targets.length === 0) throw new ConfigValidationError({
31
+ path: `exe.${spec.fileName}.targets`,
32
+ reason: "no targets: the package declares no os/cpu and the exe config states none"
33
+ });
34
+ }
35
+ }
36
+ if (input.meta !== void 0) {
37
+ if (!input.hasExports) throw new ConfigValidationError({
38
+ path: "meta",
39
+ reason: "meta requires an exports map to extract an api-model from"
40
+ });
41
+ for (const tag of input.meta.tsdoc?.tagDefinitions ?? []) if (!VALID_SYNTAX_KINDS.has(tag.syntaxKind)) throw new ConfigValidationError({
42
+ path: "meta.tsdoc.tagDefinitions",
43
+ reason: `tag "${tag.tagName}" has an invalid syntaxKind "${tag.syntaxKind}"`
44
+ });
45
+ for (const p of input.meta.localPaths ?? []) if (existsSync(p) && !statSync(p).isDirectory()) throw new ConfigValidationError({
46
+ path: "meta.localPaths",
47
+ reason: `"${p}" exists but is not a directory`
48
+ });
49
+ }
50
+ }
51
+ /** Live ConfigValidator: wraps the synchronous rule set, surfacing ConfigValidationError as a typed Effect failure. */
52
+ const ConfigValidatorLive = Layer.succeed(ConfigValidator, { validate: (input) => Effect.try({
53
+ try: () => check(input),
54
+ catch: (e) => e instanceof ConfigValidationError ? e : new ConfigValidationError({
55
+ path: "config",
56
+ reason: String(e)
57
+ })
58
+ }) });
59
+
60
+ //#endregion
61
+ export { ConfigValidatorLive };