diff --git a/packages/platform-api-docs/CHANGELOG.md b/packages/platform-api-docs/CHANGELOG.md index 6e3cfcc96a..9ffd3bba8e 100644 --- a/packages/platform-api-docs/CHANGELOG.md +++ b/packages/platform-api-docs/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Speed up documentation generation by loading source files in bulk instead of one at a time ([#9990](https://github.com/MetaMask/core/pull/9990)) - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) +### Fixed + +- Link a capability to its source rather than to the build output compiled from it, where both are available ([#10085](https://github.com/MetaMask/core/pull/10085)) + ## [0.1.0] ### Added diff --git a/packages/platform-api-docs/src/generate.test.ts b/packages/platform-api-docs/src/generate.test.ts index fa284232fc..e1971a9c6e 100644 --- a/packages/platform-api-docs/src/generate.test.ts +++ b/packages/platform-api-docs/src/generate.test.ts @@ -338,6 +338,77 @@ export type FooMessenger = Messenger<'Foo', FooAction, never>; }); }); + it('deduplicates items preferring source over build output', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + // Reproduces how build output wins in a real monorepo. `a-controller` is + // scanned before `b-controller`, and its import of `@metamask/b-controller` + // resolves through node_modules to the published `.d.cts`, so the first + // declaration seen for `B:get` is the built one. The source declaration is + // reached later, when `b-controller/src` is scanned, and should win. + const declaration = ` +/** Gets b. */ +export type BGetAction = { + type: 'B:get'; + handler: () => string; +}; +`; + + const bSrc = path.join(directoryPath, 'packages', 'b-controller', 'src'); + await fs.promises.mkdir(bSrc, { recursive: true }); + await fs.promises.writeFile( + path.join(bSrc, 'BController.ts'), + `${declaration} +export type BMessenger = Messenger<'B', BGetAction, never>; +`, + ); + + const bDist = path.join( + directoryPath, + 'node_modules', + '@metamask', + 'b-controller', + 'dist', + ); + await fs.promises.mkdir(bDist, { recursive: true }); + await fs.promises.writeFile(path.join(bDist, 'index.d.cts'), declaration); + await fs.promises.writeFile( + path.join(bDist, '..', 'package.json'), + JSON.stringify({ + name: '@metamask/b-controller', + types: './dist/index.d.cts', + }), + ); + + const aSrc = path.join(directoryPath, 'packages', 'a-controller', 'src'); + await fs.promises.mkdir(aSrc, { recursive: true }); + await fs.promises.writeFile( + path.join(aSrc, 'AController.ts'), + ` +import type { BGetAction } from '@metamask/b-controller'; + +export type AMessenger = Messenger<'A', BGetAction, never>; +`, + ); + + const outputDir = path.join(directoryPath, '.docs'); + await generate({ + projectPath: directoryPath, + outputDir, + strategy: 'scan', + scanDirs: ['src'], + }); + + const actionsMd = await fs.promises.readFile( + path.join(outputDir, 'docs', 'B', 'actions.md'), + 'utf8', + ); + expect(actionsMd).toContain('packages/b-controller/src/BController.ts'); + expect(actionsMd).not.toContain('/dist/'); + }); + }); + it('returns zero counts for project with no messenger types', async () => { expect.assertions(3); diff --git a/packages/platform-api-docs/src/generate.ts b/packages/platform-api-docs/src/generate.ts index 61e7ecdf7c..773be71690 100644 --- a/packages/platform-api-docs/src/generate.ts +++ b/packages/platform-api-docs/src/generate.ts @@ -105,7 +105,14 @@ function deduplicationScore(item: MessengerCapabilityPacket): number { item.sourceFile.toLowerCase().includes(namespacePrefix) ? 1 : 0; - return jsDocScore + homeScore; + // A capability declared in a package's own source is usually also visible in + // the `dist` built from it, and a cross-package import resolves to that + // `dist` rather than to the sibling's source. Prefer the source, which is + // what an engineer can actually read and edit. Projects that only ever see + // published packages score every candidate the same way, so nothing changes + // for them. + const sourceScore = /[\\/]dist[\\/]/u.test(item.sourceFile) ? 0 : 1; + return jsDocScore + homeScore + sourceScore; } const execFileAsync = promisify(execFile); @@ -353,16 +360,11 @@ async function scanSources( sources: ScanSources, ): Promise { const project = createProject(); - const sourceFiles = []; + const patterns: string[] = []; for (const dir of sources.scanDirs) { const root = await toGlobPath(projectPath, dir); - sourceFiles.push( - ...addSourceFiles(project, [ - `${root}/**/*.ts`, - ...buildTsSourceExclusions(root), - ]), - ); + patterns.push(`${root}/**/*.ts`, ...buildTsSourceExclusions(root)); } if (sources.packagesDir) { @@ -370,19 +372,19 @@ async function scanSources( // Anchored at each package's `src`, not at `packages` itself, so a package // whose name collides with an exclusion (`test`, `dist`) isn't dropped. const contentRoot = `${root}/*/src`; - sourceFiles.push( - ...addSourceFiles(project, [ - `${contentRoot}/**/*.ts`, - ...buildTsSourceExclusions(contentRoot), - ]), + patterns.push( + `${contentRoot}/**/*.ts`, + ...buildTsSourceExclusions(contentRoot), ); } if (sources.nodeModulesDir) { const root = await toGlobPath(sources.nodeModulesDir); - sourceFiles.push(...addSourceFiles(project, [`${root}/*/dist/**/*.d.cts`])); + patterns.push(`${root}/*/dist/**/*.d.cts`); } + const sourceFiles = addSourceFiles(project, patterns); + // Matched paths are fully resolved, so the root they are made relative to // has to be resolved the same way or every source link becomes a `../..` // walk out of the project.