@dbx-tools/projen 0.1.1 → 0.3.43
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 +45 -16
- package/index.ts +34 -12
- package/package.json +13 -15
- package/src/_lazy-require.ts +46 -0
- package/src/barrels.ts +331 -0
- package/src/clean.ts +88 -0
- package/src/cli-bin.ts +72 -0
- package/src/codegen.ts +339 -0
- package/src/dbx-tools-config.ts +84 -0
- package/src/engine-root.ts +27 -0
- package/src/generated.ts +65 -0
- package/src/mixin.ts +47 -0
- package/src/module-exports.ts +120 -0
- package/src/openapi.ts +162 -0
- package/src/packages.ts +326 -0
- package/src/pnpm-workspace.ts +202 -0
- package/src/project-predicate.ts +124 -0
- package/src/project.ts +1125 -0
- package/src/release.ts +236 -0
- package/src/scaffold.ts +34 -0
- package/src/tags.ts +172 -0
- package/src/tsconfig.ts +60 -0
- package/src/vite.ts +91 -0
- package/src/vscode.ts +69 -0
- package/src/watch.ts +107 -0
- package/tasks/barrels.ts +2 -2
- package/tasks/bump.ts +340 -0
- package/tasks/clean.ts +1 -1
- package/tasks/projenrc.ts +1 -1
- package/tasks/sync.ts +69 -5
- package/tasks/publish.ts +0 -30
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Projen engine for dbx-tools pnpm workspaces.
|
|
4
4
|
|
|
5
5
|
Import this package from `.projenrc.ts` when a repository should discover
|
|
6
|
-
|
|
6
|
+
packages from the filesystem and generate manifests, tsconfigs,
|
|
7
7
|
barrels, OpenAPI clients, codegen outputs, and release tasks.
|
|
8
8
|
|
|
9
9
|
Key features:
|
|
@@ -28,7 +28,7 @@ import { project as projectApi } from "@dbx-tools/projen";
|
|
|
28
28
|
const project = new projectApi.DBXToolsNodeProject({
|
|
29
29
|
name: "my-apps",
|
|
30
30
|
scope: "my-apps",
|
|
31
|
-
|
|
31
|
+
packageRoots: ["packages", "examples"],
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
project.synth();
|
|
@@ -44,19 +44,28 @@ new package membership.
|
|
|
44
44
|
## Customize Packages With Mixins
|
|
45
45
|
|
|
46
46
|
```ts
|
|
47
|
-
import {
|
|
47
|
+
import { project as projectApi } from "@dbx-tools/projen";
|
|
48
48
|
|
|
49
49
|
const project = new projectApi.DBXToolsNodeProject();
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}),
|
|
55
|
-
);
|
|
51
|
+
projectApi.applyToProjects(project, { tags: "shared" }, (pkg) => {
|
|
52
|
+
pkg.addDeps("zod@catalog:");
|
|
53
|
+
});
|
|
56
54
|
|
|
57
55
|
project.synth();
|
|
58
56
|
```
|
|
59
57
|
|
|
58
|
+
`applyToProjects` AND-s its globs (prefix a glob with `!` to negate) into one
|
|
59
|
+
predicate over the DBXTools child packages, then applies it as a `constructs`
|
|
60
|
+
mixin across the subtree. Filter on the folder (`path`), the tags (`tags`), or
|
|
61
|
+
the name from whichever angle fits: `name` matches the raw projen name verbatim,
|
|
62
|
+
while `identifierPackageName`, `identifierScope`, and `identifierName` match the
|
|
63
|
+
parsed `@scope/name`, its scope, and its unscoped half. Two flags widen the
|
|
64
|
+
selection past DBXTools children - `includeRoots` for the tree root and
|
|
65
|
+
`includeNonDBXToolsProjects` for plain projen projects (which widens the callback
|
|
66
|
+
parameter to `Project`). Drop to `mixin.create(predicate, fn)` +
|
|
67
|
+
`project.with(...)` only when you need a predicate the filters cannot express.
|
|
68
|
+
|
|
60
69
|
Built-in tag mixins set runtime defaults for `shared`, `node`, `cli`, `server`,
|
|
61
70
|
`ui`, and `openapi`. Repo-specific mixins layer package-specific dependencies,
|
|
62
71
|
scripts, and generated files on top.
|
|
@@ -64,13 +73,13 @@ scripts, and generated files on top.
|
|
|
64
73
|
## Work With Package Discovery
|
|
65
74
|
|
|
66
75
|
```ts
|
|
67
|
-
import {
|
|
76
|
+
import { packages } from "@dbx-tools/projen";
|
|
68
77
|
|
|
69
|
-
const discovered =
|
|
70
|
-
const recorded =
|
|
78
|
+
const discovered = packages.scanPackages(process.cwd(), ["packages"]);
|
|
79
|
+
const recorded = packages.recordedPackages();
|
|
71
80
|
```
|
|
72
81
|
|
|
73
|
-
`scanPackages()` reads the filesystem during synth. `
|
|
82
|
+
`scanPackages()` reads the filesystem during synth. `recordedPackages()` reads
|
|
74
83
|
the generated `pnpm-workspace.yaml` plus package manifests for post-synth tools.
|
|
75
84
|
Use the latter for docs, linting, and release checks that should match the
|
|
76
85
|
recorded workspace.
|
|
@@ -106,8 +115,25 @@ project.pnpmWorkspace?.addCatalog("react", "^19");
|
|
|
106
115
|
project.pnpmWorkspace?.allowBuild("esbuild");
|
|
107
116
|
```
|
|
108
117
|
|
|
109
|
-
`
|
|
110
|
-
|
|
118
|
+
projen's native `javascript.PnpmWorkspaceYaml` writes `pnpm-workspace.yaml`;
|
|
119
|
+
`pnpmWorkspace.PnpmWorkspaceState` supplies the options it renders and tracks
|
|
120
|
+
package members, catalog entries, and build-script allowances. Any other pnpm
|
|
121
|
+
setting goes through the root's `workspaceYaml` option, which is projen's typed
|
|
122
|
+
`PnpmWorkspaceYamlOptions`:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
new DBXToolsNodeProject({ workspaceYaml: { overrides: { glob: "^13.0.0" } } });
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`allowBuild` writes pnpm's `allowBuilds` map rather than projen's own
|
|
129
|
+
`allowScripts` option, which for pnpm renders `onlyBuiltDependencies` - a key
|
|
130
|
+
current pnpm does not read, so the list would leave every build script skipped.
|
|
131
|
+
Only allowances are declared; a dependency that is never allowed needs no entry,
|
|
132
|
+
because pnpm warns and moves on.
|
|
133
|
+
|
|
134
|
+
The engine also applies `catalogMode: manual` (keeps `pnpm add` out of the
|
|
135
|
+
generated catalog) and `verifyDepsBeforeRun: warn`. The file is emitted for the
|
|
136
|
+
tree ROOT only; a member package never gets a nested one.
|
|
111
137
|
|
|
112
138
|
## Clean And Watch Generated Files
|
|
113
139
|
|
|
@@ -131,7 +157,7 @@ file contract as the CLI.
|
|
|
131
157
|
- `mixin` / `projectPredicate` - constructs mixin factory and package
|
|
132
158
|
predicates.
|
|
133
159
|
- `tags` - built-in runtime tag mixins and compiler floors.
|
|
134
|
-
- `
|
|
160
|
+
- `packages` - filesystem discovery and recorded package metadata.
|
|
135
161
|
- `pnpmWorkspace` - generated pnpm workspace file and catalog model.
|
|
136
162
|
- `barrels` / `moduleExports` - public entrypoint generation.
|
|
137
163
|
- `codegen` - `.d.ts` to zod schema generation.
|
|
@@ -142,4 +168,7 @@ file contract as the CLI.
|
|
|
142
168
|
- `publish` - packaging and tag-based release helpers.
|
|
143
169
|
- `engineRoot` - engine package root resolution for bootstrapped repos.
|
|
144
170
|
|
|
145
|
-
The
|
|
171
|
+
The engine registers its commands as projen tasks on the workspace root, so run
|
|
172
|
+
them with `pnpm run <task>` - `sync` (add `--watch`), `barrels`, `openapi`, and
|
|
173
|
+
`clean`. [`@dbx-tools/cli`](../packages/cli/dbx-tools) is only needed to
|
|
174
|
+
bootstrap a folder that has no `.projenrc.ts` or toolchain yet.
|
package/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// GENERATED by projen watch - DO NOT EDIT.
|
|
2
|
+
// Regenerated from the exporting modules in ./src.
|
|
3
|
+
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
|
+
|
|
3
5
|
export * as barrels from "./src/barrels";
|
|
4
6
|
export * as clean from "./src/clean";
|
|
7
|
+
export * as cliBin from "./src/cli-bin";
|
|
5
8
|
export * as codegen from "./src/codegen";
|
|
6
9
|
export * as dbxToolsConfig from "./src/dbx-tools-config";
|
|
7
10
|
export * as engineRoot from "./src/engine-root";
|
|
@@ -9,26 +12,45 @@ export * as generated from "./src/generated";
|
|
|
9
12
|
export * as mixin from "./src/mixin";
|
|
10
13
|
export * as moduleExports from "./src/module-exports";
|
|
11
14
|
export * as openapi from "./src/openapi";
|
|
15
|
+
export * as packages from "./src/packages";
|
|
12
16
|
export * as pnpmWorkspace from "./src/pnpm-workspace";
|
|
13
17
|
export * as project from "./src/project";
|
|
14
18
|
export * as projectPredicate from "./src/project-predicate";
|
|
15
|
-
export * as
|
|
19
|
+
export * as release from "./src/release";
|
|
16
20
|
export * as scaffold from "./src/scaffold";
|
|
17
21
|
export * as tags from "./src/tags";
|
|
18
22
|
export * as tsconfig from "./src/tsconfig";
|
|
19
23
|
export * as vite from "./src/vite";
|
|
20
24
|
export * as vscode from "./src/vscode";
|
|
21
25
|
export * as watch from "./src/watch";
|
|
22
|
-
export
|
|
23
|
-
export
|
|
26
|
+
export { generateBarrels } from "./src/barrels";
|
|
27
|
+
export { listGeneratedFiles, listNodeModulesDirs, removePaths } from "./src/clean";
|
|
28
|
+
export { CliBinLauncher, addCliBinLaunchers } from "./src/cli-bin";
|
|
29
|
+
export { generateCodegen } from "./src/codegen";
|
|
30
|
+
export { DBXToolsConfig } from "./src/dbx-tools-config";
|
|
24
31
|
export type { DBXToolsConfigOptions } from "./src/dbx-tools-config";
|
|
32
|
+
export { resolvePkgRoot } from "./src/engine-root";
|
|
33
|
+
export { makeWritable, makeReadonly, isReadonly, header, stampGenerated } from "./src/generated";
|
|
25
34
|
export type { HeaderOpts } from "./src/generated";
|
|
35
|
+
export { create } from "./src/mixin";
|
|
26
36
|
export type { ConstructsMixin } from "./src/mixin";
|
|
27
|
-
export
|
|
28
|
-
export type {
|
|
29
|
-
export
|
|
30
|
-
export
|
|
31
|
-
export type {
|
|
32
|
-
export
|
|
37
|
+
export { moduleStatements } from "./src/module-exports";
|
|
38
|
+
export type { ModuleStatement, ModuleExport } from "./src/module-exports";
|
|
39
|
+
export { isTsoaController, generateOpenapi } from "./src/openapi";
|
|
40
|
+
export { repoRoot, DEFAULT_PACKAGE_ROOTS, projectName, toPosix, isGeneratedFile, isModuleFile, DiscoveredPackage, scanPackages, readPackageManifest, syncResynthPaths, recordedPackages, recordedRoots } from "./src/packages";
|
|
41
|
+
export type { RecordedPackage } from "./src/packages";
|
|
42
|
+
export { PnpmWorkspaceState } from "./src/pnpm-workspace";
|
|
43
|
+
export type { Catalog, AllowBuilds, DBXToolsPNPMWorkspaceOptions } from "./src/pnpm-workspace";
|
|
44
|
+
export { PackageIdentifier, identifier, applyCompilerOptions, applyIncludes, applyTasks, applyExports, addExports, addPackageFiles, srcModuleExports, PROJEN_VERSION, DBXToolsNodeProject, DBXToolsTypeScriptProject, taskScript, applyToProjects } from "./src/project";
|
|
45
|
+
export type { DBXToolsProject, DBXToolsProjectOptions, DBXToolsTypeScriptProjectOptions, ApplyToProjectsOptions } from "./src/project";
|
|
46
|
+
export { isProject, isDBXToolsProject, hasName, hasIdentifierPackageName, hasIdentifierName, hasIdentifierScope, hasTag, hasPath } from "./src/project-predicate";
|
|
47
|
+
export { DBXToolsRelease } from "./src/release";
|
|
48
|
+
export type { StandaloneRelease, DBXToolsReleaseOptions } from "./src/release";
|
|
49
|
+
export { runSynth } from "./src/scaffold";
|
|
50
|
+
export { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS } from "./src/tags";
|
|
51
|
+
export type { PackageTag } from "./src/tags";
|
|
52
|
+
export { DBXToolsRootTsconfig } from "./src/tsconfig";
|
|
53
|
+
export { ViteConfigFile } from "./src/vite";
|
|
54
|
+
export { DBXToolsVsCode } from "./src/vscode";
|
|
55
|
+
export { watchRoots, watchLoop } from "./src/watch";
|
|
33
56
|
export type { IgnoreGroupOptions } from "./src/watch";
|
|
34
|
-
export type { WorkspacePackage } from "./src/workspace";
|
package/package.json
CHANGED
|
@@ -2,28 +2,24 @@
|
|
|
2
2
|
"name": "@dbx-tools/projen",
|
|
3
3
|
"devDependencies": {
|
|
4
4
|
"@types/node": "^24.6.0",
|
|
5
|
-
"commit-and-tag-version": "^12",
|
|
6
5
|
"constructs": "^10.0.0",
|
|
7
6
|
"projen": "^0.101.6",
|
|
8
|
-
"
|
|
7
|
+
"tsoa": "^6.6.0",
|
|
9
8
|
"typescript": "^5.9.3"
|
|
10
9
|
},
|
|
11
10
|
"dependencies": {
|
|
12
11
|
"@clack/prompts": "^1.7.0",
|
|
13
|
-
"@dbx-tools/core": "^0.
|
|
14
|
-
"@dbx-tools/path": "^0.
|
|
15
|
-
"@dbx-tools/shared-core": "^0.
|
|
16
|
-
"@typescript-eslint/typescript-estree": "^8",
|
|
12
|
+
"@dbx-tools/core": "^0.3.40",
|
|
13
|
+
"@dbx-tools/path": "^0.3.40",
|
|
14
|
+
"@dbx-tools/shared-core": "^0.3.40",
|
|
17
15
|
"commander": "^15.0.0",
|
|
18
|
-
"
|
|
16
|
+
"concurrently": "^10.0.3",
|
|
19
17
|
"constructs": "^10.6.0",
|
|
20
18
|
"is-identifier": "^1",
|
|
21
19
|
"openapi-typescript": "^7.13.0",
|
|
22
20
|
"oxc-parser": "^0.90.0",
|
|
23
|
-
"
|
|
24
|
-
"projen": "^0.101.6",
|
|
21
|
+
"projen": "^0.101.16",
|
|
25
22
|
"ts-to-zod": "^5.1.0",
|
|
26
|
-
"tsoa": "^6.6.0",
|
|
27
23
|
"tsx": "^4.23.0",
|
|
28
24
|
"yaml": "^2.9.0"
|
|
29
25
|
},
|
|
@@ -36,15 +32,19 @@
|
|
|
36
32
|
},
|
|
37
33
|
"main": "index.ts",
|
|
38
34
|
"license": "Apache-2.0",
|
|
39
|
-
"version": "0.
|
|
35
|
+
"version": "0.3.43",
|
|
40
36
|
"types": "index.ts",
|
|
41
37
|
"type": "module",
|
|
42
38
|
"exports": {
|
|
43
39
|
".": "./index.ts",
|
|
44
|
-
"./engine-root": "./src/engine-root.ts",
|
|
45
40
|
"./package.json": "./package.json"
|
|
46
41
|
},
|
|
47
|
-
"
|
|
42
|
+
"files": [
|
|
43
|
+
"index.ts",
|
|
44
|
+
"src",
|
|
45
|
+
"tasks"
|
|
46
|
+
],
|
|
47
|
+
"//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "projen build",
|
|
50
50
|
"bump": "projen bump",
|
|
@@ -56,9 +56,7 @@
|
|
|
56
56
|
"post-compile": "projen post-compile",
|
|
57
57
|
"post-upgrade": "projen post-upgrade",
|
|
58
58
|
"pre-compile": "projen pre-compile",
|
|
59
|
-
"release": "projen release",
|
|
60
59
|
"test": "projen test",
|
|
61
|
-
"unbump": "projen unbump",
|
|
62
60
|
"upgrade": "projen upgrade",
|
|
63
61
|
"watch": "projen watch",
|
|
64
62
|
"projen": "projen"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading heavy generator tools out of the CONSUMING workspace.
|
|
3
|
+
*
|
|
4
|
+
* The generators (`openapi.ts`, `codegen.ts`) each drive a large toolchain that
|
|
5
|
+
* only matters when that generator actually has work to do, so none of them are
|
|
6
|
+
* imported at module scope and several are not engine dependencies at all. Node
|
|
7
|
+
* resolves a bare specifier from the engine's own location by walking up, and under
|
|
8
|
+
* pnpm that walk passes through `node_modules/.pnpm/node_modules` - the hidden
|
|
9
|
+
* directory holding every package installed ANYWHERE in the workspace. So a tool
|
|
10
|
+
* declared by some member package (`tsoa`, via the `server` tag) or by the root
|
|
11
|
+
* (`typescript`) resolves fine from here without the engine shipping its own copy.
|
|
12
|
+
*
|
|
13
|
+
* The failure mode is a consumer whose workspace never installed the tool, which
|
|
14
|
+
* surfaces as a bare MODULE_NOT_FOUND naming a package they never asked for. The
|
|
15
|
+
* helper below turns that into the install command instead.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** How to obtain each lazily-loaded tool, for the error message. */
|
|
19
|
+
const INSTALL_HINTS: Record<string, string> = {
|
|
20
|
+
tsoa: "pnpm add tsoa",
|
|
21
|
+
typescript: "pnpm add -D typescript",
|
|
22
|
+
"ts-to-zod": "pnpm add -D ts-to-zod",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `require` a generator tool from the workspace, reporting a missing one as an
|
|
27
|
+
* actionable error rather than a raw MODULE_NOT_FOUND.
|
|
28
|
+
*
|
|
29
|
+
* @param require - a `createRequire(import.meta.url)` bound to the calling module,
|
|
30
|
+
* so the resolution walk starts at the engine and reaches the consumer's install.
|
|
31
|
+
* @param name - bare package specifier of the tool.
|
|
32
|
+
* @param reason - what the tool is needed for, named in the error.
|
|
33
|
+
*/
|
|
34
|
+
export function lazyRequire<T>(require: NodeJS.Require, name: string, reason: string): T {
|
|
35
|
+
try {
|
|
36
|
+
return require(name) as T;
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
if ((cause as NodeJS.ErrnoException)?.code !== "MODULE_NOT_FOUND") throw cause;
|
|
39
|
+
const hint = INSTALL_HINTS[name] ?? `pnpm add -D ${name}`;
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${reason} needs the \`${name}\` package, which is not installed in this workspace. ` +
|
|
42
|
+
`Add it to the package that needs it (\`${hint}\`) and re-run.`,
|
|
43
|
+
{ cause },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/barrels.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel generator.
|
|
3
|
+
*
|
|
4
|
+
* For every package it writes a single `index.ts` **at the package root** (above
|
|
5
|
+
* `src/`) that namespace-re-exports every module under `src/`, subject to a few
|
|
6
|
+
* rules (see {@link isExcluded}):
|
|
7
|
+
* 1. a file/folder whose name starts with `_` is private and never barrelled;
|
|
8
|
+
* 2. test / `.d.ts` files are skipped;
|
|
9
|
+
* 3. a `src/**/index.ts` is a hand-authored subpath entry, not a module;
|
|
10
|
+
* 4. only files that actually contain an `export` are re-exported.
|
|
11
|
+
*
|
|
12
|
+
* A hand-authored `exports.ts` sitting next to the generated `index.ts` (a Vite-style
|
|
13
|
+
* override) is spliced in last and wins: its exports are appended, and any generated
|
|
14
|
+
* `export * as <ns>` whose namespace it also declares is dropped so the custom one
|
|
15
|
+
* takes priority. This keeps the barrel auto-generated while letting you add or
|
|
16
|
+
* override individual exports.
|
|
17
|
+
*
|
|
18
|
+
* Each eligible module becomes `export * as <name> from "./src/x"` (camelCase
|
|
19
|
+
* namespace from its path segments; invalid identifiers suffixed with `Module`),
|
|
20
|
+
* sorted by module path.
|
|
21
|
+
*
|
|
22
|
+
* On top of the namespace lines, every export that is UNIQUE across the package
|
|
23
|
+
* (declared in exactly one module) is also HOISTED to the barrel's top level, so
|
|
24
|
+
* consumers can write `GenieMessage` or `DBXToolsNodeProject` instead of
|
|
25
|
+
* `genieModel.GenieMessage` / `project.DBXToolsNodeProject`. Types go out as
|
|
26
|
+
* `export type { ... }` (required under `isolatedModules`), values as
|
|
27
|
+
* `export { ... }`. The module namespaces stay either way, so a namespaced call
|
|
28
|
+
* site keeps working.
|
|
29
|
+
*
|
|
30
|
+
* Uniqueness is tallied over types and values TOGETHER: a name carried by two
|
|
31
|
+
* modules is ambiguous whichever kind it is, and hoisting one module's value
|
|
32
|
+
* beside another's same-named type would emit two conflicting re-exports. Such a
|
|
33
|
+
* name stays namespace-only. Names that collide with a generated namespace, or
|
|
34
|
+
* that a hand-authored `exports.ts` declares, are never hoisted (that file wins).
|
|
35
|
+
*
|
|
36
|
+
* The result gets a do-not-edit header + read-only bit (see `./generated`).
|
|
37
|
+
*/
|
|
38
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { find } from "@dbx-tools/path";
|
|
41
|
+
import { string } from "@dbx-tools/shared-core";
|
|
42
|
+
import isIdentifier from "is-identifier";
|
|
43
|
+
import { header, makeReadonly, makeWritable, stampGenerated, type HeaderOpts } from "./generated";
|
|
44
|
+
import { moduleExports, moduleStatements, type ModuleExport } from "./module-exports";
|
|
45
|
+
import { isModuleFile, toPosix, recordedPackages } from "./packages";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A `src`-relative posix path excluded from the root barrel:
|
|
49
|
+
* 1. any path segment starting with `_` (private module or folder);
|
|
50
|
+
* 2. a test / spec file;
|
|
51
|
+
* 3. a `.d.ts` declaration;
|
|
52
|
+
* 4. a `src/**/index.ts` - a hand-authored subpath entry (e.g. `src/react/index.ts`
|
|
53
|
+
* behind a package's `./react` export), not a module to namespace into the barrel.
|
|
54
|
+
*/
|
|
55
|
+
function isExcluded(relPath: string): boolean {
|
|
56
|
+
return (
|
|
57
|
+
/(^|\/)_/.test(relPath) ||
|
|
58
|
+
/\.(test|spec)\./.test(relPath) ||
|
|
59
|
+
/\.d\.ts$/.test(relPath) ||
|
|
60
|
+
/(^|\/)index\.ts$/.test(relPath)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Module file extension, for stripping to an extensionless module path. */
|
|
65
|
+
const MODULE_EXT_RE = /\.(tsx?|jsx?|mts|cts)$/;
|
|
66
|
+
|
|
67
|
+
/** True for a TypeScript source file (preferred over a compiled `.js` sibling). */
|
|
68
|
+
function isSourceExt(file: string): boolean {
|
|
69
|
+
return /\.(tsx?|mts|cts)$/.test(file);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Top-level statement types that make a file a re-exportable module. */
|
|
73
|
+
const EXPORT_STATEMENT_TYPES = new Set([
|
|
74
|
+
"ExportNamedDeclaration",
|
|
75
|
+
"ExportDefaultDeclaration",
|
|
76
|
+
"ExportAllDeclaration",
|
|
77
|
+
"TSExportAssignment",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/** True when the file has at least one top-level export statement. */
|
|
81
|
+
function hasExport(file: string): boolean {
|
|
82
|
+
return moduleStatements(file).some((stmt) => EXPORT_STATEMENT_TYPES.has(stmt.type));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The do-not-edit banner stamped on every generated barrel. Deliberately stable
|
|
87
|
+
* (no timestamp) so a barrel is a pure function of its exporting modules - which
|
|
88
|
+
* is what lets {@link generateForPackage} skip the rewrite when nothing changed.
|
|
89
|
+
*/
|
|
90
|
+
const BARREL_HEADER: HeaderOpts = {
|
|
91
|
+
tool: "projen watch",
|
|
92
|
+
source: "the exporting modules in ./src",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** `pnpm-workspace` -> `pnpmWorkspace`; nested paths join in camelCase. */
|
|
96
|
+
function kebabToCamel(segment: string): string {
|
|
97
|
+
return segment.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Derive a valid namespace identifier from a relocated barrel module path. */
|
|
101
|
+
function modulePathToNamespace(modulePath: string): string {
|
|
102
|
+
const rel = modulePath.replace(/^\.\/src\//, "").replace(/\.(tsx?|jsx?|mjs|cjs)$/, "");
|
|
103
|
+
const segments = rel.split("/").map(kebabToCamel);
|
|
104
|
+
let name =
|
|
105
|
+
segments.length === 1
|
|
106
|
+
? segments[0]!
|
|
107
|
+
: segments[0]! +
|
|
108
|
+
segments
|
|
109
|
+
.slice(1)
|
|
110
|
+
.map((s) => string.capitalize(s))
|
|
111
|
+
.join("");
|
|
112
|
+
if (!isIdentifier(name)) {
|
|
113
|
+
name = `${name}Module`;
|
|
114
|
+
}
|
|
115
|
+
return name;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A `./src/x` module path parsed out of a generated `export * as <ns>` line. */
|
|
119
|
+
function namespaceLines(content: string): { ns: string; modulePath: string }[] {
|
|
120
|
+
const out: { ns: string; modulePath: string }[] = [];
|
|
121
|
+
for (const line of content.split("\n")) {
|
|
122
|
+
const match = /^export \* as (\w+) from "(\.\/src\/.+)";\s*$/.exec(line);
|
|
123
|
+
if (match) out.push({ ns: match[1]!, modulePath: match[2]! });
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Append hoisted top-level re-exports for every export that is UNIQUE across the
|
|
130
|
+
* package's modules - `export type { ... }` for types, `export { ... }` for
|
|
131
|
+
* values. A name declared by two or more modules is ambiguous and left
|
|
132
|
+
* namespace-only. `suppress` names (a hand-authored `exports.ts` surface) are
|
|
133
|
+
* never hoisted so that file stays authoritative.
|
|
134
|
+
*/
|
|
135
|
+
function hoistUniqueExports(content: string, pkgDir: string, suppress: Set<string>): string {
|
|
136
|
+
const namespaces = namespaceLines(content);
|
|
137
|
+
if (namespaces.length === 0) return content;
|
|
138
|
+
|
|
139
|
+
// A hoisted name must never collide with a generated `export * as <ns>`
|
|
140
|
+
// namespace (e.g. a `mixin.ts` exporting a `mixin` value alongside the
|
|
141
|
+
// `export * as mixin` line), so treat every namespace id as suppressed too.
|
|
142
|
+
const blocked = new Set<string>(suppress);
|
|
143
|
+
for (const { ns } of namespaces) blocked.add(ns);
|
|
144
|
+
|
|
145
|
+
// Uniqueness is tallied over types AND values together - name -> { count,
|
|
146
|
+
// owning module } - so a name any two modules share stays namespace-only
|
|
147
|
+
// whichever kind each one is. `moduleExports` already dedupes within a module,
|
|
148
|
+
// so a count above one always means more than one module.
|
|
149
|
+
const seen = new Map<string, { count: number; modulePath: string }>();
|
|
150
|
+
const perModule = new Map<string, ModuleExport[]>();
|
|
151
|
+
for (const { modulePath } of namespaces) {
|
|
152
|
+
const abs = join(pkgDir, modulePath.replace(/^\.\//, ""));
|
|
153
|
+
const exports = moduleExports(withTsExt(abs));
|
|
154
|
+
perModule.set(modulePath, exports);
|
|
155
|
+
for (const { name } of exports) {
|
|
156
|
+
const prior = seen.get(name);
|
|
157
|
+
if (prior) prior.count += 1;
|
|
158
|
+
else seen.set(name, { count: 1, modulePath });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
for (const { modulePath } of namespaces) {
|
|
164
|
+
const types: string[] = [];
|
|
165
|
+
const values: string[] = [];
|
|
166
|
+
for (const { name, isType } of perModule.get(modulePath) ?? []) {
|
|
167
|
+
if (blocked.has(name)) continue;
|
|
168
|
+
const entry = seen.get(name);
|
|
169
|
+
// Unique across the package AND this is the module that owns it.
|
|
170
|
+
if (!entry || entry.count !== 1 || entry.modulePath !== modulePath) continue;
|
|
171
|
+
(isType ? types : values).push(name);
|
|
172
|
+
}
|
|
173
|
+
if (values.length) lines.push(`export { ${values.join(", ")} } from "${modulePath}";`);
|
|
174
|
+
if (types.length) lines.push(`export type { ${types.join(", ")} } from "${modulePath}";`);
|
|
175
|
+
}
|
|
176
|
+
if (lines.length === 0) return content;
|
|
177
|
+
return `${content.replace(/\n+$/, "")}\n${lines.join("\n")}\n`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Resolve a barrel module path (`./src/x`, extensionless) to its on-disk `.ts(x)` file. */
|
|
181
|
+
function withTsExt(absNoExt: string): string {
|
|
182
|
+
for (const ext of [".ts", ".tsx", ".mts", ".cts"]) {
|
|
183
|
+
if (existsSync(absNoExt + ext)) return absNoExt + ext;
|
|
184
|
+
}
|
|
185
|
+
return `${absNoExt}.ts`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Hand-authored override barrel: a sibling of the generated `index.ts`. */
|
|
189
|
+
const CUSTOM_EXPORTS_FILE = "exports.ts";
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Best-effort set of the top-level export names a module declares - named
|
|
193
|
+
* declarations, `export { x }` specifiers, `export * as ns`, and default. A bare
|
|
194
|
+
* `export *` re-exports opaque names that can't be resolved statically, so a custom
|
|
195
|
+
* `exports.ts` should name what it means to override explicitly.
|
|
196
|
+
*/
|
|
197
|
+
function customExportNames(file: string): Set<string> {
|
|
198
|
+
const names = new Set<string>();
|
|
199
|
+
const body = moduleStatements(file) as ReadonlyArray<Record<string, any>>;
|
|
200
|
+
const add = (node: Record<string, any> | undefined | null): void => {
|
|
201
|
+
if (node && typeof node.name === "string") names.add(node.name);
|
|
202
|
+
else if (node && typeof node.value === "string") names.add(node.value);
|
|
203
|
+
};
|
|
204
|
+
for (const stmt of body) {
|
|
205
|
+
if (stmt.type === "ExportDefaultDeclaration") {
|
|
206
|
+
names.add("default");
|
|
207
|
+
} else if (stmt.type === "ExportAllDeclaration") {
|
|
208
|
+
add(stmt.exported); // `export * as ns from ...`; a bare `export *` has none
|
|
209
|
+
} else if (stmt.type === "ExportNamedDeclaration") {
|
|
210
|
+
for (const spec of stmt.specifiers ?? []) add(spec.exported);
|
|
211
|
+
const decl = stmt.declaration;
|
|
212
|
+
if (decl?.id) add(decl.id);
|
|
213
|
+
for (const d of decl?.declarations ?? []) if (d.id?.type === "Identifier") add(d.id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return names;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Splice a hand-authored `<pkg>/exports.ts` into the barrel. Any generated
|
|
221
|
+
* `export * as <ns>` whose namespace the custom file also declares is dropped (so the
|
|
222
|
+
* custom export wins - a plain `export *` cannot otherwise override an explicit
|
|
223
|
+
* `export * as`), then the whole module is re-exported last.
|
|
224
|
+
*/
|
|
225
|
+
function mergeCustomExports(content: string, pkgDir: string): string {
|
|
226
|
+
if (!existsSync(join(pkgDir, CUSTOM_EXPORTS_FILE))) return content;
|
|
227
|
+
const overridden = customExportNames(join(pkgDir, CUSTOM_EXPORTS_FILE));
|
|
228
|
+
const kept = content.split("\n").filter((line) => {
|
|
229
|
+
const ns = /^export \* as (\w+) from /.exec(line)?.[1];
|
|
230
|
+
return !(ns && overridden.has(ns));
|
|
231
|
+
});
|
|
232
|
+
return `${kept.join("\n").replace(/\n+$/, "")}\nexport * from "./exports";\n`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Rebuild one package's root barrel. Returns 1 only if the barrel's contents
|
|
237
|
+
* actually changed - a module was added, removed, renamed, or toggled its
|
|
238
|
+
* `export` - and 0 for a no-op. An edit *inside* an already-exported module (even
|
|
239
|
+
* adding a new named export) leaves the namespace `export * as … from "./src/x"`
|
|
240
|
+
* list identical, so it is a no-op.
|
|
241
|
+
*/
|
|
242
|
+
function generateForPackage(pkgDir: string): number {
|
|
243
|
+
const srcDir = join(pkgDir, "src");
|
|
244
|
+
if (!existsSync(srcDir)) return 0;
|
|
245
|
+
|
|
246
|
+
const rootBarrel = join(pkgDir, "index.ts");
|
|
247
|
+
// Snapshot the current barrel so we can tell a real change (module added/removed/
|
|
248
|
+
// renamed) from an edit *inside* an already-exported module, which leaves the
|
|
249
|
+
// `export * as … from "./src/x"` list - and therefore this file - byte-for-byte
|
|
250
|
+
// identical.
|
|
251
|
+
const before = existsSync(rootBarrel) ? readFileSync(rootBarrel, "utf8") : undefined;
|
|
252
|
+
|
|
253
|
+
// The re-exportable module set under `src/`: every source file that actually
|
|
254
|
+
// exports something, minus the private / test / declaration files and any
|
|
255
|
+
// `src/**/index.ts` (those are hand-authored subpath entries behind a package's
|
|
256
|
+
// `./sub` export, not modules to namespace into the root barrel). `findFiles`
|
|
257
|
+
// yields posix paths relative to `srcDir`; `hasExport` parses each via
|
|
258
|
+
// `moduleStatements` and needs the absolute path.
|
|
259
|
+
const candidates = [...find.findFiles("**/*", { cwd: srcDir })]
|
|
260
|
+
.map(toPosix)
|
|
261
|
+
.filter(isModuleFile)
|
|
262
|
+
.filter((f) => !isExcluded(f))
|
|
263
|
+
.filter((f) => hasExport(join(srcDir, f)));
|
|
264
|
+
|
|
265
|
+
// Collapse each extensionless module path to one entry, preferring a TypeScript
|
|
266
|
+
// source over a sibling compiled artifact (`math.ts` wins over a committed
|
|
267
|
+
// `math.js`), so a module is barrelled exactly once. Then sort by module path.
|
|
268
|
+
const byModulePath = new Map<string, string>();
|
|
269
|
+
for (const f of candidates) {
|
|
270
|
+
const stem = f.replace(MODULE_EXT_RE, "");
|
|
271
|
+
const existing = byModulePath.get(stem);
|
|
272
|
+
if (!existing || (!isSourceExt(existing) && isSourceExt(f))) byModulePath.set(stem, f);
|
|
273
|
+
}
|
|
274
|
+
const modulePaths = [...byModulePath.keys()].sort((a, b) => a.localeCompare(b));
|
|
275
|
+
|
|
276
|
+
// No eligible modules -> no barrel: drop any stale root barrel and bail.
|
|
277
|
+
if (modulePaths.length === 0) {
|
|
278
|
+
if (existsSync(rootBarrel)) {
|
|
279
|
+
makeWritable(rootBarrel);
|
|
280
|
+
rmSync(rootBarrel, { force: true });
|
|
281
|
+
}
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Unlock the read-only barrel so the rewrite below can replace it.
|
|
286
|
+
makeWritable(rootBarrel);
|
|
287
|
+
|
|
288
|
+
// `./src/<path-without-ext>` namespaced by its path segments (camelCase; invalid
|
|
289
|
+
// identifiers suffixed with `Module`).
|
|
290
|
+
let content = modulePaths
|
|
291
|
+
.map((stem) => {
|
|
292
|
+
const modulePath = `./src/${stem}`;
|
|
293
|
+
return `export * as ${modulePathToNamespace(modulePath)} from "${modulePath}";`;
|
|
294
|
+
})
|
|
295
|
+
.join("\n");
|
|
296
|
+
// Hoist package-unique named exports to the top level. Names a hand-authored
|
|
297
|
+
// `exports.ts` declares are suppressed so that file stays authoritative.
|
|
298
|
+
const customPath = join(pkgDir, CUSTOM_EXPORTS_FILE);
|
|
299
|
+
const suppress = existsSync(customPath) ? customExportNames(customPath) : new Set<string>();
|
|
300
|
+
content = hoistUniqueExports(content, pkgDir, suppress);
|
|
301
|
+
// A sibling `exports.ts` overrides/extends the generated barrel and wins on conflict.
|
|
302
|
+
content = mergeCustomExports(content, pkgDir);
|
|
303
|
+
|
|
304
|
+
// The barrel only *changes* when its set of exporting modules does. If the
|
|
305
|
+
// stamped result matches what's already on disk, restore the read-only bit we
|
|
306
|
+
// cleared above and report no change (0) - this keeps the watcher quiet on
|
|
307
|
+
// ordinary in-file edits (which leave the export * as … list identical).
|
|
308
|
+
content = `${content.replace(/\n+$/, "")}\n`;
|
|
309
|
+
const next = `${header(BARREL_HEADER)}\n${content}`;
|
|
310
|
+
if (before === next) {
|
|
311
|
+
makeReadonly(rootBarrel);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
writeFileSync(rootBarrel, content);
|
|
316
|
+
stampGenerated(rootBarrel, BARREL_HEADER);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Rebuild barrels for the given package dirs (default: every package recorded in
|
|
322
|
+
* `pnpm-workspace.yaml` - the source of truth, read via `recordedPackages()`).
|
|
323
|
+
* Returns the number of barrels whose contents actually changed (an unchanged
|
|
324
|
+
* export surface is a no-op), so callers can stay quiet when nothing moved.
|
|
325
|
+
*/
|
|
326
|
+
export function generateBarrels(opts: { dirs?: string[] } = {}): number {
|
|
327
|
+
const dirs = opts.dirs ?? recordedPackages().map((p) => p.dir);
|
|
328
|
+
let total = 0;
|
|
329
|
+
for (const dir of dirs) total += generateForPackage(dir);
|
|
330
|
+
return total;
|
|
331
|
+
}
|