@savvy-web/tsdown-plugins 0.12.0 → 1.0.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
@@ -41,6 +41,7 @@ export default defineConfig({
41
41
 
42
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
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, and a `"./package.json": "./package.json"` entry is added to the exports map so consumers can `import "<pkg>/package.json"`.
44
+ - **Ambient `.d.ts` exports** — `extractAmbientDts` and `classifyDtsExport` pick the types-only, hand-authored declaration exports out of a package's `exports` map; `transformExports` rewrites each to a key-derived `{ types }` pointer and `copyAmbientDts` copies the source declaration verbatim into every target dir, preserving its extension. `findRelativeSpecifiers` rejects a non-self-contained declaration and `mixedDtsExportError` rejects an export that mixes a hand-authored `types` with a runtime source.
44
45
  - **Catalog resolution** — `resolveManifest` resolves `catalog:` and `workspace:` specifiers against the workspace, delegating to `workspaces-effect`'s `CatalogResolver`.
45
46
  - **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
47
  - **JSX resolution** — `resolveJsxConfig` and `readTsconfigJsx` derive the effective JSX transform from a package's tsconfig, with an explicit override winning.
@@ -6,7 +6,7 @@ import { createTimer } from "../report/timer.js";
6
6
  import { createTsdownLogger } from "../report/tsdown-logger.js";
7
7
  import { cjsDefaultInterop } from "./cjs-default-interop.js";
8
8
  import { nodeBuiltinDefaultInterop } from "./node-builtin-default-interop.js";
9
- import { syncPublicDir } from "./sync-public.js";
9
+ import { copyPublicDir } from "./sync-public.js";
10
10
  import { deriveDeclarationsPassOptions, deriveDtsPassOptions, deriveTargetGroupOptions, outDirFor } from "./target-groups.js";
11
11
  import { readFileSync, writeFileSync } from "node:fs";
12
12
  import { dirname, isAbsolute, join } from "node:path";
@@ -140,7 +140,6 @@ async function buildTargetGroups(options) {
140
140
  ...metricsPlugins(group.id, "js")
141
141
  ]
142
142
  }));
143
- if (isBase) syncPublicDir(publicDir, join(js.outDir, "public"));
144
143
  const dtsNeverBundle = [...partExternals ?? [], ...partDtsExternals ?? []];
145
144
  if (Object.keys(dts.entry).length === 0) continue;
146
145
  const dtsDeps = dtsNeverBundle.length > 0 || partBundleNodeModules || dts.bundledPackages ? { deps: {
@@ -302,6 +301,7 @@ async function buildTargetGroups(options) {
302
301
  ]
303
302
  }));
304
303
  }
304
+ copyPublicDir(publicDir, outDirFor(options.cwd, group.id));
305
305
  }
306
306
  }
307
307
 
@@ -1,4 +1,6 @@
1
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
1
+ import { ConfigValidationError } from "../errors.js";
2
+ import { findRelativeSpecifiers } from "../dts/relative-imports.js";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
2
4
  import { dirname, join, relative } from "node:path";
3
5
 
4
6
  //#region src/build/sync-public.ts
@@ -17,52 +19,76 @@ function sameBytes(a, b) {
17
19
  if (statSync(a).size !== statSync(b).size) return false;
18
20
  return readFileSync(a).equals(readFileSync(b));
19
21
  }
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
- }
22
+ /** Throw the standard public-asset collision error for a destination-relative path. */
23
+ function throwPublicCollision(rel) {
24
+ throw new ConfigValidationError({
25
+ path: `public/${rel}`,
26
+ reason: `public asset "${rel}" collides with a built output at the package root — rename or remove it`
27
+ });
31
28
  }
32
29
  /**
33
- * Mirror `sourceDir` into `targetDir`, idempotently.
30
+ * Copy the CONTENTS of `sourceDir` into `outDir`, additively.
34
31
  *
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:
32
+ * Each `sourceDir/<rel>` copies to `outDir/<rel>` only the `public/` directory itself is dropped;
33
+ * the substructure under it is preserved (`public/tsconfig/ecma.json` becomes `<pkg>/tsconfig/ecma.json`,
34
+ * NOT `<pkg>/ecma.json`). The published manifest mirrors this drop via `transformExports`, which strips
35
+ * a leading `public/` from export values. This function NEVER deletes: `outDir` is the shared package
36
+ * root that the JS/dts passes own, so deleting "files not in source" would wipe the build product.
37
+ * Stale-asset pruning on a non-clean rebuild is therefore out of scope (a full build's `clean: true` handles it).
37
38
  *
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.
39
+ * Collision guard: when a destination already exists, identical bytes mean a prior copy of the same
40
+ * asset (skipped); anything else differing bytes, a directory where a file is needed, or a file
41
+ * where a parent directory is needed means a built output occupies that path, so it throws
42
+ * {@link ConfigValidationError} rather than clobbering it or surfacing a raw fs error.
45
43
  * @public
46
44
  */
47
- function syncPublicDir(sourceDir, targetDir) {
45
+ function copyPublicDir(sourceDir, outDir) {
48
46
  if (!existsSync(sourceDir)) return;
49
- if (!existsSync(targetDir)) {
50
- cpSync(sourceDir, targetDir, { recursive: true });
51
- return;
52
- }
53
- const sourceFiles = listFilesRel(sourceDir);
54
- const sourceSet = new Set(sourceFiles);
55
- for (const rel of sourceFiles) {
47
+ for (const rel of listFilesRel(sourceDir)) {
56
48
  const src = join(sourceDir, rel);
57
- const dst = join(targetDir, rel);
49
+ const dst = join(outDir, rel);
50
+ if (existsSync(dst)) {
51
+ if (statSync(dst).isFile() && sameBytes(src, dst)) continue;
52
+ throwPublicCollision(rel);
53
+ }
54
+ try {
55
+ mkdirSync(dirname(dst), { recursive: true });
56
+ copyFileSync(src, dst);
57
+ } catch (err) {
58
+ const code = err.code;
59
+ if (code === "ENOTDIR" || code === "EEXIST" || code === "EISDIR") throwPublicCollision(rel);
60
+ throw err;
61
+ }
62
+ }
63
+ }
64
+ /**
65
+ * Copy each ambient `.d.ts` export's source verbatim into `outDir/<outName>`, byte-stable (an
66
+ * unchanged file keeps its timestamp). The copy is NOT compiled or bundled, so the build owns two
67
+ * fast-fail checks: the source must exist, and it must be self-contained — a relative
68
+ * import/export/reference would not resolve once the file is flattened to the package root.
69
+ *
70
+ * Throws {@link ConfigValidationError} on a missing source or any relative specifier.
71
+ * @public
72
+ */
73
+ function copyAmbientDts(options) {
74
+ for (const a of options.ambient) {
75
+ const src = join(options.srcCwd, a.source);
76
+ if (!existsSync(src)) throw new ConfigValidationError({
77
+ path: `exports."${a.exportKey}"`,
78
+ reason: `ambient .d.ts source not found: ${a.source}`
79
+ });
80
+ const relativeSpecifiers = findRelativeSpecifiers(readFileSync(src, "utf-8"), a.source);
81
+ if (relativeSpecifiers.length > 0) throw new ConfigValidationError({
82
+ path: `exports."${a.exportKey}"`,
83
+ reason: `ambient .d.ts "${a.source}" has relative import(s) [${relativeSpecifiers.join(", ")}] that cannot resolve after a verbatim copy — use bare package specifiers or self-contained declare module/global blocks`
84
+ });
85
+ const dst = join(options.outDir, a.outName);
58
86
  if (!existsSync(dst) || !sameBytes(src, dst)) {
59
87
  mkdirSync(dirname(dst), { recursive: true });
60
88
  copyFileSync(src, dst);
61
89
  }
62
90
  }
63
- for (const rel of listFilesRel(targetDir)) if (!sourceSet.has(rel)) rmSync(join(targetDir, rel), { force: true });
64
- pruneEmptyDirs(targetDir);
65
91
  }
66
92
 
67
93
  //#endregion
68
- export { syncPublicDir };
94
+ export { copyAmbientDts, copyPublicDir };
@@ -0,0 +1,32 @@
1
+ import ts from "typescript";
2
+
3
+ //#region src/dts/relative-imports.ts
4
+ const isRelative = (s) => s.startsWith("./") || s.startsWith("../");
5
+ /**
6
+ * Find every relative module specifier in a declaration source: static `import`/`export … from`,
7
+ * `import("…")` type nodes, and `/// <reference path="…" />`. Pure parsing — no I/O.
8
+ *
9
+ * A non-empty result means the file is NOT self-contained and would break when copied verbatim to a
10
+ * flattened output location, so the ambient-copy step rejects it.
11
+ * @public
12
+ */
13
+ function findRelativeSpecifiers(source, fileName = "ambient.d.ts") {
14
+ const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
15
+ const found = /* @__PURE__ */ new Set();
16
+ for (const ref of sf.referencedFiles) if (isRelative(ref.fileName)) found.add(ref.fileName);
17
+ const visit = (node) => {
18
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier !== void 0 && ts.isStringLiteral(node.moduleSpecifier)) {
19
+ if (isRelative(node.moduleSpecifier.text)) found.add(node.moduleSpecifier.text);
20
+ } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal)) {
21
+ if (isRelative(node.argument.literal.text)) found.add(node.argument.literal.text);
22
+ } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && ts.isStringLiteral(node.moduleReference.expression)) {
23
+ if (isRelative(node.moduleReference.expression.text)) found.add(node.moduleReference.expression.text);
24
+ }
25
+ ts.forEachChild(node, visit);
26
+ };
27
+ visit(sf);
28
+ return [...found];
29
+ }
30
+
31
+ //#endregion
32
+ export { findRelativeSpecifiers };
@@ -0,0 +1,102 @@
1
+ import { ConfigValidationError } from "../errors.js";
2
+ import { createEntryName } from "./extract.js";
3
+
4
+ //#region src/entry/ambient-dts.ts
5
+ const DECLARATION_EXTS = [
6
+ ".d.ts",
7
+ ".d.cts",
8
+ ".d.mts"
9
+ ];
10
+ /** The declaration-file extension of a path, or undefined when it is not a declaration file. @public */
11
+ function declarationExt(p) {
12
+ return DECLARATION_EXTS.find((e) => p.endsWith(e));
13
+ }
14
+ /**
15
+ * Classify an export value:
16
+ * - `ambient` — a types-only declaration source (bare `.d.ts` string, or `{ types: "*.d.ts" }` with no runtime source).
17
+ * - `mixed` — a declaration `types` AND a compilable runtime source (`import`/`require`/`default` → `.ts`/`.tsx`).
18
+ * - `none` — anything else (normal runtime export, json, etc.).
19
+ * @public
20
+ */
21
+ function classifyDtsExport(value) {
22
+ if (typeof value === "string") return declarationExt(value) !== void 0 ? {
23
+ kind: "ambient",
24
+ source: value
25
+ } : { kind: "none" };
26
+ if (value && typeof value === "object") {
27
+ const o = value;
28
+ const typesVal = typeof o.types === "string" ? o.types : void 0;
29
+ const typesIsDecl = typesVal !== void 0 && declarationExt(typesVal) !== void 0;
30
+ const hasRuntime = Object.entries(o).some(([k, v]) => k !== "types" && typeof v === "string" && declarationExt(v) === void 0);
31
+ if (typesIsDecl) return hasRuntime ? { kind: "mixed" } : {
32
+ kind: "ambient",
33
+ source: typesVal
34
+ };
35
+ }
36
+ return { kind: "none" };
37
+ }
38
+ /**
39
+ * Output basename (including the preserved declaration extension) for an ambient export, derived
40
+ * from the export KEY — consistent with how JS entries are named. @public
41
+ */
42
+ function ambientOutName(exportKey, source, exportsAsIndexes = false) {
43
+ const ext = declarationExt(source) ?? ".d.ts";
44
+ return `${createEntryName(exportKey, exportsAsIndexes)}${ext}`;
45
+ }
46
+ /** The shared mixed-export error (Decision 2), used by both the extractor and the manifest transform. @public */
47
+ function mixedDtsExportError(exportKey) {
48
+ return new ConfigValidationError({
49
+ path: `exports."${exportKey}"`,
50
+ reason: "an export with a runtime source (import/require/default) cannot also hand-author its `types` as a .d.ts; the bundler generates types from the source"
51
+ });
52
+ }
53
+ /**
54
+ * Extract the types-only `.d.ts` exports from a package's `exports` map. Pure.
55
+ * Throws {@link ConfigValidationError} on a mixed export (Decision 2) or an ambient-vs-ambient
56
+ * output-name collision. @public
57
+ */
58
+ function extractAmbientDts(pkg, options = {}) {
59
+ const exports = pkg.exports;
60
+ if (!exports || typeof exports !== "object") return [];
61
+ const exportsAsIndexes = options.exportsAsIndexes ?? false;
62
+ const out = [];
63
+ const byName = /* @__PURE__ */ new Map();
64
+ for (const [key, value] of Object.entries(exports)) {
65
+ if (key === "./package.json" || key.endsWith(".json")) continue;
66
+ if (declarationExt(key) !== void 0) continue;
67
+ const cls = classifyDtsExport(value);
68
+ if (cls.kind === "none") continue;
69
+ if (cls.kind === "mixed") throw mixedDtsExportError(key);
70
+ const outName = ambientOutName(key, cls.source, exportsAsIndexes);
71
+ const prev = byName.get(outName);
72
+ if (prev !== void 0) throw new ConfigValidationError({
73
+ path: `exports."${key}"`,
74
+ reason: `ambient .d.ts export flattens to "${outName}", colliding with export key "${prev}". Rename one so each produces a distinct file.`
75
+ });
76
+ byName.set(outName, key);
77
+ out.push({
78
+ exportKey: key,
79
+ source: cls.source,
80
+ outName
81
+ });
82
+ }
83
+ return out;
84
+ }
85
+ /**
86
+ * Throw {@link ConfigValidationError} if any ambient output name collides with a JS build-entry name.
87
+ * The JS entry names carry no extension, so each ambient `outName` is compared with its declaration
88
+ * extension stripped. @public
89
+ */
90
+ function assertNoEntryCollisions(jsEntryNames, ambient) {
91
+ const js = new Set(jsEntryNames);
92
+ for (const a of ambient) {
93
+ const base = a.outName.replace(/\.d\.(ts|cts|mts)$/, "");
94
+ if (js.has(base)) throw new ConfigValidationError({
95
+ path: `exports."${a.exportKey}"`,
96
+ reason: `ambient .d.ts export "${a.outName}" collides with the JS build entry "${base}". Rename the export so each produces a distinct output.`
97
+ });
98
+ }
99
+ }
100
+
101
+ //#endregion
102
+ export { ambientOutName, assertNoEntryCollisions, classifyDtsExport, declarationExt, extractAmbientDts, mixedDtsExportError };
package/index.d.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { CatalogAssemblyError, CatalogResolutionError, ManifestLike, ManifestLike as ManifestLike$1 } from "workspaces-effect";
1
+ /**
2
+ * Find every relative module specifier in a declaration source: static `import`/`export … from`,
3
+ * `import("…")` type nodes, and `/// <reference path="…" />`. Pure parsing — no I/O.
4
+ *
5
+ * A non-empty result means the file is NOT self-contained and would break when copied verbatim to a
6
+ * flattened output location, so the ambient-copy step rejects it.
7
+ * @public
8
+ */import { CatalogAssemblyError, CatalogResolutionError, ManifestLike, ManifestLike as ManifestLike$1 } from "workspaces-effect";
2
9
  import { Plugin } from "rolldown";
3
10
  import { Context, Effect, Layer, Schema } from "effect";
4
11
  import { JsxEmit, ModuleDetectionKind, ModuleKind, ModuleResolutionKind, NewLineKind, ParsedCommandLine, ScriptTarget } from "typescript";
@@ -762,23 +769,156 @@ declare function nodeBuiltinDefaultInterop(): Plugin;
762
769
  */
763
770
  declare function removeDeclarationMaps(pkgDir: string): string[];
764
771
  //#endregion
772
+ //#region src/errors.d.ts
773
+ declare const MetaGenerationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
774
+ readonly _tag: "MetaGenerationError";
775
+ } & Readonly<A>;
776
+ /**
777
+ * API Extractor meta generation failed for an entry.
778
+ *
779
+ * @public
780
+ */
781
+ declare class MetaGenerationError extends MetaGenerationError_base<{
782
+ readonly entry: string;
783
+ readonly reason: string;
784
+ }> {
785
+ get message(): string;
786
+ }
787
+ declare const ConfigValidationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
788
+ readonly _tag: "ConfigValidationError";
789
+ } & Readonly<A>;
790
+ /**
791
+ * A savvy.build.ts or publishConfig.targets config is structurally invalid; raised before any build work.
792
+ *
793
+ * @public
794
+ */
795
+ declare class ConfigValidationError extends ConfigValidationError_base<{
796
+ readonly path: string;
797
+ readonly reason: string;
798
+ }> {
799
+ get message(): string;
800
+ }
801
+ //#endregion
802
+ //#region src/entry/extract.d.ts
803
+ /** @public */
804
+ interface PackageJsonLike {
805
+ readonly exports?: unknown;
806
+ readonly bin?: unknown;
807
+ }
808
+ /** @public */
809
+ interface ExtractOptions {
810
+ readonly exportsAsIndexes?: boolean | undefined;
811
+ /** Source paths to NOT turn into JS build entries (e.g. an exe entry compiled as a SEA). */
812
+ readonly excludeSources?: ReadonlyArray<string> | undefined;
813
+ }
814
+ /** @public */
815
+ interface ExtractResult {
816
+ /** entry name to TS source path */
817
+ readonly entries: Record<string, string>;
818
+ /** entry name to original export key (for downstream output-map alignment) */
819
+ readonly exportPaths: Record<string, string>;
820
+ }
821
+ /**
822
+ * Map an export key to the tsdown entry name (the emitted output basename).
823
+ *
824
+ * `.` becomes `index`; otherwise the leading `./` is stripped and, unless
825
+ * `exportsAsIndexes` is set, nested slashes are flattened to dashes
826
+ * (e.g. `./changesets/markdownlint` to `changesets-markdownlint`). The manifest
827
+ * transform reuses this so the declared output path always matches the emitted file.
828
+ *
829
+ * @internal
830
+ */
831
+ declare const createEntryName: (exportKey: string, exportsAsIndexes: boolean) => string;
832
+ /** @public */
833
+ declare function extractEntries(pkg: PackageJsonLike, options?: ExtractOptions): ExtractResult;
834
+ //#endregion
835
+ //#region src/entry/ambient-dts.d.ts
836
+ /** The declaration-file extension of a path, or undefined when it is not a declaration file. @public */
837
+ declare function declarationExt(p: string): ".d.ts" | ".d.cts" | ".d.mts" | undefined;
838
+ /** Classification of a single export value for ambient-.d.ts handling. @public */
839
+ type DtsExportClass = {
840
+ readonly kind: "ambient";
841
+ readonly source: string;
842
+ } | {
843
+ readonly kind: "mixed";
844
+ } | {
845
+ readonly kind: "none";
846
+ };
847
+ /**
848
+ * Classify an export value:
849
+ * - `ambient` — a types-only declaration source (bare `.d.ts` string, or `{ types: "*.d.ts" }` with no runtime source).
850
+ * - `mixed` — a declaration `types` AND a compilable runtime source (`import`/`require`/`default` → `.ts`/`.tsx`).
851
+ * - `none` — anything else (normal runtime export, json, etc.).
852
+ * @public
853
+ */
854
+ declare function classifyDtsExport(value: unknown): DtsExportClass;
855
+ /**
856
+ * Output basename (including the preserved declaration extension) for an ambient export, derived
857
+ * from the export KEY — consistent with how JS entries are named. @public
858
+ */
859
+ declare function ambientOutName(exportKey: string, source: string, exportsAsIndexes?: boolean): string;
860
+ /** The shared mixed-export error (Decision 2), used by both the extractor and the manifest transform. @public */
861
+ declare function mixedDtsExportError(exportKey: string): ConfigValidationError;
862
+ /** One ambient `.d.ts` export resolved for copy + manifest. @public */
863
+ interface AmbientDtsEntry {
864
+ readonly exportKey: string;
865
+ readonly source: string;
866
+ readonly outName: string;
867
+ }
868
+ /** @public */
869
+ interface ExtractAmbientOptions {
870
+ readonly exportsAsIndexes?: boolean | undefined;
871
+ }
872
+ /**
873
+ * Extract the types-only `.d.ts` exports from a package's `exports` map. Pure.
874
+ * Throws {@link ConfigValidationError} on a mixed export (Decision 2) or an ambient-vs-ambient
875
+ * output-name collision. @public
876
+ */
877
+ declare function extractAmbientDts(pkg: PackageJsonLike, options?: ExtractAmbientOptions): ReadonlyArray<AmbientDtsEntry>;
878
+ /**
879
+ * Throw {@link ConfigValidationError} if any ambient output name collides with a JS build-entry name.
880
+ * The JS entry names carry no extension, so each ambient `outName` is compared with its declaration
881
+ * extension stripped. @public
882
+ */
883
+ declare function assertNoEntryCollisions(jsEntryNames: ReadonlyArray<string>, ambient: ReadonlyArray<AmbientDtsEntry>): void;
884
+ //#endregion
765
885
  //#region src/build/sync-public.d.ts
766
886
  /**
767
- * Mirror `sourceDir` into `targetDir`, idempotently.
887
+ * Copy the CONTENTS of `sourceDir` into `outDir`, additively.
768
888
  *
769
- * Replaces tsdown's built-in `copy`, whose non-recursive mkdir throws `EEXIST` when the target
770
- * already exists (re-builds, `prepare`-on-install, concurrent turbo invocations). Behavior:
889
+ * Each `sourceDir/<rel>` copies to `outDir/<rel>` only the `public/` directory itself is dropped;
890
+ * the substructure under it is preserved (`public/tsconfig/ecma.json` becomes `<pkg>/tsconfig/ecma.json`,
891
+ * NOT `<pkg>/ecma.json`). The published manifest mirrors this drop via `transformExports`, which strips
892
+ * a leading `public/` from export values. This function NEVER deletes: `outDir` is the shared package
893
+ * root that the JS/dts passes own, so deleting "files not in source" would wipe the build product.
894
+ * Stale-asset pruning on a non-clean rebuild is therefore out of scope (a full build's `clean: true` handles it).
771
895
  *
772
- * - source absent: no-op.
773
- * - target absent: copy `sourceDir` wholesale.
774
- * - target present: copy only files that are new or whose bytes differ, then delete target files
775
- * that no longer exist in the source and prune the directories left empty.
896
+ * Collision guard: when a destination already exists, identical bytes mean a prior copy of the same
897
+ * asset (skipped); anything else differing bytes, a directory where a file is needed, or a file
898
+ * where a parent directory is needed means a built output occupies that path, so it throws
899
+ * {@link ConfigValidationError} rather than clobbering it or surfacing a raw fs error.
900
+ * @public
901
+ */
902
+ declare function copyPublicDir(sourceDir: string, outDir: string): void;
903
+ /** @public */
904
+ interface CopyAmbientDtsOptions {
905
+ /** The ambient exports to copy (from `extractAmbientDts`). */
906
+ readonly ambient: ReadonlyArray<AmbientDtsEntry>;
907
+ /** Package root the `source` paths are relative to. */
908
+ readonly srcCwd: string;
909
+ /** The built package dir to copy into (e.g. `dist/dev/pkg`). */
910
+ readonly outDir: string;
911
+ }
912
+ /**
913
+ * Copy each ambient `.d.ts` export's source verbatim into `outDir/<outName>`, byte-stable (an
914
+ * unchanged file keeps its timestamp). The copy is NOT compiled or bundled, so the build owns two
915
+ * fast-fail checks: the source must exist, and it must be self-contained — a relative
916
+ * import/export/reference would not resolve once the file is flattened to the package root.
776
917
  *
777
- * The byte-diff keeps unchanged files (and their timestamps) untouched, so a large copied asset
778
- * tree — e.g. the mcp markdown corpus — is not rewritten on every build.
918
+ * Throws {@link ConfigValidationError} on a missing source or any relative specifier.
779
919
  * @public
780
920
  */
781
- declare function syncPublicDir(sourceDir: string, targetDir: string): void;
921
+ declare function copyAmbientDts(options: CopyAmbientDtsOptions): void;
782
922
  //#endregion
783
923
  //#region src/catalog/resolve-catalogs.d.ts
784
924
  /**
@@ -817,36 +957,6 @@ interface NextVersions {
817
957
  */
818
958
  declare function resolveNextVersions(cwd: string): Promise<NextVersions>;
819
959
  //#endregion
820
- //#region src/errors.d.ts
821
- declare const MetaGenerationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
822
- readonly _tag: "MetaGenerationError";
823
- } & Readonly<A>;
824
- /**
825
- * API Extractor meta generation failed for an entry.
826
- *
827
- * @public
828
- */
829
- declare class MetaGenerationError extends MetaGenerationError_base<{
830
- readonly entry: string;
831
- readonly reason: string;
832
- }> {
833
- get message(): string;
834
- }
835
- declare const ConfigValidationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
836
- readonly _tag: "ConfigValidationError";
837
- } & Readonly<A>;
838
- /**
839
- * A savvy.build.ts or publishConfig.targets config is structurally invalid; raised before any build work.
840
- *
841
- * @public
842
- */
843
- declare class ConfigValidationError extends ConfigValidationError_base<{
844
- readonly path: string;
845
- readonly reason: string;
846
- }> {
847
- get message(): string;
848
- }
849
- //#endregion
850
960
  //#region src/exe/config.d.ts
851
961
  /**
852
962
  * Default Node runtime embedded in the SEA (parity with the vitest-agent reference).
@@ -1172,6 +1282,9 @@ declare function renderReexportStub(options: {
1172
1282
  readonly baseSpecifier: string;
1173
1283
  }): string;
1174
1284
  //#endregion
1285
+ //#region src/dts/relative-imports.d.ts
1286
+ declare function findRelativeSpecifiers(source: string, fileName?: string): string[];
1287
+ //#endregion
1175
1288
  //#region src/dts/resolved-tsconfig.d.ts
1176
1289
  /** @public */
1177
1290
  interface ResolvedTsconfigOptions {
@@ -1220,39 +1333,6 @@ declare function writeResolvedTsconfig(options: ResolvedTsconfigOptions): string
1220
1333
  */
1221
1334
  declare function writeDtsEmitTsconfig(resolvedTsconfigPath: string): string;
1222
1335
  //#endregion
1223
- //#region src/entry/extract.d.ts
1224
- /** @public */
1225
- interface PackageJsonLike {
1226
- readonly exports?: unknown;
1227
- readonly bin?: unknown;
1228
- }
1229
- /** @public */
1230
- interface ExtractOptions {
1231
- readonly exportsAsIndexes?: boolean | undefined;
1232
- /** Source paths to NOT turn into JS build entries (e.g. an exe entry compiled as a SEA). */
1233
- readonly excludeSources?: ReadonlyArray<string> | undefined;
1234
- }
1235
- /** @public */
1236
- interface ExtractResult {
1237
- /** entry name to TS source path */
1238
- readonly entries: Record<string, string>;
1239
- /** entry name to original export key (for downstream output-map alignment) */
1240
- readonly exportPaths: Record<string, string>;
1241
- }
1242
- /**
1243
- * Map an export key to the tsdown entry name (the emitted output basename).
1244
- *
1245
- * `.` becomes `index`; otherwise the leading `./` is stripped and, unless
1246
- * `exportsAsIndexes` is set, nested slashes are flattened to dashes
1247
- * (e.g. `./changesets/markdownlint` to `changesets-markdownlint`). The manifest
1248
- * transform reuses this so the declared output path always matches the emitted file.
1249
- *
1250
- * @internal
1251
- */
1252
- declare const createEntryName: (exportKey: string, exportsAsIndexes: boolean) => string;
1253
- /** @public */
1254
- declare function extractEntries(pkg: PackageJsonLike, options?: ExtractOptions): ExtractResult;
1255
- //#endregion
1256
1336
  //#region src/entry/package-json-entries.d.ts
1257
1337
  /** @public */
1258
1338
  interface PackageJsonEntriesOptions extends ExtractOptions {
@@ -1798,5 +1878,5 @@ declare function resolveTargets(options: {
1798
1878
  baseName: string;
1799
1879
  }): TargetResolution;
1800
1880
  //#endregion
1801
- export { BuildCollector, BuildCollectorTag, type BuildEmittedManifestOptions, type BuildFormat, type BuildGroupSpec, type BuildIssues, type BuildPlatform, type BuildReport, BuildReport as BuildReportSchema, type BuildTargetGroupsOptions, CatalogAssemblyError, CatalogResolutionError, CiAnnotationsFormatter, ConfigValidationError, ConfigValidator, ConfigValidatorLive, type CssOptions, DEFAULT_EXE_NODE_VERSION, type DeriveOptions, type DerivedTsdownOptions, type DiagnosticEntry, type DiagnosticInput, type DualExports, type EmitManifestOptions, type EmittedFile, type EntryOverride, type Environment, EnvironmentDetector, EnvironmentDetectorLive, type ExeBuild, type ExeConfig, type ExeRewrite, type ExeSeaConfig, type ExeTarget, type ExeTargetInput, type Executor, ExecutorResolver, ExecutorResolverLive, type ExtractOptions, type ExtractResult, FormatSelector, FormatSelectorLive, type Formatter, type FormatterContext, type GenerateMetaOptions, type Json, JsonFormatter, type JsxConfig, type LooseFileSpec, type LooseFiles, type ManifestLike, MarkdownFormatter, MetaGenerationError, type MetaOptions, type MetaResult, type ModuleExportNames, type NextVersions, type NormalizedExe, type NormalizedLooseFile, type NormalizedMeta, type OutputFormat, OutputRenderer, OutputRendererLive, type PackageJsonEntriesOptions, type PackageJsonLike, type PassKind, type PassReport, type PkgOsCpu, type PlainDiagnostic, type PortableTsconfig, type PublishTargetObject, type PublishTargetValue, type PublishTargets, type ReexportBarrelAnalysis, type RenderReportOptions, type RenderedOutput, ReportPipelineLive, ReportTimings, type ResolvedCompilerOptions, type ResolvedGroup, type ResolvedTarget, type ResolvedTsconfig, type ResolvedTsconfigOptions, type RunExeBuildOptions, type RunMetaPassOptions, SilentFormatter, type TargetGroupId, type TargetGroupRef, type TargetGroupReport, TargetGroupReport as TargetGroupReportSchema, type TargetResolution, TerminalFormatter, type Timer, type TransformManifestOptions, type TsconfigJsx, TsconfigResolver, type TsdocOptions, type TsdocTagDefinition, type TsdownBuild, type TsdownLogger, type ValidationInput, type WarningSuppressionRule, analyzeReexportBarrel, applySubdirMetaEntries, buildEmittedManifest, buildMetricsPlugin, buildResolvedTsconfig, buildTargetGroups, cjsDefaultInterop, collectExportNames, computeExeFileName, createEntryName, createTimer, createTsdownLogger, defaultManifestTransform, deriveExportPaths, deriveTargetGroupOptions, emitManifest, extractEntries, flattenIssues, formatTime, generateMeta, isTargetObject, nodeBuiltinDefaultInterop, normalizeBinPaths, normalizeExeOptions, normalizeLooseFiles, normalizeMetaOptions, packageJsonEntries, readTsconfigJsx, removeDeclarationMaps, renderReexportStub, renderReport, resolveJsxConfig, resolveManifest, resolveNextVersions, resolvePortableTsconfig, resolveTargets, rewriteMetaVersions, runExeBuild, runMetaPass, serializeIssues, syncPublicDir, transformBin, transformExports, transformManifest, writeDtsEmitTsconfig, writeIssuesArtifact, writeResolvedTsconfig, writeTargetsBinding };
1881
+ export { type AmbientDtsEntry, BuildCollector, BuildCollectorTag, type BuildEmittedManifestOptions, type BuildFormat, type BuildGroupSpec, type BuildIssues, type BuildPlatform, type BuildReport, BuildReport as BuildReportSchema, type BuildTargetGroupsOptions, CatalogAssemblyError, CatalogResolutionError, CiAnnotationsFormatter, ConfigValidationError, ConfigValidator, ConfigValidatorLive, type CopyAmbientDtsOptions, type CssOptions, DEFAULT_EXE_NODE_VERSION, type DeriveOptions, type DerivedTsdownOptions, type DiagnosticEntry, type DiagnosticInput, type DtsExportClass, type DualExports, type EmitManifestOptions, type EmittedFile, type EntryOverride, type Environment, EnvironmentDetector, EnvironmentDetectorLive, type ExeBuild, type ExeConfig, type ExeRewrite, type ExeSeaConfig, type ExeTarget, type ExeTargetInput, type Executor, ExecutorResolver, ExecutorResolverLive, type ExtractAmbientOptions, type ExtractOptions, type ExtractResult, FormatSelector, FormatSelectorLive, type Formatter, type FormatterContext, type GenerateMetaOptions, type Json, JsonFormatter, type JsxConfig, type LooseFileSpec, type LooseFiles, type ManifestLike, MarkdownFormatter, MetaGenerationError, type MetaOptions, type MetaResult, type ModuleExportNames, type NextVersions, type NormalizedExe, type NormalizedLooseFile, type NormalizedMeta, type OutputFormat, OutputRenderer, OutputRendererLive, type PackageJsonEntriesOptions, type PackageJsonLike, type PassKind, type PassReport, type PkgOsCpu, type PlainDiagnostic, type PortableTsconfig, type PublishTargetObject, type PublishTargetValue, type PublishTargets, type ReexportBarrelAnalysis, type RenderReportOptions, type RenderedOutput, ReportPipelineLive, ReportTimings, type ResolvedCompilerOptions, type ResolvedGroup, type ResolvedTarget, type ResolvedTsconfig, type ResolvedTsconfigOptions, type RunExeBuildOptions, type RunMetaPassOptions, SilentFormatter, type TargetGroupId, type TargetGroupRef, type TargetGroupReport, TargetGroupReport as TargetGroupReportSchema, type TargetResolution, TerminalFormatter, type Timer, type TransformManifestOptions, type TsconfigJsx, TsconfigResolver, type TsdocOptions, type TsdocTagDefinition, type TsdownBuild, type TsdownLogger, type ValidationInput, type WarningSuppressionRule, ambientOutName, analyzeReexportBarrel, applySubdirMetaEntries, assertNoEntryCollisions, buildEmittedManifest, buildMetricsPlugin, buildResolvedTsconfig, buildTargetGroups, cjsDefaultInterop, classifyDtsExport, collectExportNames, computeExeFileName, copyAmbientDts, copyPublicDir, createEntryName, createTimer, createTsdownLogger, declarationExt, defaultManifestTransform, deriveExportPaths, deriveTargetGroupOptions, emitManifest, extractAmbientDts, extractEntries, findRelativeSpecifiers, flattenIssues, formatTime, generateMeta, isTargetObject, mixedDtsExportError, nodeBuiltinDefaultInterop, normalizeBinPaths, normalizeExeOptions, normalizeLooseFiles, normalizeMetaOptions, packageJsonEntries, readTsconfigJsx, removeDeclarationMaps, renderReexportStub, renderReport, resolveJsxConfig, resolveManifest, resolveNextVersions, resolvePortableTsconfig, resolveTargets, rewriteMetaVersions, runExeBuild, runMetaPass, serializeIssues, transformBin, transformExports, transformManifest, writeDtsEmitTsconfig, writeIssuesArtifact, writeResolvedTsconfig, writeTargetsBinding };
1802
1882
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { analyzeReexportBarrel, collectExportNames, renderReexportStub } from "./dts/reexport-stub.js";
2
2
  import { buildResolvedTsconfig, writeDtsEmitTsconfig, writeResolvedTsconfig } from "./dts/resolved-tsconfig.js";
3
3
  import { resolveManifest } from "./catalog/resolve-catalogs.js";
4
+ import { ConfigValidationError, MetaGenerationError } from "./errors.js";
4
5
  import { createEntryName, extractEntries } from "./entry/extract.js";
6
+ import { ambientOutName, assertNoEntryCollisions, classifyDtsExport, declarationExt, extractAmbientDts, mixedDtsExportError } from "./entry/ambient-dts.js";
5
7
  import { defaultManifestTransform, normalizeBinPaths, transformBin, transformExports, transformManifest } from "./manifest/transform.js";
6
8
  import { buildEmittedManifest, emitManifest } from "./manifest/emit-manifest.js";
7
9
  import { buildMetricsPlugin } from "./report/metrics-plugin.js";
@@ -9,10 +11,10 @@ import { createTimer, formatTime } from "./report/timer.js";
9
11
  import { createTsdownLogger } from "./report/tsdown-logger.js";
10
12
  import { cjsDefaultInterop } from "./build/cjs-default-interop.js";
11
13
  import { nodeBuiltinDefaultInterop } from "./build/node-builtin-default-interop.js";
12
- import { syncPublicDir } from "./build/sync-public.js";
14
+ import { findRelativeSpecifiers } from "./dts/relative-imports.js";
15
+ import { copyAmbientDts, copyPublicDir } from "./build/sync-public.js";
13
16
  import { deriveTargetGroupOptions } from "./build/target-groups.js";
14
17
  import { buildTargetGroups } from "./build/build-target-groups.js";
15
- import { ConfigValidationError, MetaGenerationError } from "./errors.js";
16
18
  import { normalizeLooseFiles } from "./build/loose-files.js";
17
19
  import { removeDeclarationMaps } from "./build/strip-maps.js";
18
20
  import { resolveNextVersions } from "./changesets/next-versions.js";
@@ -50,4 +52,4 @@ import { ReportPipelineLive, renderReport } from "./report/pipeline.js";
50
52
  import { writeTargetsBinding } from "./targets/binding.js";
51
53
  import { CatalogAssemblyError, CatalogResolutionError } from "workspaces-effect";
52
54
 
53
- export { BuildCollector, BuildCollectorTag, BuildReport as BuildReportSchema, CatalogAssemblyError, CatalogResolutionError, CiAnnotationsFormatter, ConfigValidationError, ConfigValidator, ConfigValidatorLive, DEFAULT_EXE_NODE_VERSION, EnvironmentDetector, EnvironmentDetectorLive, ExecutorResolver, ExecutorResolverLive, FormatSelector, FormatSelectorLive, JsonFormatter, MarkdownFormatter, MetaGenerationError, OutputRenderer, OutputRendererLive, ReportPipelineLive, ReportTimings, SilentFormatter, TargetGroupReport as TargetGroupReportSchema, TerminalFormatter, TsconfigResolver, analyzeReexportBarrel, applySubdirMetaEntries, buildEmittedManifest, buildMetricsPlugin, buildResolvedTsconfig, buildTargetGroups, cjsDefaultInterop, collectExportNames, computeExeFileName, createEntryName, createTimer, createTsdownLogger, defaultManifestTransform, deriveExportPaths, deriveTargetGroupOptions, emitManifest, extractEntries, flattenIssues, formatTime, generateMeta, isTargetObject, nodeBuiltinDefaultInterop, normalizeBinPaths, normalizeExeOptions, normalizeLooseFiles, normalizeMetaOptions, packageJsonEntries, readTsconfigJsx, removeDeclarationMaps, renderReexportStub, renderReport, resolveJsxConfig, resolveManifest, resolveNextVersions, resolvePortableTsconfig, resolveTargets, rewriteMetaVersions, runExeBuild, runMetaPass, serializeIssues, syncPublicDir, transformBin, transformExports, transformManifest, writeDtsEmitTsconfig, writeIssuesArtifact, writeResolvedTsconfig, writeTargetsBinding };
55
+ export { BuildCollector, BuildCollectorTag, BuildReport as BuildReportSchema, CatalogAssemblyError, CatalogResolutionError, CiAnnotationsFormatter, ConfigValidationError, ConfigValidator, ConfigValidatorLive, DEFAULT_EXE_NODE_VERSION, EnvironmentDetector, EnvironmentDetectorLive, ExecutorResolver, ExecutorResolverLive, FormatSelector, FormatSelectorLive, JsonFormatter, MarkdownFormatter, MetaGenerationError, OutputRenderer, OutputRendererLive, ReportPipelineLive, ReportTimings, SilentFormatter, TargetGroupReport as TargetGroupReportSchema, TerminalFormatter, TsconfigResolver, ambientOutName, analyzeReexportBarrel, applySubdirMetaEntries, assertNoEntryCollisions, buildEmittedManifest, buildMetricsPlugin, buildResolvedTsconfig, buildTargetGroups, cjsDefaultInterop, classifyDtsExport, collectExportNames, computeExeFileName, copyAmbientDts, copyPublicDir, createEntryName, createTimer, createTsdownLogger, declarationExt, defaultManifestTransform, deriveExportPaths, deriveTargetGroupOptions, emitManifest, extractAmbientDts, extractEntries, findRelativeSpecifiers, flattenIssues, formatTime, generateMeta, isTargetObject, mixedDtsExportError, nodeBuiltinDefaultInterop, normalizeBinPaths, normalizeExeOptions, normalizeLooseFiles, normalizeMetaOptions, packageJsonEntries, readTsconfigJsx, removeDeclarationMaps, renderReexportStub, renderReport, resolveJsxConfig, resolveManifest, resolveNextVersions, resolvePortableTsconfig, resolveTargets, rewriteMetaVersions, runExeBuild, runMetaPass, serializeIssues, transformBin, transformExports, transformManifest, writeDtsEmitTsconfig, writeIssuesArtifact, writeResolvedTsconfig, writeTargetsBinding };
@@ -1,4 +1,5 @@
1
1
  import { createEntryName } from "../entry/extract.js";
2
+ import { ambientOutName, classifyDtsExport, mixedDtsExportError } from "../entry/ambient-dts.js";
2
3
  import sortPackageJson from "sort-package-json";
3
4
 
4
5
  //#region src/manifest/transform.ts
@@ -83,6 +84,16 @@ const tsConditions = (exportKey, dual, subdirExports) => ({
83
84
  ...dual ? { require: toBuiltCjs(exportKey, subdirExports) } : {}
84
85
  });
85
86
  /**
87
+ * A `./public/<path>` export value points into the staged `public/` dir, whose CONTENTS `copyPublicDir`
88
+ * copies into the package root (only the `public/` segment is dropped; substructure is kept). So the
89
+ * published manifest value drops that same `public/` segment: `./public/tsconfig/ecma.json` becomes
90
+ * `./tsconfig/ecma.json`, resolving the file copyPublicDir placed at `<pkg>/tsconfig/ecma.json`.
91
+ * Non-strings and non-public values pass through unchanged.
92
+ */
93
+ function stripPublicPrefix(value) {
94
+ return typeof value === "string" && value.startsWith("./public/") ? `./${value.slice(9)}` : value;
95
+ }
96
+ /**
86
97
  * Rewrite an exports map: TS string targets become a types/import conditions object.
87
98
  * Each TS condition also gets a `require` entry when `dual` is `true` (uniform) or when
88
99
  * the export key is in the `dual` Set (per-entry).
@@ -95,12 +106,18 @@ const tsConditions = (exportKey, dual, subdirExports) => ({
95
106
  * @public
96
107
  */
97
108
  function transformExports(exports, dual = false, subdirExports) {
98
- if (typeof exports === "string") return isTs(exports) ? tsConditions(".", isDualKey(dual, "."), subdirExports) : exports;
109
+ if (typeof exports === "string") return isTs(exports) ? tsConditions(".", isDualKey(dual, "."), subdirExports) : stripPublicPrefix(exports);
99
110
  if (exports && typeof exports === "object") {
100
111
  const out = {};
101
112
  for (const [key, value] of Object.entries(exports)) {
102
- if (key === "./package.json" || key.endsWith(".json")) {
103
- out[key] = value;
113
+ if (key === "./package.json" || key.endsWith(".json") || isDeclarationFile(key)) {
114
+ out[key] = stripPublicPrefix(value);
115
+ continue;
116
+ }
117
+ const cls = classifyDtsExport(value);
118
+ if (cls.kind === "mixed") throw mixedDtsExportError(key);
119
+ if (cls.kind === "ambient") {
120
+ out[key] = { types: `./${ambientOutName(key, cls.source)}` };
104
121
  continue;
105
122
  }
106
123
  if (typeof value === "string" && isTs(value)) out[key] = tsConditions(key, isDualKey(dual, key), subdirExports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/tsdown-plugins",
3
- "version": "0.12.0",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "description": "Interface-only tsdown/rolldown plugin pack powering @savvy-web/bundler",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/tsdown-plugins",