@c7-digital/scribe 0.0.1

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 ADDED
@@ -0,0 +1,336 @@
1
+ # @c7-digital/scribe
2
+
3
+ Transform raw DAML codegen output into unified, modern ESM TypeScript packages for use with `@c7-digital/ledger`.
4
+
5
+ ## What it does
6
+
7
+ `dpm codegen-js` produces raw TypeScript/CommonJS packages -- one per DAR -- with global template registration, mixed import conventions, and self-referencing type imports. Scribe absorbs all of that complexity:
8
+
9
+ - **CJS to ESM**: bundles CommonJS `require()`/`module.exports` into clean ESM `import`/`export` via Rollup
10
+ - **Global registry bypass**: strips `damlTypes.registerTemplate()` side-effects, replaces them with a version-aware registry
11
+ - **Import fixups**: rewrites `@mojotech/json-type-validation` (default -> namespace), `@daml/types` (default -> namespace), strips `@daml/ledger` side-effect imports
12
+ - **Package unification**: merges multiple DAR packages into a single entry point with named exports
13
+ - **Self-reference resolution**: resolves `.d.ts` self-referencing imports (e.g. `@mypackage/codegen/daml-prim-DA-Types-1.0.0`) to local paths
14
+ - **Version-aware registry**: generates a `versionedRegistry` function that conforms to `VersionedRegistry` from `@c7-digital/ledger`
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pnpm add -D @c7-digital/scribe
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ### Zero-config
25
+
26
+ Point scribe at the raw `dpm codegen-js` output:
27
+
28
+ ```bash
29
+ pnpm exec scribe -i ./codegen/js
30
+ ```
31
+
32
+ Scribe auto-detects:
33
+
34
+ | What | How |
35
+ |------|-----|
36
+ | Main package | The non-`daml-prim`/`daml-stdlib`/`splice-*` package |
37
+ | Modules | Every directory under `lib/` |
38
+ | Templates & interfaces | Exports with `templateId` in `.d.ts` files |
39
+ | Package ID | From `lib/index.d.ts` |
40
+ | Version | From directory name (e.g. `my-project-0.1.0` -> `0.1.0`) |
41
+ | Vendor packages | `splice-*` packages (third-party DARs your model depends on; exported separately from your main modules) |
42
+ | Stdlib | `daml-prim-*`, `daml-stdlib-*`, `ghc-stdlib-*` |
43
+
44
+ Output:
45
+
46
+ ```
47
+ dist/
48
+ ├── codegen.js # Bundled ESM (all modules + registry)
49
+ ├── index.js # Re-exports from codegen.js
50
+ ├── index.d.ts # TypeScript declarations
51
+ ├── version.js # PACKAGE_VERSION export
52
+ └── version.d.ts # Version type declaration
53
+ ```
54
+
55
+ ### With config
56
+
57
+ For projects that need aliases, selective exports, or compat versions, create a `scribe.yaml`:
58
+
59
+ ```yaml
60
+ schema: "1.0"
61
+ input: "../../codegen/js"
62
+
63
+ main:
64
+ pattern: "domain-verification-model-*"
65
+
66
+ vendor:
67
+ "splice-amulet-*":
68
+ modules:
69
+ "Splice.Amulet":
70
+ alias: Splice_Amulet
71
+ templates:
72
+ - FeaturedAppActivityMarker
73
+
74
+ compat:
75
+ "domain-verification-model-*":
76
+ versions:
77
+ - hash: "8afd289e3ba826fcb23d955cfc108470b53527b0615745554185e5c9625b5832"
78
+ version: "0.0.5"
79
+ ```
80
+
81
+ ```bash
82
+ pnpm exec scribe --config scribe.yaml
83
+ ```
84
+
85
+ ## CLI
86
+
87
+ ```
88
+ scribe [options]
89
+
90
+ Options:
91
+ -i, --input <path> Path to codegen/js directory (default: ./codegen/js)
92
+ -c, --config <path> Path to scribe.yaml config file
93
+ -v, --version <ver> Override package version
94
+ -o, --output <path> Output directory (default: dist)
95
+ --dry-run Preview without writing files
96
+ ```
97
+
98
+ **Version resolution priority**: `--version` flag > `$DAR_VERSION` env var > directory name.
99
+
100
+ ## Config reference
101
+
102
+ All fields are optional except `schema`.
103
+
104
+ ```yaml
105
+ schema: "1.0" # Required. Config schema version.
106
+
107
+ input: "./codegen/js" # Path to raw codegen output.
108
+ # Default: ./codegen/js
109
+
110
+ output:
111
+ dir: "dist" # Output directory. Default: dist
112
+ bundle: true # Run CJS->ESM bundling. Default: true
113
+
114
+ main:
115
+ pattern: "my-project-*" # Glob to identify the main package.
116
+ # Only needed when auto-detection is ambiguous.
117
+
118
+ modules: # Restrict which modules are exported.
119
+ # Omit to export all discovered modules.
120
+ MyModule:
121
+ templates: # Restrict which templates are registered.
122
+ - MyTemplate # Omit to register all.
123
+ interfaces:
124
+ - MyInterface
125
+ alias: CustomName # Override the export name.
126
+ OtherModule: {} # All templates/interfaces in this module.
127
+
128
+ vendor:
129
+ "splice-amulet-*": # Glob pattern matching vendor package names.
130
+ modules:
131
+ "Splice.Amulet":
132
+ alias: Splice_Amulet # Export alias.
133
+ templates:
134
+ - FeaturedAppActivityMarker
135
+
136
+ compat:
137
+ "my-project-*": # Glob matching package names (main or vendor).
138
+ versions: # Backward-compat hashes for contract upgrades.
139
+ - hash: "8afd289e..." # Package ID hash from a previous version.
140
+ version: "0.0.5" # Version label for that hash.
141
+ ```
142
+
143
+ **The config exists to constrain, not to describe.** If you omit `modules`, all are exported. If you omit `templates`, all are registered. Use config when you want *less* than the default, or need aliases and compat hashes.
144
+
145
+ ### Multi-version main packages
146
+
147
+ When `codegen/js` contains multiple versions of the main package (e.g. `model-0.0.7`, `model-0.0.8`, `model-0.0.9`), scribe selects one:
148
+
149
+ 1. If `--version` or `$DAR_VERSION` is set, use that version
150
+ 2. Otherwise, pick the latest by semver
151
+
152
+ Non-selected versions are demoted to stdlib (bundled as dependencies but not exported).
153
+
154
+ ### Vendor filtering
155
+
156
+ When a `vendor` section is present, only `splice-*` packages matching a vendor pattern are included. Unmatched `splice-*` packages are excluded (demoted to stdlib). Without a `vendor` section, all `splice-*` packages are included as vendors.
157
+
158
+ ## Vite plugin
159
+
160
+ Scribe ships a companion Vite plugin for consumer apps at `@c7-digital/scribe/vite`. This handles the runtime concerns that can't be solved at build time -- `@daml/types` and `@mojotech/json-type-validation` need special resolution in the consumer's dev server and build.
161
+
162
+ ```typescript
163
+ import { defineConfig } from 'vite';
164
+ import react from '@vitejs/plugin-react';
165
+ import { damlCodegenPlugin } from '@c7-digital/scribe/vite';
166
+
167
+ export default defineConfig({
168
+ plugins: [
169
+ damlCodegenPlugin(),
170
+ react(),
171
+ ],
172
+ });
173
+ ```
174
+
175
+ ### What `damlCodegenPlugin()` configures
176
+
177
+ - `@mojotech/json-type-validation` -> UMD build (required for ESM compat)
178
+ - `@daml/types` -> local node_modules copy
179
+ - `optimizeDeps` for `@daml/types`, `@c7-digital/ledger`, `@c7-digital/react`, and transitive deps
180
+
181
+ ### Options
182
+
183
+ ```typescript
184
+ damlCodegenPlugin({
185
+ // Override node_modules lookup path.
186
+ // Useful in monorepos where node_modules isn't at cwd().
187
+ nodeModulesPath: __dirname,
188
+ })
189
+ ```
190
+
191
+ The plugin is independent of scribe's CLI -- it works for any app that imports `@daml/types`, even without using scribe for codegen.
192
+
193
+ ## Programmatic API
194
+
195
+ ```typescript
196
+ import { run, loadConfig, discoverPackages, analyzePackage, generate } from '@c7-digital/scribe';
197
+
198
+ // Run the full pipeline
199
+ await run({ input: './codegen/js' });
200
+
201
+ // Or use individual steps
202
+ const config = await loadConfig({ config: './scribe.yaml' });
203
+ const packages = await discoverPackages(config);
204
+ const analyzed = await Promise.all(
205
+ packages.map(pkg => analyzePackage(pkg, config))
206
+ );
207
+ await generate(analyzed, config);
208
+ ```
209
+
210
+ ## Generated output
211
+
212
+ ### Exports
213
+
214
+ Scribe generates named exports for each module and a flat `versionedRegistry`:
215
+
216
+ ```typescript
217
+ // Main package modules
218
+ import { InternetDomainName, AddressBook, C7Credentials } from '@mypackage/codegen';
219
+
220
+ // Vendor modules (with aliases from config)
221
+ import { Splice_Amulet } from '@mypackage/codegen';
222
+
223
+ // Registry (conforms to VersionedRegistry from @c7-digital/ledger)
224
+ import { versionedRegistry, PACKAGE_VERSION } from '@mypackage/codegen';
225
+ ```
226
+
227
+ ### Using with `@c7-digital/ledger`
228
+
229
+ ```typescript
230
+ import { Ledger } from '@c7-digital/ledger';
231
+ import { versionedRegistry, InternetDomainName } from '@mypackage/codegen';
232
+
233
+ const ledger = new Ledger({
234
+ url: 'http://localhost:7575',
235
+ token: authToken,
236
+ versionedRegistry, // plugs directly into ledger
237
+ });
238
+
239
+ const domains = await ledger.query(InternetDomainName.DomainOwnershipToken, {
240
+ owner: myParty,
241
+ });
242
+ ```
243
+
244
+ The `versionedRegistry` function is structurally compatible with `VersionedRegistry` from `@c7-digital/ledger` without a hard import dependency. TypeScript accepts it wherever `VersionedRegistry` is expected.
245
+
246
+ ## How it works
247
+
248
+ ### Pipeline
249
+
250
+ 1. **Load config** -- read `scribe.yaml` (if provided), merge with CLI flags and defaults
251
+ 2. **Discover** -- scan input directory, classify packages as main/vendor/stdlib
252
+ 3. **Analyze** -- extract `packageId`, modules, templates, and interfaces from each package's `.d.ts` files
253
+ 4. **Generate** -- produce `index.js` (imports, exports, registry), `index.d.ts`, `version.js`, `version.d.ts`
254
+ 5. **Bundle** -- Vite/Rollup build: CJS->ESM, import fixups, `registerTemplate` stripping, self-reference resolution
255
+
256
+ ### Architecture
257
+
258
+ ```
259
+ dpm codegen-js scribe consumer app
260
+ ┌─────────────┐ ┌──────────────────┐ ┌──────────────────────┐
261
+ │ model-0.0.8/│ │ discover │ │ vite.config.ts │
262
+ │ splice-*/ │--->│ analyze │ │ damlCodegenPlugin()│
263
+ │ daml-prim-*/│ │ generate + bundle│--->│ │
264
+ │ daml-stdlib/│ │ │ │ import { ... } │
265
+ └─────────────┘ └──────────────────┘ │ from '@pkg/codegen'│
266
+ └──────────────────────┘
267
+ ```
268
+
269
+ Scribe owns the **build** side (raw codegen -> clean ESM bundle). The `damlCodegenPlugin()` Vite plugin owns the **consumer** side (`@daml/types` resolution, optimizeDeps). Neither depends on the other.
270
+
271
+ ## Consumer `package.json` setup
272
+
273
+ ```json
274
+ {
275
+ "name": "@mypackage/codegen",
276
+ "version": "0.0.1",
277
+ "type": "module",
278
+ "main": "dist/index.js",
279
+ "types": ".scribe/index.d.ts",
280
+ "exports": {
281
+ ".": {
282
+ "import": "./dist/index.js",
283
+ "types": "./.scribe/index.d.ts"
284
+ },
285
+ "./version": {
286
+ "import": "./dist/version.js",
287
+ "types": "./.scribe/version.d.ts"
288
+ }
289
+ },
290
+ "scripts": {
291
+ "build": "scribe --config scribe.yaml",
292
+ "prepare": "scribe --config scribe.yaml"
293
+ },
294
+ "dependencies": {
295
+ "@daml/types": "3.4.9",
296
+ "@mojotech/json-type-validation": "^3.1.0"
297
+ },
298
+ "devDependencies": {
299
+ "@c7-digital/scribe": "^0.0.1"
300
+ }
301
+ }
302
+ ```
303
+
304
+ Scribe creates a `.scribe/` staging directory (symlinked raw codegen packages + generated `.d.ts` files) and a `dist/` directory (bundled ESM). The `.scribe/` directory is tool-managed and should not be edited manually. The `types` field points at `.scribe/index.d.ts` so TypeScript resolves types through the original `.d.ts` chain, while `main`/`exports` point at the bundled `dist/` output for runtime.
305
+
306
+ ## Development
307
+
308
+ ### Testing
309
+
310
+ ```bash
311
+ # Unit tests (fixture-based, no external dependencies)
312
+ pnpm test
313
+
314
+ # E2E tests (requires dpm or daml CLI)
315
+ pnpm test:e2e
316
+ ```
317
+
318
+ Unit tests validate scribe's transformation logic against hand-crafted fixtures in `src/__fixtures__/`. They're fast and don't require the Daml SDK.
319
+
320
+ E2E tests validate the full pipeline against real codegen output: compile a Daml model, run `dpm codegen-js`, run scribe, then verify that a downstream consumer can type-check (`tsc --noEmit`) and bundle (`vite.build()`) the output. They require `dpm` (preferred) or the legacy `daml` assistant and will skip gracefully if neither is installed.
321
+
322
+ ### SDK version pin
323
+
324
+ The Daml SDK version is pinned in two places:
325
+
326
+ - `e2e/daml/daml.yaml` — `sdk-version` used to compile the test model
327
+ - `.github/workflows/ci.yml` — version installed on CI runners
328
+
329
+ Both must match `ledger`'s `@daml/types` dependency (currently **3.4.9**).
330
+
331
+ ## Requirements
332
+
333
+ - Node.js >= 18.0.0
334
+ - pnpm >= 8.0.0
335
+ - Vite 5.x or 6.x (optional, for bundling and the companion plugin)
336
+ - `dpm` or `daml` CLI (optional, for e2e tests only)
@@ -0,0 +1,22 @@
1
+ import type { ResolvedConfig } from "./config.js";
2
+ import type { DiscoveredPackage } from "./discover.js";
3
+ export interface AnalyzedTemplate {
4
+ name: string;
5
+ kind: "template" | "interface";
6
+ }
7
+ export interface AnalyzedModule {
8
+ /** Module name, e.g. "InternetDomainName" */
9
+ name: string;
10
+ /** Relative path from package root to module directory */
11
+ modulePath: string;
12
+ /** Export alias (from config or auto-generated) */
13
+ alias: string;
14
+ /** Templates and interfaces found in this module */
15
+ members: AnalyzedTemplate[];
16
+ }
17
+ export interface AnalyzedPackage extends DiscoveredPackage {
18
+ packageId: string;
19
+ version: string;
20
+ modules: AnalyzedModule[];
21
+ }
22
+ export declare function analyzePackage(pkg: DiscoveredPackage, config: ResolvedConfig): Promise<AnalyzedPackage>;
@@ -0,0 +1,175 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Extract packageId from lib/index.d.ts.
5
+ * Looks for: export declare const packageId = "...";
6
+ */
7
+ async function extractPackageId(pkgPath) {
8
+ const indexDts = join(pkgPath, "lib", "index.d.ts");
9
+ const content = await readFile(indexDts, "utf-8");
10
+ const match = content.match(/export\s+declare\s+const\s+packageId\s*=\s*['"]([0-9a-f]+)['"]/);
11
+ if (!match?.[1]) {
12
+ throw new Error(`Could not extract packageId from ${indexDts}`);
13
+ }
14
+ return match[1];
15
+ }
16
+ /**
17
+ * Scan a module.d.ts file and classify exports as templates or interfaces.
18
+ *
19
+ * Templates have a `templateId` field and a `Template` type in their declaration.
20
+ * Interfaces have an `InterfaceCompanion` type.
21
+ */
22
+ async function discoverMembers(moduleDtsPath) {
23
+ let content;
24
+ try {
25
+ content = await readFile(moduleDtsPath, "utf-8");
26
+ }
27
+ catch (err) {
28
+ const code = err.code;
29
+ if (code !== "ENOENT") {
30
+ console.warn(`Warning: could not read ${moduleDtsPath}: ${code ?? err}`);
31
+ }
32
+ return [];
33
+ }
34
+ const members = [];
35
+ // Match exported consts that look like template or interface companions.
36
+ // Pattern: export declare const Foo : ...Template<...>... or ...InterfaceCompanion<...>...
37
+ const regex = /export\s+declare\s+const\s+(\w+)\s*:\s*.*?(Template|InterfaceCompanion)\s*</g;
38
+ let m;
39
+ while ((m = regex.exec(content)) !== null) {
40
+ const name = m[1];
41
+ const kind = m[2] === "InterfaceCompanion" ? "interface" : "template";
42
+ members.push({ name, kind });
43
+ }
44
+ return members;
45
+ }
46
+ /**
47
+ * Discover modules inside a package's lib/ directory.
48
+ * Each subdirectory under lib/ that contains module.d.ts (or an index.d.ts
49
+ * for nested modules like Splice/Amulet/) is a module.
50
+ */
51
+ async function discoverModules(pkgPath, role) {
52
+ const libPath = join(pkgPath, "lib");
53
+ const modules = [];
54
+ async function walk(dir, prefix) {
55
+ let entries;
56
+ try {
57
+ entries = await readdir(dir, { withFileTypes: true });
58
+ }
59
+ catch (err) {
60
+ const code = err.code;
61
+ if (code !== "ENOENT") {
62
+ console.warn(`Warning: could not read directory ${dir}: ${code ?? err}`);
63
+ }
64
+ return;
65
+ }
66
+ for (const entry of entries) {
67
+ if (!entry.isDirectory())
68
+ continue;
69
+ const subdir = join(dir, entry.name);
70
+ const moduleDts = join(subdir, "module.d.ts");
71
+ const indexDts = join(subdir, "index.d.ts");
72
+ // Check if this directory has module.d.ts (leaf module)
73
+ let dtsPath;
74
+ try {
75
+ await stat(moduleDts);
76
+ dtsPath = moduleDts;
77
+ }
78
+ catch {
79
+ try {
80
+ await stat(indexDts);
81
+ dtsPath = indexDts;
82
+ }
83
+ catch {
84
+ // Not a leaf module; recurse
85
+ }
86
+ }
87
+ const moduleName = prefix ? `${prefix}.${entry.name}` : entry.name;
88
+ if (dtsPath) {
89
+ const members = await discoverMembers(dtsPath);
90
+ const alias = moduleName.replace(/\./g, "_");
91
+ modules.push({
92
+ name: moduleName,
93
+ modulePath: dtsPath.replace(libPath + "/", "").replace(/\.d\.ts$/, ""),
94
+ alias,
95
+ members,
96
+ });
97
+ }
98
+ // Always recurse for nested modules (e.g., Splice/Amulet/)
99
+ await walk(subdir, moduleName);
100
+ }
101
+ }
102
+ await walk(libPath, "");
103
+ return modules;
104
+ }
105
+ export async function analyzePackage(pkg, config) {
106
+ const packageId = await extractPackageId(pkg.path);
107
+ // Config version override only applies to the main package
108
+ const version = pkg.role === "main"
109
+ ? (config.version ?? pkg.detectedVersion ?? "0.0.0")
110
+ : (pkg.detectedVersion ?? "0.0.0");
111
+ let modules = await discoverModules(pkg.path, pkg.role);
112
+ // Apply config overrides for main package
113
+ if (pkg.role === "main" && config.main.modules) {
114
+ modules = applyModuleOverrides(modules, config.main.modules);
115
+ }
116
+ // Apply config overrides for vendor packages
117
+ if (pkg.role === "vendor" && config.vendor) {
118
+ const vendorOverride = findVendorOverride(pkg.name, config.vendor);
119
+ if (vendorOverride?.modules) {
120
+ modules = applyModuleOverrides(modules, vendorOverride.modules);
121
+ }
122
+ }
123
+ return {
124
+ ...pkg,
125
+ packageId,
126
+ version,
127
+ modules,
128
+ };
129
+ }
130
+ /**
131
+ * Apply config-driven overrides to discovered modules:
132
+ * - If overrides specify modules, only include those (filter)
133
+ * - Apply alias overrides
134
+ * - If overrides specify templates/interfaces, only include those (filter members)
135
+ */
136
+ function applyModuleOverrides(discovered, overrides) {
137
+ const overrideKeys = Object.keys(overrides);
138
+ const result = [];
139
+ for (const key of overrideKeys) {
140
+ const override = overrides[key];
141
+ // Find discovered module matching this key (by name)
142
+ const mod = discovered.find((m) => m.name === key);
143
+ if (!mod) {
144
+ console.warn(`Config references module "${key}" but it was not found.`);
145
+ continue;
146
+ }
147
+ let members = mod.members;
148
+ // Filter templates if specified
149
+ if (override.templates) {
150
+ const allowedTemplates = new Set(override.templates);
151
+ members = members.filter((m) => m.kind !== "template" || allowedTemplates.has(m.name));
152
+ }
153
+ // Filter interfaces if specified
154
+ if (override.interfaces) {
155
+ const allowedInterfaces = new Set(override.interfaces);
156
+ members = members.filter((m) => m.kind !== "interface" || allowedInterfaces.has(m.name));
157
+ }
158
+ result.push({
159
+ ...mod,
160
+ alias: override.alias ?? mod.alias,
161
+ members,
162
+ });
163
+ }
164
+ return result;
165
+ }
166
+ /** Find the vendor override config entry matching a package name. */
167
+ function findVendorOverride(pkgName, vendorConfig) {
168
+ for (const pattern of Object.keys(vendorConfig)) {
169
+ const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
170
+ if (regex.test(pkgName)) {
171
+ return vendorConfig[pattern];
172
+ }
173
+ }
174
+ return undefined;
175
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ export interface RunOptions {
3
+ input?: string;
4
+ config?: string;
5
+ version?: string;
6
+ output?: string;
7
+ dryRun?: boolean;
8
+ }
9
+ export declare function run(opts?: RunOptions): Promise<void>;
package/lib/src/cli.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { loadConfig } from "./config.js";
4
+ import { discoverPackages } from "./discover.js";
5
+ import { analyzePackage } from "./analyze.js";
6
+ import { generate } from "./generate/index.js";
7
+ export async function run(opts = {}) {
8
+ const config = await loadConfig(opts);
9
+ const packages = await discoverPackages(config);
10
+ const analyzed = await Promise.all(packages.map((pkg) => analyzePackage(pkg, config)));
11
+ await generate(analyzed, config);
12
+ }
13
+ async function main() {
14
+ const { values } = parseArgs({
15
+ options: {
16
+ input: { type: "string", short: "i" },
17
+ config: { type: "string", short: "c" },
18
+ version: { type: "string", short: "v" },
19
+ output: { type: "string", short: "o" },
20
+ "dry-run": { type: "boolean", default: false },
21
+ },
22
+ strict: true,
23
+ });
24
+ await run({
25
+ input: values.input,
26
+ config: values.config,
27
+ version: values.version,
28
+ output: values.output,
29
+ dryRun: values["dry-run"],
30
+ });
31
+ }
32
+ main().catch((err) => {
33
+ console.error(err);
34
+ process.exit(1);
35
+ });