@dbx-tools/projen 0.3.44 → 0.4.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/package.json CHANGED
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "main": "index.ts",
34
34
  "license": "Apache-2.0",
35
- "version": "0.3.44",
35
+ "version": "0.4.0",
36
36
  "types": "index.ts",
37
37
  "type": "module",
38
38
  "exports": {
package/src/project.ts CHANGED
@@ -18,6 +18,7 @@ import { generateCodegen } from "./codegen";
18
18
  import { DBXToolsConfig, type DBXToolsConfigOptions } from "./dbx-tools-config";
19
19
  import { resolvePkgRoot } from "./engine-root";
20
20
  import { PnpmWorkspaceState, type DBXToolsPNPMWorkspaceOptions } from "./pnpm-workspace";
21
+ import { applyCompiledPublish } from "./publish";
21
22
  import { DBXToolsRelease, type StandaloneRelease } from "./release";
22
23
  import { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS, type PackageTag } from "./tags";
23
24
  import { DBXToolsRootTsconfig } from "./tsconfig";
@@ -234,10 +235,15 @@ export function addExports(pkg: javascript.NodeProject, exports: Record<string,
234
235
  * and `LICENSE` on top of whatever is listed, so those are never declared here.
235
236
  *
236
237
  * The baseline (`index.ts` + `src`, set at construction) is the source-first
237
- * entry surface the `exports` map actually resolves to. A tag adds what its own
238
- * layout ships outside `src` - the `cli` tag its `bin/` launchers. Everything
239
- * else the build leaves behind (`lib/`, `test/`, `.projen/`, `tsconfig*`) is
240
- * unreachable through `exports` and is deliberately withheld.
238
+ * entry surface the workspace's own `exports` map resolves to. A tag adds what
239
+ * its layout ships outside `src` - the `cli` tag its `bin/` launchers - and
240
+ * {@link applyCompiledPublish} adds `lib/`, which is what the PUBLISHED
241
+ * `exports` resolves to. Source ships alongside the compiled output rather than
242
+ * instead of it: it costs little, and it keeps stack traces and go-to-definition
243
+ * landing on real code for consumers that want it.
244
+ *
245
+ * Everything else the build leaves behind (`test/`, `.projen/`, `tsconfig*`) is
246
+ * unreachable through either map and is deliberately withheld.
241
247
  */
242
248
  export function addPackageFiles(pkg: javascript.NodeProject, ...entries: string[]): void {
243
249
  const current = (pkg.package.manifest.files ?? []) as string[];
@@ -994,6 +1000,10 @@ function preSynthesizeProject(project: javascript.NodeProject): void {
994
1000
  }
995
1001
  for (const p of subtree) {
996
1002
  if (!p.parent) continue;
1003
+ // Swap the source entry points for compiled ones in the PUBLISHED manifest
1004
+ // only. Runs here rather than in the constructor so the tags have already
1005
+ // installed their `exports` layouts for it to mirror.
1006
+ if (p instanceof javascript.NodeProject) applyCompiledPublish(p);
997
1007
  // A child's `.gitignore` survives ONLY when it carries custom patterns (see
998
1008
  // swapChildGitignore). `.gitattributes` is always dropped - the root's
999
1009
  // annotateGenerated globs cover the children. Runs once from the root's
package/src/publish.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The compiled publish surface: what a package looks like on npm, as opposed to
3
+ * what it looks like inside this workspace.
4
+ *
5
+ * Packages resolve each other from SOURCE - every `exports` entry points at a
6
+ * `.ts` file, so a cross-package import type-checks with no build step and no
7
+ * `dist` to keep in sync. That property is worth keeping, but it cannot be what
8
+ * ships: Node refuses to strip types under `node_modules`
9
+ * (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so a published package whose
10
+ * entry point is `index.ts` is unloadable by anything that is not a bundler.
11
+ * Consumers papered over that by special-casing `@dbx-tools/*` into their own
12
+ * bundle, which is a tax this repo has no business charging.
13
+ *
14
+ * pnpm resolves both at once. It substitutes `publishConfig`'s `main`/`types`/
15
+ * `exports` into the manifest at pack time and drops `publishConfig` itself, so
16
+ * the workspace keeps its source entry points while the tarball advertises the
17
+ * compiled ones. Nothing here changes how the repo builds or type-checks; it
18
+ * only changes what `pnpm pack` writes.
19
+ *
20
+ * Two pieces make the emitted tree actually loadable:
21
+ *
22
+ * - `rootDir: "."` so the package-ROOT `index.ts` barrel is compiled at all.
23
+ * projen's default `rootDir: "src"` puts the barrel outside the compilation,
24
+ * which is why `lib/` has never had an `index.js`.
25
+ * - a specifier pass after `tsc`, because `tsc` never rewrites import paths.
26
+ * Sources are written for `moduleResolution: bundler` and so import `"./http"`,
27
+ * which Node cannot resolve; `tasks/emit.ts` appends the extension that the
28
+ * emitted file actually has. Doing it after the fact keeps 800-odd import
29
+ * sites free of the `.js` suffix that would otherwise have to be written - and
30
+ * maintained - by hand.
31
+ *
32
+ * UI packages are deliberately excluded (see {@link publishesCompiled}).
33
+ */
34
+ import type { javascript } from "projen";
35
+ import { typescript } from "projen";
36
+ import { addPackageFiles, applyCompilerOptions, applyIncludes, taskScript } from "./project";
37
+ import { isDBXToolsProject } from "./project-predicate";
38
+
39
+ /** Directory `tsc` emits into, and the root of every published entry point. */
40
+ export const COMPILED_DIR = "lib";
41
+
42
+ /** An `exports` target written as TypeScript source, i.e. one with a compiled twin. */
43
+ const TS_SOURCE = /\.tsx?$/;
44
+
45
+ /**
46
+ * Whether a package publishes compiled output rather than source.
47
+ *
48
+ * Everything does EXCEPT the `ui` tag, and the exclusion is about consumers
49
+ * rather than convenience. The problem being solved is that Node cannot load raw
50
+ * TypeScript - but a browser package is never loaded by Node. UI packages reach
51
+ * their consumer through Vite, which reads their source happily, and they export
52
+ * `./styles.css` plus raw SVG assets that `tsc` does not copy and could not
53
+ * rewrite. Compiling them would mean a real asset pipeline (Vite library mode)
54
+ * to solve a problem they do not have.
55
+ *
56
+ * This is the same split the downstream app build already makes on its own: its
57
+ * client bundle resolves `@dbx-tools/ui-*` from source without complaint, while
58
+ * its SERVER bundle is the one that had to inline `@dbx-tools/*` to avoid
59
+ * handing Node a `.ts` entry point.
60
+ */
61
+ export function publishesCompiled(pkg: javascript.NodeProject): boolean {
62
+ if (!(pkg instanceof typescript.TypeScriptProject) || !pkg.parent) return false;
63
+ return isDBXToolsProject(pkg) && !pkg.dbxToolsConfig.tags.includes("ui");
64
+ }
65
+
66
+ /**
67
+ * The compiled counterpart of a source `exports` target, or `undefined` for a
68
+ * target that ships as-is (`./package.json`, a stylesheet, an SVG asset).
69
+ *
70
+ * `rootDir: "."` means the emitted tree mirrors the package layout, so the
71
+ * mapping is positional: `./src/react/index.ts` -> `./lib/src/react/index.js`.
72
+ */
73
+ function compiledTarget(target: string): { types: string; default: string } | undefined {
74
+ if (!TS_SOURCE.test(target)) return undefined;
75
+ const stem = `./${COMPILED_DIR}/${target.replace(/^\.\//, "").replace(TS_SOURCE, "")}`;
76
+ return { types: `${stem}.d.ts`, default: `${stem}.js` };
77
+ }
78
+
79
+ /**
80
+ * Derive `publishConfig` from the package's FINAL `exports` map.
81
+ *
82
+ * Runs at preSynthesize precisely so the tags have already installed their
83
+ * export layouts - deriving it any earlier would mirror the constructor's bare
84
+ * `.` entry and silently omit every subpath a tag added.
85
+ */
86
+ function publishConfig(pkg: javascript.NodeProject): Record<string, unknown> | undefined {
87
+ const exports = (pkg.package.manifest.exports ?? {}) as Record<string, string>;
88
+ const compiled = Object.entries(exports).map(
89
+ ([subpath, target]) => [subpath, compiledTarget(target) ?? target] as const,
90
+ );
91
+ // Nothing to swap means the package ships no TypeScript entry point at all;
92
+ // leave its manifest alone rather than writing an inert `publishConfig`.
93
+ if (!compiled.some(([, target]) => typeof target !== "string")) return undefined;
94
+
95
+ // Setting this field REPLACES whatever projen renders into it, and what projen
96
+ // renders is `access` - dropped, every scoped package here would publish as
97
+ // restricted. It is not readable from `manifest` at preSynthesize (projen
98
+ // emits it from `npmAccess` later), so carry it over from that source instead.
99
+ const root = compiled.find(([subpath]) => subpath === ".")?.[1];
100
+ return {
101
+ access: pkg.package.npmAccess,
102
+ ...(typeof root === "object" ? { main: root.default, types: root.types } : {}),
103
+ exports: Object.fromEntries(compiled),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Give a package a compiled publish surface: emit the barrel, fix the emitted
109
+ * specifiers, ship `lib/`, and advertise it through `publishConfig`.
110
+ *
111
+ * Idempotent - `preSynthesizeProject` reaches every package twice (once from the
112
+ * root's walk, once from the package's own preSynthesize) and both passes must
113
+ * agree.
114
+ */
115
+ export function applyCompiledPublish(pkg: javascript.NodeProject): void {
116
+ if (!publishesCompiled(pkg)) return;
117
+
118
+ // The barrel lives at the package root, so the compilation has to start there.
119
+ // The `cli` tag already does this for its `bin/` tree; for everything else it
120
+ // is what puts an `index.js` in the emitted output for the first time.
121
+ applyCompilerOptions(pkg, { rootDir: "." });
122
+ applyIncludes(pkg, "index.ts");
123
+ addPackageFiles(pkg, COMPILED_DIR);
124
+
125
+ const config = publishConfig(pkg);
126
+ if (config) pkg.package.addField("publishConfig", config);
127
+
128
+ // `tsc` emits extensionless relative specifiers; Node ESM cannot resolve them.
129
+ const fixSpecifiers = taskScript(pkg, "emit.ts", COMPILED_DIR);
130
+ if (!pkg.compileTask.steps.some((step) => step.exec === fixSpecifiers)) {
131
+ pkg.compileTask.exec(fixSpecifiers);
132
+ }
133
+
134
+ // The release workflow publishes straight after `pnpm install`, with no build
135
+ // in between, so the compiled output has to be produced by the pack itself
136
+ // rather than assumed present. This also covers a bare `pnpm pack` and the
137
+ // bump task's local-registry publish.
138
+ if (!pkg.tasks.tryFind("prepack")) {
139
+ pkg.addTask("prepack", { description: "Compile before packing the published tarball" });
140
+ }
141
+ const prepack = pkg.tasks.tryFind("prepack")!;
142
+ if (prepack.steps.length === 0) prepack.spawn(pkg.compileTask);
143
+ }
package/tasks/emit.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Append the explicit file extension Node ESM requires to every relative
3
+ * specifier in a compiled tree.
4
+ *
5
+ * Sources are written for `moduleResolution: bundler`, so they import `"./http"`
6
+ * with no extension, and `tsc` copies specifiers through untouched. Node's ESM
7
+ * resolver does no extension probing, so that emitted output is unloadable -
8
+ * which is the whole reason these packages used to publish source instead. The
9
+ * alternative fix is to write `"./http.js"` at all 800-odd import sites and keep
10
+ * writing it forever; this pass makes it a build detail instead.
11
+ *
12
+ * Deliberately filesystem-driven rather than clever: each specifier is resolved
13
+ * against what `tsc` actually emitted, so a module that became a file and a
14
+ * module that became a directory are told apart by looking, not by guessing.
15
+ * Anything that does not resolve is left exactly as it was - a bare package
16
+ * specifier, an asset, or a genuine mistake that should surface as itself.
17
+ *
18
+ * Usage: `tsx emit.ts <compiled-dir>` (relative to the package root).
19
+ */
20
+ import { log } from "@dbx-tools/shared-core";
21
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
22
+ import { dirname, join, resolve } from "node:path";
23
+
24
+ const logger = log.logger("projen:emit");
25
+
26
+ /**
27
+ * The specifier of a static import/export or a dynamic `import()`.
28
+ *
29
+ * Anchored on the keyword that precedes it so a relative path appearing inside a
30
+ * string literal or a comment is never touched.
31
+ */
32
+ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(["'])(\.\.?\/[^"']*)\2/g;
33
+
34
+ /** Extensions that are already explicit, whether JavaScript or an asset. */
35
+ const EXPLICIT = /\.(js|mjs|cjs|json|css|svg|png|jpg|jpeg|gif|webp)$/;
36
+
37
+ /** Files whose specifiers matter: the emitted JavaScript and its declarations. */
38
+ const EMITTED = /\.(js|mjs|cjs|d\.ts)$/;
39
+
40
+ /**
41
+ * What a relative specifier must become, or `undefined` to leave it alone.
42
+ *
43
+ * Declarations resolve against the emitted `.js` too: inside a `.d.ts`,
44
+ * TypeScript maps a `"./foo.js"` specifier onto the neighbouring `foo.d.ts`, so
45
+ * both file kinds want the same suffix.
46
+ */
47
+ function retarget(fileDir: string, specifier: string): string | undefined {
48
+ if (EXPLICIT.test(specifier)) return undefined;
49
+ const base = resolve(fileDir, specifier);
50
+ if (existsSync(`${base}.js`)) return `${specifier}.js`;
51
+ if (existsSync(join(base, "index.js"))) return `${specifier}/index.js`;
52
+ return undefined;
53
+ }
54
+
55
+ /** Every file under `dir`, recursively. */
56
+ function walk(dir: string): string[] {
57
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
58
+ entry.isDirectory() ? walk(join(dir, entry.name)) : [join(dir, entry.name)],
59
+ );
60
+ }
61
+
62
+ /** Rewrite one file in place; returns whether anything changed. */
63
+ function rewrite(file: string): boolean {
64
+ const before = readFileSync(file, "utf8");
65
+ const after = before.replace(SPECIFIER, (match, keyword, quote, specifier) => {
66
+ const next = retarget(dirname(file), specifier as string);
67
+ return next ? `${keyword}${quote}${next}${quote}` : match;
68
+ });
69
+ if (after === before) return false;
70
+ writeFileSync(file, after);
71
+ return true;
72
+ }
73
+
74
+ const target = resolve(process.argv[2] ?? "lib");
75
+ // A package whose compile produced nothing (no emit, or a tag that replaced the
76
+ // compile task) is not an error - there is simply nothing to fix up.
77
+ if (existsSync(target)) {
78
+ const changed = walk(target)
79
+ .filter((file) => EMITTED.test(file))
80
+ .filter(rewrite).length;
81
+ if (changed > 0) logger.debug(`emit: resolved specifiers in ${changed} files`);
82
+ }