diff --git a/docs/configuration.md b/docs/configuration.md index f6efa963..97924cfb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,6 +75,7 @@ Versioning configuration. | `allowFirstBump` | boolean | `false` | Acknowledge applying a bump on a first release with an already-stable manifest. On a first release (no prior tag), `--stable --bump ` applies the bump (e.g. 1.0.0 → 2.0.0) rather than graduating, which can silently overshoot the staged first version. By default this is flagged per `mismatchStrategy` (warn, or abort under "error"); set true (or pass --allow-first-bump) to apply the bump silently — legitimate when importing a package with prior external version history. | | `strictReachable` | boolean | `false` | Only use reachable tags | | `zeroMajor` | `"spec"` \| `"strict"` | `"spec"` | Pre-1.0 handling of commit-inferred breaking changes. 'spec' (default): bump the 0.x minor (0.24.0 → 0.25.0), per semver §4. 'strict': bump the next major (→ 1.0.0). Inferred path only — explicit overrides (--bump major, bump:major) always graduate to 1.0.0. | +| `npm` | object | — | npm/JavaScript version handling | | `pub` | object | — | Dart/Flutter pub configuration | ### `version.zeroMajor` @@ -127,13 +128,21 @@ ungrouped packages honor targets as-is. **Workspace pins.** Intra-group `workspace:*` dependencies resolve to the group version within a run, so a fixed group publishes internally consistent. +### `version.npm` + +npm/JavaScript version handling. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | boolean | `true` | Version package.json manifests. Detected npm packages are versioned by default; set false to opt out (e.g. npm versioning handled elsewhere). | + ### `version.cargo` Cargo/Rust configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `true` | Enable Cargo.toml version handling | +| `enabled` | boolean | `true` | Version Cargo.toml manifests. Detected Rust packages are versioned by default; set false to opt out (e.g. a vendored crate, or Rust versioning handled elsewhere). | | `paths` | `string[]` | — | Directories to search for Cargo.toml files | --- @@ -162,7 +171,7 @@ NPM publishing configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `true` | Enable NPM publishing | +| `enabled` | boolean | `true` | Publish to npm. Detected npm packages are published by default; set false to opt out (version and tag only). | | `auth` | `"auto"` \| `"oidc"` \| `"token"` | `"auto"` | Authentication method | | `provenance` | boolean | `true` | Enable npm provenance attestation | | `access` | `"public"` \| `"restricted"` | `"public"` | Package access level | @@ -177,7 +186,7 @@ Cargo publishing configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `false` | Enable Cargo publishing | +| `enabled` | boolean | `true` | Publish to crates.io. Detected Rust packages are published by default; set false to opt out (version and tag only). | | `noVerify` | boolean | `false` | Skip verification before publish | | `publishOrder` | `string[]` | `[]` | Order in which to publish packages | | `clean` | boolean | `false` | Clean before publishing | @@ -188,7 +197,7 @@ Dart/Flutter publishing configuration via pub. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `false` | Enable pub.dev publishing | +| `enabled` | boolean | `true` | Publish to pub.dev. Detected Dart/Flutter packages are published by default; set false to opt out (version and tag only). | | `publishOrder` | `string[]` | `[]` | Order in which to publish packages | ### `publish.githubRelease` diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 79155581..0a48fcf7 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -26,13 +26,32 @@ export const MonorepoConfigSchema = z.object({ packagesPath: z.string().optional().describe('Path to packages directory'), }); +export const VersionNpmConfigSchema = z.object({ + enabled: z + .boolean() + .default(true) + .describe( + 'Version package.json manifests. Detected npm packages are versioned by default; set false to opt out (e.g. npm versioning handled elsewhere).', + ), +}); + export const VersionCargoConfigSchema = z.object({ - enabled: z.boolean().default(true).describe('Enable Cargo.toml version handling'), + enabled: z + .boolean() + .default(true) + .describe( + 'Version Cargo.toml manifests. Detected Rust packages are versioned by default; set false to opt out (e.g. a vendored crate, or Rust versioning handled elsewhere).', + ), paths: z.array(z.string()).optional().describe('Directories to search for Cargo.toml files'), }); export const VersionPubConfigSchema = z.object({ - enabled: z.boolean().default(true).describe('Enable pubspec.yaml version handling'), + enabled: z + .boolean() + .default(true) + .describe( + 'Version pubspec.yaml manifests. Detected Dart/Flutter packages are versioned by default; set false to opt out.', + ), paths: z.array(z.string()).optional().describe('Directories to search for pubspec.yaml files'), }); @@ -129,12 +148,18 @@ export const VersionConfigSchema = z.object({ .describe( "Pre-1.0 handling of commit-inferred breaking changes. 'spec' (default): bump the 0.x minor (0.24.0 → 0.25.0), per semver §4. 'strict': bump the next major (→ 1.0.0). Inferred path only — explicit overrides (--bump major, bump:major) always graduate to 1.0.0.", ), + npm: VersionNpmConfigSchema.optional().describe('npm/JavaScript version handling'), cargo: VersionCargoConfigSchema.optional().describe('Cargo/Rust configuration'), pub: VersionPubConfigSchema.optional().describe('Dart/Flutter pub configuration'), }); export const NpmConfigSchema = z.object({ - enabled: z.boolean().default(true).describe('Enable NPM publishing'), + enabled: z + .boolean() + .default(true) + .describe( + 'Publish to npm. Detected npm packages are published by default; set false to opt out (version and tag only).', + ), auth: z.enum(['auto', 'oidc', 'token']).default('auto').describe('Authentication method'), provenance: z.boolean().default(true).describe('Enable npm provenance attestation'), access: z.enum(['public', 'restricted']).default('public').describe('Package access level'), @@ -148,14 +173,24 @@ export const NpmConfigSchema = z.object({ }); export const CargoPublishConfigSchema = z.object({ - enabled: z.boolean().default(false).describe('Enable Cargo publishing'), + enabled: z + .boolean() + .default(true) + .describe( + 'Publish to crates.io. Detected Rust packages are published by default; set false to opt out (version and tag only).', + ), noVerify: z.boolean().default(false).describe('Skip verification before publish'), publishOrder: z.array(z.string()).default([]).describe('Order in which to publish packages'), clean: z.boolean().default(false).describe('Clean before publishing'), }); export const PubPublishConfigSchema = z.object({ - enabled: z.boolean().default(false).describe('Enable pub.dev publishing'), + enabled: z + .boolean() + .default(true) + .describe( + 'Publish to pub.dev. Detected Dart/Flutter packages are published by default; set false to opt out (version and tag only).', + ), publishOrder: z.array(z.string()).default([]).describe('Order in which to publish packages'), }); @@ -248,13 +283,13 @@ export const PublishConfigSchema = z.object({ publishOrder: [], }).describe('NPM publishing configuration'), cargo: CargoPublishConfigSchema.default({ - enabled: false, + enabled: true, noVerify: false, publishOrder: [], clean: false, }).describe('Cargo publishing configuration'), pub: PubPublishConfigSchema.default({ - enabled: false, + enabled: true, publishOrder: [], }).describe('Dart/Flutter publishing configuration via pub'), githubRelease: GitHubReleaseConfigSchema.default({ diff --git a/packages/config/test/unit/schema.spec.ts b/packages/config/test/unit/schema.spec.ts index e7a112de..0f4f919a 100644 --- a/packages/config/test/unit/schema.spec.ts +++ b/packages/config/test/unit/schema.spec.ts @@ -18,6 +18,7 @@ import { TemplateConfigSchema, VerifyConfigSchema, VersionConfigSchema, + VersionNpmConfigSchema, } from '../../src/schema.js'; describe('GitConfigSchema', () => { @@ -64,7 +65,6 @@ describe('MonorepoConfigSchema', () => { describe('VersionConfigSchema', () => { it('should apply defaults', () => { const result = VersionConfigSchema.parse({}); - // biome-ignore lint/suspicious/noTemplateCurlyInString: checking the literal default string expect(result.tagTemplate).toBe('${prefix}${version}'); expect(result.packageSpecificTags).toBe(false); expect(result.preset).toBe('conventional'); @@ -104,6 +104,11 @@ describe('VersionConfigSchema', () => { expect(result.cargo?.paths).toEqual(['crates/core', 'crates/cli']); }); + it('should default version.npm.enabled to true and accept an opt-out', () => { + expect(VersionNpmConfigSchema.parse({}).enabled).toBe(true); + expect(VersionConfigSchema.parse({ npm: { enabled: false } }).npm?.enabled).toBe(false); + }); + it('should accept version groups with fixed and linked sync modes', () => { const result = VersionConfigSchema.parse({ sync: false, @@ -172,7 +177,8 @@ describe('NpmConfigSchema', () => { describe('CargoPublishConfigSchema', () => { it('should apply defaults', () => { const result = CargoPublishConfigSchema.parse({}); - expect(result.enabled).toBe(false); + // Detection enables, config opts out: crates.io publishing defaults on. + expect(result.enabled).toBe(true); expect(result.noVerify).toBe(false); expect(result.publishOrder).toEqual([]); expect(result.clean).toBe(false); @@ -221,10 +227,11 @@ describe('VerifyConfigSchema', () => { }); describe('PublishConfigSchema', () => { - it('should apply defaults for npm and cargo', () => { + it('should default publishing on for npm, cargo, and pub (detection enables, config opts out)', () => { const result = PublishConfigSchema.parse({}); expect(result.npm.enabled).toBe(true); - expect(result.cargo.enabled).toBe(false); + expect(result.cargo.enabled).toBe(true); + expect(result.pub.enabled).toBe(true); expect(result.githubRelease.enabled).toBe(true); }); }); diff --git a/packages/version/src/core/groupStrategy.ts b/packages/version/src/core/groupStrategy.ts index 5ff91797..a2bdeb1d 100644 --- a/packages/version/src/core/groupStrategy.ts +++ b/packages/version/src/core/groupStrategy.ts @@ -407,7 +407,10 @@ async function releaseGroup( } const packageJsonPath = path.join(pkg.dir, 'package.json'); - if (fs.existsSync(packageJsonPath)) { + // npm version handling gates package.json manifests (default true). + if (config.npm?.enabled === false) { + log(`Skipping package.json update for ${name} - npm version handling disabled`, 'debug'); + } else if (fs.existsSync(packageJsonPath)) { updatePackageVersion(packageJsonPath, version, config.dryRun); } else { log(`Skipping package.json update for ${name} - no package.json found (Rust-only package)`, 'debug'); @@ -567,7 +570,8 @@ export function createGroupStrategy(config: Config): (packages: PackagesWithRoot const name = pkg.packageJson.name; const packageJsonPath = path.join(pkg.dir, 'package.json'); - if (fs.existsSync(packageJsonPath)) { + // npm version handling gates package.json manifests (default true). + if (config.npm?.enabled !== false && fs.existsSync(packageJsonPath)) { updatePackageVersion(packageJsonPath, plan.ownNext, config.dryRun); } const cargoTomlPath = path.join(pkg.dir, 'Cargo.toml'); diff --git a/packages/version/src/core/versionStrategies.ts b/packages/version/src/core/versionStrategies.ts index 59aedd36..1fc514e6 100644 --- a/packages/version/src/core/versionStrategies.ts +++ b/packages/version/src/core/versionStrategies.ts @@ -225,17 +225,29 @@ export function createSyncStrategy(config: Config): StrategyFunction { // Check if packages.root is defined before joining paths if (packages.root) { const rootPkgPath = path.join(packages.root, 'package.json'); - if (fs.existsSync(rootPkgPath)) { + // npm version handling gates package.json manifests (default true). + if (config.npm?.enabled !== false && fs.existsSync(rootPkgPath)) { // Mark as root so consumers (preview, standing PR) can exclude this lockstep // bump from package counts — it isn't a publishable package. updatePackageVersion(rootPkgPath, nextVersion, dryRun, true); files.push(rootPkgPath); updatedPackages.push('root'); - processedPaths.add(rootPkgPath); - - // Handle Cargo.toml files in root - const rootCargoFiles = updateCargoFiles(packages.root, nextVersion, config.cargo, dryRun); - files.push(...rootCargoFiles); + } + // Record the root path unconditionally (even when npm is disabled) so the per-package loop + // skips it in a single-package repo where packages.root === packages[0].dir — otherwise its + // Cargo.toml would be versioned twice (once here, once in the loop). + processedPaths.add(rootPkgPath); + + // Root Cargo.toml handling is independent of npm (updateCargoFiles honors cargo.enabled), + // so it runs whether or not npm versioning is disabled — matching the per-package loop below. + const rootCargoFiles = updateCargoFiles(packages.root, nextVersion, config.cargo, dryRun); + files.push(...rootCargoFiles); + // Record 'root' when only the root Cargo.toml was versioned (npm disabled, or no root + // package.json). In a single-package repo where packages.root === packages[0].dir the loop + // skips this path via processedPaths, so without this nothing tracks the write and the empty + // updatedPackages triggers the early return below — abandoning the already-written file. + if (rootCargoFiles.length > 0 && !updatedPackages.includes('root')) { + updatedPackages.push('root'); } } else { log('Root package path is undefined, skipping root package.json update', 'warning'); @@ -258,8 +270,10 @@ export function createSyncStrategy(config: Config): StrategyFunction { continue; } - // Skip pure Rust packages that don't have a package.json - if (!fs.existsSync(packageJsonPath)) { + // Skip when npm version handling is opted out, or for pure Rust packages with no package.json. + if (config.npm?.enabled === false) { + log(`Skipping package.json update for ${pkg.packageJson.name} - npm version handling disabled`, 'debug'); + } else if (!fs.existsSync(packageJsonPath)) { log( `Skipping package.json update for ${pkg.packageJson.name} - no package.json found (Rust-only package)`, 'debug', @@ -275,8 +289,10 @@ export function createSyncStrategy(config: Config): StrategyFunction { const pkgCargoFiles = updateCargoFiles(pkg.dir, nextVersion, config.cargo, dryRun); files.push(...pkgCargoFiles); - // Track pure-Rust packages by name when their Cargo.toml was updated - if (!fs.existsSync(packageJsonPath) && pkgCargoFiles.length > 0) { + // Track by name when only the Cargo.toml was updated: a pure-Rust package (no package.json), + // or a hybrid package whose package.json write was skipped because npm handling is disabled. + // Without this the early return below abandons the already-written Cargo.toml changes. + if ((!fs.existsSync(packageJsonPath) || config.npm?.enabled === false) && pkgCargoFiles.length > 0) { updatedPackages.push(pkg.packageJson.name); } } @@ -641,12 +657,15 @@ export function createSingleStrategy(config: Config): StrategyFunction { // Track all files that need to be committed const filesToCommit: string[] = []; - // Only update package.json if it exists (skip for pure Rust packages) - if (fs.existsSync(packageJsonPath)) { + // Update package.json when npm version handling is enabled (default true) and it exists; + // otherwise skip (opted out, or a pure Rust package with no package.json). + const npmEnabled = config.npm?.enabled !== false; + if (npmEnabled && fs.existsSync(packageJsonPath)) { updatePackageVersion(packageJsonPath, nextVersion, dryRun); filesToCommit.push(packageJsonPath); } else { - log(`Skipping package.json update for ${packageName} - no package.json found (Rust-only package)`, 'debug'); + const reason = !npmEnabled ? 'npm version handling disabled' : 'no package.json found (Rust-only package)'; + log(`Skipping package.json update for ${packageName} - ${reason}`, 'debug'); } // Handle Cargo.toml files for this package diff --git a/packages/version/src/package/packageProcessor.ts b/packages/version/src/package/packageProcessor.ts index db112ceb..918d80c1 100644 --- a/packages/version/src/package/packageProcessor.ts +++ b/packages/version/src/package/packageProcessor.ts @@ -346,8 +346,9 @@ export class PackageProcessor { // This ensures consistent versioning across language ecosystems. const packageJsonPath = path.join(pkgPath, 'package.json'); - // Always update package.json if it exists - if (fs.existsSync(packageJsonPath)) { + // Update package.json when npm version handling is enabled (default true) and it exists. + const npmEnabled = this.fullConfig.npm?.enabled !== false; + if (npmEnabled && fs.existsSync(packageJsonPath)) { updatePackageVersion(packageJsonPath, nextVersion, this.dryRun); } diff --git a/packages/version/src/types.ts b/packages/version/src/types.ts index 1d8da18d..aea36e74 100644 --- a/packages/version/src/types.ts +++ b/packages/version/src/types.ts @@ -159,6 +159,9 @@ export interface Config extends VersionConfigBase { strictReachable?: boolean; /** Pre-1.0 inferred-breaking bump policy ('spec' | 'strict'). See docs/configuration.md#versionzeromajor. */ zeroMajor?: 'spec' | 'strict'; + npm?: { + enabled?: boolean; + }; cargo?: { enabled?: boolean; paths?: string[]; @@ -240,6 +243,7 @@ export function toVersionConfig(config: VersionConfig | undefined): Config { zeroMajor: config.zeroMajor, versionPrefix: config.versionPrefix ?? '', prereleaseIdentifier: config.prereleaseIdentifier, + npm: config.npm, cargo: config.cargo, pub: config.pub, }; diff --git a/packages/version/test/unit/core/versionStrategies.spec.ts b/packages/version/test/unit/core/versionStrategies.spec.ts index 58e6f6f4..1e186e76 100644 --- a/packages/version/test/unit/core/versionStrategies.spec.ts +++ b/packages/version/test/unit/core/versionStrategies.spec.ts @@ -230,6 +230,58 @@ describe('Version Strategies', () => { expect(jsonOutput.setPackageUpdateTag).not.toHaveBeenCalled(); }); + it('should still version Cargo.toml and not abandon it when npm handling is disabled on a hybrid repo', async () => { + // Hybrid sync monorepo (existsSync mock returns true for every package.json AND Cargo.toml) with + // npm versioning opted out: package.json writes are skipped, but Cargo.toml must still be versioned + // and the run must not early-return as "no packages updated" (which would abandon the written files). + const config: Partial = { ...defaultConfig, sync: true, npm: { enabled: false } }; + const syncStrategy = strategies.createSyncStrategy(config as Config); + + await syncStrategy(mockPackages); + + // package.json manifests (root 4-arg, members 3-arg) are skipped... + expect(packageManagement.updatePackageVersion).not.toHaveBeenCalledWith( + packageAPath, + expect.anything(), + expect.anything(), + ); + expect(packageManagement.updatePackageVersion).not.toHaveBeenCalledWith( + rootPackagePath, + expect.anything(), + expect.anything(), + expect.anything(), + ); + // ...but Cargo.toml is still versioned (root + members); updateCargoFiles passes dryRun=false. + expect(packageManagement.updatePackageVersion).toHaveBeenCalledWith( + '/test/workspace/packages/a/Cargo.toml', + '1.1.0', + false, + ); + expect(packageManagement.updatePackageVersion).toHaveBeenCalledWith('/test/workspace/Cargo.toml', '1.1.0', false); + // ...and the run proceeds to tag rather than early-returning. + expect(jsonOutput.addTag).toHaveBeenCalledWith('v1.1.0'); + }); + + it('should not version a single-package repo Cargo.toml twice when npm is disabled', async () => { + // Single-package repo where the package IS the root (packages.root === packages[0].dir). With + // npm disabled, processedPaths must still record the root path so the per-package loop skips it, + // or updateCargoFiles runs for the same dir twice — writing Cargo.toml twice. + const singlePkg = { + root: '/test/workspace', + packages: [{ dir: '/test/workspace', packageJson: { name: 'root-pkg', version: '1.0.0' } }], + }; + const config: Partial = { ...defaultConfig, sync: true, npm: { enabled: false } }; + await strategies.createSyncStrategy(config as Config)(singlePkg as typeof mockPackages); + + const cargoCalls = vi + .mocked(packageManagement.updatePackageVersion) + .mock.calls.filter((c) => c[0] === '/test/workspace/Cargo.toml'); + expect(cargoCalls).toHaveLength(1); + // The run must not early-return as "nothing updated" — 'root' is tracked for the cargo-only + // update, so the shared tag is still created and the written Cargo.toml is committed. + expect(jsonOutput.addTag).toHaveBeenCalledWith('v1.1.0'); + }); + it('should use mainPackage for version calculation when specified', async () => { // Setup with mainPackage const config: Partial = { diff --git a/packages/version/test/unit/package/packageProcessor.spec.ts b/packages/version/test/unit/package/packageProcessor.spec.ts index 7c44b185..6d608e2f 100644 --- a/packages/version/test/unit/package/packageProcessor.spec.ts +++ b/packages/version/test/unit/package/packageProcessor.spec.ts @@ -929,6 +929,36 @@ describe('Package Processor', () => { false, ); }); + + it('should skip package.json but still update Cargo.toml when version.npm.enabled is false', async () => { + vi.spyOn(fs, 'existsSync').mockImplementation(() => true); + + const hybridPackage = { + ...mockPackages[0], + dir: '/path/to/hybrid-package', + packageJson: { name: 'hybrid-package', version: '0.1.0' }, + }; + + const processor = new PackageProcessor({ + getLatestTag: gitTags.getLatestTag, + config: {}, + fullConfig: { ...mockConfig, npm: { enabled: false } }, + }); + + await processor.processPackages([hybridPackage]); + + // npm opted out → package.json is left untouched; Cargo.toml is still versioned. + expect(packageManagement.updatePackageVersion).not.toHaveBeenCalledWith( + '/path/to/hybrid-package/package.json', + expect.anything(), + expect.anything(), + ); + expect(packageManagement.updatePackageVersion).toHaveBeenCalledWith( + '/path/to/hybrid-package/Cargo.toml', + '1.1.0', + false, + ); + }); }); describe('Cargo.toml handling', () => { diff --git a/releasekit.schema.json b/releasekit.schema.json index dc05d1cc..580ec17d 100644 --- a/releasekit.schema.json +++ b/releasekit.schema.json @@ -210,6 +210,18 @@ "default": "spec", "description": "Pre-1.0 handling of commit-inferred breaking changes. 'spec' (default): bump the 0.x minor (0.24.0 → 0.25.0), per semver §4. 'strict': bump the next major (→ 1.0.0). Inferred path only — explicit overrides (--bump major, bump:major) always graduate to 1.0.0." }, + "npm": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Version package.json manifests. Detected npm packages are versioned by default; set false to opt out (e.g. npm versioning handled elsewhere)." + } + }, + "description": "npm/JavaScript version handling" + }, "cargo": { "type": "object", "additionalProperties": false, @@ -217,7 +229,7 @@ "enabled": { "type": "boolean", "default": true, - "description": "Enable Cargo.toml version handling" + "description": "Version Cargo.toml manifests. Detected Rust packages are versioned by default; set false to opt out (e.g. a vendored crate, or Rust versioning handled elsewhere)." }, "paths": { "type": "array", @@ -236,7 +248,7 @@ "enabled": { "type": "boolean", "default": true, - "description": "Enable pubspec.yaml version handling" + "description": "Version pubspec.yaml manifests. Detected Dart/Flutter packages are versioned by default; set false to opt out." }, "paths": { "type": "array", @@ -298,7 +310,7 @@ "enabled": { "type": "boolean", "default": true, - "description": "Enable NPM publishing" + "description": "Publish to npm. Detected npm packages are published by default; set false to opt out (version and tag only)." }, "auth": { "type": "string", @@ -361,8 +373,8 @@ "properties": { "enabled": { "type": "boolean", - "default": false, - "description": "Enable Cargo publishing" + "default": true, + "description": "Publish to crates.io. Detected Rust packages are published by default; set false to opt out (version and tag only)." }, "noVerify": { "type": "boolean", @@ -391,8 +403,8 @@ "properties": { "enabled": { "type": "boolean", - "default": false, - "description": "Enable pub.dev publishing" + "default": true, + "description": "Publish to pub.dev. Detected Dart/Flutter packages are published by default; set false to opt out (version and tag only)." }, "publishOrder": { "type": "array", diff --git a/scripts/generate-config-docs.ts b/scripts/generate-config-docs.ts index 3ef82dc8..4a0c4e44 100644 --- a/scripts/generate-config-docs.ts +++ b/scripts/generate-config-docs.ts @@ -213,6 +213,14 @@ function renderVersion(prop: SchemaProperty): void { ); } + const npm = prop.properties?.npm; + if (npm) { + emit('### `version.npm`', ''); + emit(ensurePeriod(npm.description), ''); + emit(propsTable(npm.properties ?? {})); + emitBlank(); + } + const cargo = prop.properties?.cargo; if (cargo) { emit('### `version.cargo`', '');