@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.
- package/README.md +61 -0
- package/build/build-target-groups.js +133 -0
- package/build/cjs-default-interop.js +98 -0
- package/build/node-builtin-default-interop.js +74 -0
- package/build/strip-maps.js +40 -0
- package/build/sync-public.js +67 -0
- package/build/target-groups.js +52 -0
- package/catalog/resolve-catalogs.js +24 -0
- package/config-validation/ConfigValidator.js +8 -0
- package/config-validation/ConfigValidatorLive.js +61 -0
- package/dts/resolved-tsconfig.js +44 -0
- package/entry/extract.js +68 -0
- package/entry/package-json-entries.js +12 -0
- package/errors.js +36 -0
- package/exe/build.js +23 -0
- package/exe/config.js +50 -0
- package/index.d.ts +1457 -0
- package/index.js +44 -0
- package/jsx/config.js +40 -0
- package/manifest/emit-manifest.js +56 -0
- package/manifest/transform.js +123 -0
- package/meta/api-extractor.js +59 -0
- package/meta/config.js +14 -0
- package/meta/generate.js +76 -0
- package/meta/merge-models.js +44 -0
- package/meta/message-suppressor.js +37 -0
- package/meta/tsconfig-resolver.js +260 -0
- package/meta/tsdoc-config.js +47 -0
- package/package.json +45 -0
- package/report/formatters/ci-annotations.js +20 -0
- package/report/formatters/json.js +12 -0
- package/report/formatters/markdown.js +25 -0
- package/report/formatters/silent.js +8 -0
- package/report/formatters/terminal.js +29 -0
- package/report/layers/EnvironmentDetectorLive.js +15 -0
- package/report/layers/ExecutorResolverLive.js +8 -0
- package/report/layers/FormatSelectorLive.js +8 -0
- package/report/layers/OutputRendererLive.js +23 -0
- package/report/pipeline.js +25 -0
- package/report/schema-export.js +18 -0
- package/report/schema.js +19 -0
- package/report/services/EnvironmentDetector.js +7 -0
- package/report/services/ExecutorResolver.js +7 -0
- package/report/services/FormatSelector.js +7 -0
- package/report/services/OutputRenderer.js +7 -0
- package/report/timer.js +15 -0
- package/targets/binding.js +15 -0
- package/targets/config.js +8 -0
- package/targets/resolve-targets.js +126 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @savvy-web/tsdown-plugins
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@savvy-web/tsdown-plugins)
|
|
4
|
+
[](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 };
|