@savvy-web/tsdown-plugins 0.9.2 → 0.11.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.
@@ -5,7 +5,7 @@ import { createTsdownLogger } from "../report/tsdown-logger.js";
5
5
  import { cjsDefaultInterop } from "./cjs-default-interop.js";
6
6
  import { nodeBuiltinDefaultInterop } from "./node-builtin-default-interop.js";
7
7
  import { syncPublicDir } from "./sync-public.js";
8
- import { deriveDtsPassOptions, deriveTargetGroupOptions, outDirFor } from "./target-groups.js";
8
+ import { deriveDeclarationsPassOptions, deriveDtsPassOptions, deriveTargetGroupOptions, outDirFor } from "./target-groups.js";
9
9
  import { dirname, join } from "node:path";
10
10
 
11
11
  //#region src/build/build-target-groups.ts
@@ -80,6 +80,7 @@ async function buildTargetGroups(options) {
80
80
  };
81
81
  const js = deriveTargetGroupOptions(deriveInput);
82
82
  const dts = deriveDtsPassOptions(deriveInput);
83
+ const decl = deriveDeclarationsPassOptions(deriveInput);
83
84
  const partOutDir = part.outSubdir !== void 0 ? join(js.outDir, part.outSubdir) : js.outDir;
84
85
  const targetGroup = {
85
86
  id: group.id,
@@ -165,6 +166,44 @@ async function buildTargetGroups(options) {
165
166
  ...metricsPlugins(group.id, "dts")
166
167
  ]
167
168
  }));
169
+ if (js.isProd && options.emitDeclarations === true && Object.keys(decl.entry).length > 0) {
170
+ const partDeclDir = part.outSubdir !== void 0 ? join(decl.outDir, part.outSubdir) : decl.outDir;
171
+ try {
172
+ await build({
173
+ config: false,
174
+ cwd: options.cwd,
175
+ entry: decl.entry,
176
+ outDir: partDeclDir,
177
+ format: decl.format,
178
+ platform: decl.platform,
179
+ sourcemap: decl.sourcemap,
180
+ unbundle: decl.unbundle,
181
+ clean: isBase,
182
+ fixedExtension: decl.fixedExtension,
183
+ dts: decl.dts,
184
+ define: decl.define,
185
+ logLevel: "silent",
186
+ ...dtsNeverBundle.length > 0 || partBundleNodeModules || decl.bundledPackages ? { deps: {
187
+ ...dtsNeverBundle.length > 0 ? { neverBundle: dtsNeverBundle } : {},
188
+ ...partBundleNodeModules ? {
189
+ skipNodeModulesBundle: false,
190
+ ...decl.bundledPackages ? { dts: { alwaysBundle: decl.bundledPackages } } : {}
191
+ } : decl.bundledPackages ? {
192
+ skipNodeModulesBundle: true,
193
+ dts: { alwaysBundle: decl.bundledPackages }
194
+ } : {}
195
+ } } : {},
196
+ ...decl.jsx !== void 0 ? { inputOptions: { jsx: decl.jsx } } : {},
197
+ ...options.extraPlugins !== void 0 ? { plugins: [...options.extraPlugins] } : {}
198
+ });
199
+ } catch (err) {
200
+ collector?.recordWarning(group.id, {
201
+ source: "tsdown",
202
+ level: "warn",
203
+ text: `Could not emit per-module declarations for API Extractor diagnostics: ${String(err)}`
204
+ });
205
+ }
206
+ }
168
207
  }
169
208
  const looseOutDir = outDirFor(options.cwd, group.id);
170
209
  const isProdGroup = group.id !== "dev";
@@ -4,6 +4,12 @@ import { join } from "node:path";
4
4
  /** The output dir for a group: dev -> dist/dev/pkg, prod -> dist/prod/<group>/pkg. */
5
5
  const outDirFor = (cwd, group) => group === "dev" ? join(cwd, "dist/dev/pkg") : join(cwd, "dist/prod", group, "pkg");
6
6
  /**
7
+ * Per-module declarations dir for a prod group: a sibling of `pkg/`, NOT published, used only as
8
+ * API Extractor's diagnostics-run input so locations resolve to per-file source. Kept (not stripped)
9
+ * as a cacheable, inspectable build artifact (issue #162).
10
+ */
11
+ const declarationsDirFor = (cwd, group) => join(cwd, "dist/prod", group, "declarations");
12
+ /**
7
13
  * Derive the JS-pass tsdown options for one TargetGroup (per-module JS, no dts).
8
14
  *
9
15
  * @public
@@ -59,6 +65,35 @@ function deriveDtsPassOptions(options) {
59
65
  ...options.bundledPackages !== void 0 ? { bundledPackages: options.bundledPackages } : {}
60
66
  };
61
67
  }
68
+ /**
69
+ * Derive the per-module declarations pass (API Extractor diagnostics input). Mirrors
70
+ * {@link deriveDtsPassOptions} but `unbundle: true` (preserveModules → 1:1 source positions),
71
+ * esm-only (AE reads the `.d.ts` entry only), into the `declarations/` sibling dir. Bin entries
72
+ * are dropped (no exports).
73
+ * @internal
74
+ */
75
+ function deriveDeclarationsPassOptions(options) {
76
+ const entry = Object.fromEntries(Object.entries(options.entry).filter(([name]) => !name.startsWith("bin/")));
77
+ return {
78
+ outDir: declarationsDirFor(options.cwd, options.group),
79
+ sourcemap: false,
80
+ format: ["esm"],
81
+ unbundle: true,
82
+ platform: "node",
83
+ fixedExtension: false,
84
+ entry,
85
+ dts: {
86
+ tsconfig: options.tsconfigPath,
87
+ emitDtsOnly: true
88
+ },
89
+ define: {
90
+ "process.env.__PACKAGE_VERSION__": JSON.stringify(options.version),
91
+ ...options.define
92
+ },
93
+ ...options.bundledPackages !== void 0 ? { bundledPackages: options.bundledPackages } : {},
94
+ ...options.jsx !== void 0 ? { jsx: options.jsx } : {}
95
+ };
96
+ }
62
97
 
63
98
  //#endregion
64
- export { deriveDtsPassOptions, deriveTargetGroupOptions, outDirFor };
99
+ export { declarationsDirFor, deriveDeclarationsPassOptions, deriveDtsPassOptions, deriveTargetGroupOptions, outDirFor };
package/index.d.ts CHANGED
@@ -689,6 +689,13 @@ interface BuildTargetGroupsOptions {
689
689
  readonly collector?: BuildCollector | undefined;
690
690
  /** Compute gzip sizes for emitted files (verbose render). Forwarded to the metrics plugin. */
691
691
  readonly verbose?: boolean | undefined;
692
+ /**
693
+ * Emit a per-module (`unbundle: true`) declaration tree into `dist/prod/<id>/declarations/` per
694
+ * group, in addition to the bundled dts in `pkg/`. API Extractor's diagnostics-run input for the
695
+ * meta pass. Prod-only; the bundler sets it for `--target prod`. Absent → no third pass
696
+ * (byte-identical to the two-pass default). Not captured by the collector.
697
+ */
698
+ readonly emitDeclarations?: boolean | undefined;
692
699
  }
693
700
  /**
694
701
  * Run tsdown.build() per TargetGroup. Composable so the escape hatch gets multi-group too.
@@ -1273,6 +1280,13 @@ interface GenerateMetaOptions {
1273
1280
  readonly tsconfigPath: string;
1274
1281
  /** Directory holding the tsdown-emitted per-file .d.ts (e.g. dist/dev/pkg). */
1275
1282
  readonly dtsDir: string;
1283
+ /**
1284
+ * Per-module declarations dir used ONLY for a second, diagnostics-only API Extractor run that
1285
+ * resolves accurate per-file source locations. The shipped api-model is still produced from
1286
+ * `dtsDir` (the bundled dts), so it is unchanged. When omitted or equal to `dtsDir`, a single
1287
+ * run over `dtsDir` produces both model and diagnostics (legacy behavior).
1288
+ */
1289
+ readonly aeInputDir?: string | undefined;
1276
1290
  /** Map of entry name to the .d.ts basename (without extension) inside dtsDir. */
1277
1291
  readonly entries: Record<string, string>;
1278
1292
  /** Map of entry name to its export path (".", "./sub"). */
@@ -12,12 +12,9 @@ const CI_FATAL_MESSAGE_IDS = /* @__PURE__ */ new Set(["ae-forgotten-export"]);
12
12
  /**
13
13
  * Map an API Extractor message to a collector DiagnosticInput, or undefined if not warn/error.
14
14
  *
15
- * The location (`file`/`line`/`column`) is deliberately dropped. API Extractor analyzes the
16
- * bundled `.d.ts` and maps positions back through its `.d.ts.map` source map, which anchors
17
- * every message to the start of an adjacent declaration rather than the symbol's true source
18
- * position — wrong for release-tag codes, unresolved links, and the rest alike. A misleading
19
- * `file:line` is worse than none, so it is omitted; the authoritative locator is the symbol
20
- * name quoted in `text`. See systems#154.
15
+ * Location (`file`/`line`/`column`) is preserved: diagnostics now come from a per-module declaration
16
+ * run (see generateMeta's two-input split), where each `.d.ts.map` references only its own source, so
17
+ * positions resolve to the true source declaration. Reverts the systems#154 mitigation. See systems#162.
21
18
  */
22
19
  function mapExtractorMessage(message) {
23
20
  const isError = message.logLevel === ExtractorLogLevel.Error;
@@ -27,7 +24,10 @@ function mapExtractorMessage(message) {
27
24
  source: "api-extractor",
28
25
  level: isError ? "error" : "warn",
29
26
  text: message.text,
30
- ...message.messageId !== void 0 ? { code: message.messageId } : {}
27
+ ...message.messageId !== void 0 ? { code: message.messageId } : {},
28
+ ...message.sourceFilePath !== void 0 ? { file: message.sourceFilePath } : {},
29
+ ...message.sourceFileLine !== void 0 ? { line: message.sourceFileLine } : {},
30
+ ...message.sourceFileColumn !== void 0 ? { column: message.sourceFileColumn } : {}
31
31
  };
32
32
  }
33
33
  /** Run API Extractor over a single entry's .d.ts, writing the .api.json (and optionally tsdoc-metadata.json). Throws on failure. */
@@ -41,7 +41,7 @@ function runApiExtractor(options) {
41
41
  mainEntryPointFilePath: options.entryDtsPath,
42
42
  enumMemberOrder: "preserve",
43
43
  compiler: { tsconfigFilePath: options.tsconfigPath },
44
- docModel: {
44
+ docModel: options.emitDocModel === false ? { enabled: false } : {
45
45
  enabled: true,
46
46
  apiJsonFilePath: options.apiJsonPath
47
47
  },
package/meta/generate.js CHANGED
@@ -20,6 +20,8 @@ function unscopedName(name) {
20
20
  */
21
21
  async function generateMeta(options) {
22
22
  const { cwd, packageName, tsconfigPath, dtsDir, entries, exportPaths, outMetaDir, localPaths, tsdoc, manifestTransform, onMessage, ci, onSuppressed } = options;
23
+ const aeInputDir = options.aeInputDir ?? dtsDir;
24
+ const splitDiagnostics = aeInputDir !== dtsDir;
23
25
  const tsdocConfigPath = writeTsdocConfig(cwd, tsdoc);
24
26
  const packageJsonPath = join(cwd, "package.json");
25
27
  mkdirSync(outMetaDir, { recursive: true });
@@ -29,24 +31,72 @@ async function generateMeta(options) {
29
31
  const perEntryModels = /* @__PURE__ */ new Map();
30
32
  const intermediateApiJsons = [];
31
33
  let mainEntryDidTsdocMetadata = false;
34
+ const diagnosticKey = (e) => `${e.code ?? ""}\0${e.text}`;
35
+ const stripLocation = (e) => ({
36
+ source: e.source,
37
+ level: e.level,
38
+ text: e.text,
39
+ ...e.code !== void 0 ? { code: e.code } : {},
40
+ ...e.ciFatal !== void 0 ? { ciFatal: e.ciFatal } : {}
41
+ });
32
42
  for (const entryName of entryNames) {
33
- const entryDtsPath = join(dtsDir, `${entries[entryName]}.d.ts`);
43
+ const modelEntryDts = join(dtsDir, `${entries[entryName]}.d.ts`);
34
44
  const perEntryApiJson = join(outMetaDir, `${entryName.replace(/[\\/]/g, "__")}.entry.api.json`);
35
45
  intermediateApiJsons.push(perEntryApiJson);
36
46
  const isMain = (exportPaths[entryName] ?? (entryName === "index" ? "." : `./${entryName}`)) === ".";
47
+ const captureRollupOnlyFatal = splitDiagnostics && onMessage !== void 0 && ci !== true;
48
+ const rollupFatal = [];
49
+ const runBReported = /* @__PURE__ */ new Set();
50
+ const runAOnMessage = captureRollupOnlyFatal ? (entry) => {
51
+ if (entry.ciFatal === true) rollupFatal.push(entry);
52
+ } : ci === true && onMessage !== void 0 ? (entry) => {
53
+ if (entry.level === "error") onMessage(stripLocation(entry));
54
+ } : () => {};
37
55
  runApiExtractor({
38
56
  cwd,
39
57
  packageJsonPath,
40
- entryDtsPath,
58
+ entryDtsPath: modelEntryDts,
41
59
  tsconfigPath,
42
60
  tsdocConfigPath,
43
61
  apiJsonPath: perEntryApiJson,
44
62
  ...isMain && !mainEntryDidTsdocMetadata ? { tsdocMetadataPath } : {},
45
63
  suppressWarnings: tsdoc.suppressWarnings,
46
64
  ...ci !== void 0 ? { ci } : {},
47
- ...onMessage !== void 0 ? { onMessage } : {},
48
- ...onSuppressed !== void 0 ? { onSuppressed } : {}
65
+ ...splitDiagnostics ? { onMessage: runAOnMessage } : {
66
+ ...onMessage !== void 0 ? { onMessage } : {},
67
+ ...onSuppressed !== void 0 ? { onSuppressed } : {}
68
+ }
49
69
  });
70
+ if (splitDiagnostics) {
71
+ const runBOnMessage = captureRollupOnlyFatal && onMessage !== void 0 ? (entry) => {
72
+ if (entry.ciFatal === true) runBReported.add(diagnosticKey(entry));
73
+ onMessage(entry);
74
+ } : onMessage;
75
+ try {
76
+ runApiExtractor({
77
+ cwd,
78
+ packageJsonPath,
79
+ entryDtsPath: join(aeInputDir, `${entries[entryName]}.d.ts`),
80
+ tsconfigPath,
81
+ tsdocConfigPath,
82
+ apiJsonPath: perEntryApiJson,
83
+ emitDocModel: false,
84
+ suppressWarnings: tsdoc.suppressWarnings,
85
+ ...runBOnMessage !== void 0 ? { onMessage: runBOnMessage } : {},
86
+ ...onSuppressed !== void 0 ? { onSuppressed } : {}
87
+ });
88
+ } catch (err) {
89
+ if (onMessage !== void 0) onMessage({
90
+ source: "api-extractor",
91
+ level: "warn",
92
+ text: `Could not harvest per-module source locations for "${entryName}": ${String(err)}`
93
+ });
94
+ }
95
+ if (captureRollupOnlyFatal && onMessage !== void 0) for (const entry of rollupFatal) {
96
+ if (runBReported.has(diagnosticKey(entry))) continue;
97
+ onMessage(stripLocation(entry));
98
+ }
99
+ }
50
100
  if (isMain) mainEntryDidTsdocMetadata = true;
51
101
  perEntryModels.set(entryName, JSON.parse(readFileSync(perEntryApiJson, "utf-8")));
52
102
  }
package/meta/run-pass.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createEntryName } from "../entry/extract.js";
2
+ import { declarationsDirFor } from "../build/target-groups.js";
2
3
  import { resolveNextVersions } from "../changesets/next-versions.js";
3
4
  import { normalizeMetaOptions } from "./config.js";
4
5
  import { generateMeta } from "./generate.js";
@@ -31,6 +32,7 @@ async function runMetaPass(o) {
31
32
  packageName: o.packageName,
32
33
  tsconfigPath: o.tsconfigPath,
33
34
  dtsDir: join(o.cwd, "dist", "prod", g.id, "pkg"),
35
+ aeInputDir: declarationsDirFor(o.cwd, g.id),
34
36
  entries: dtsBasenames,
35
37
  exportPaths,
36
38
  outMetaDir: join(o.cwd, "dist", "prod", g.id, "meta"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/tsdown-plugins",
3
- "version": "0.9.2",
3
+ "version": "0.11.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",