diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index e27e4c836ac..d1f34f76967 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -81,6 +81,7 @@
/packages/error-reporting-service @MetaMask/core-platform
/packages/eth-json-rpc-middleware @MetaMask/core-platform
/packages/messenger @MetaMask/core-platform
+/packages/messenger-docs @MetaMask/core-platform
/packages/sample-controllers @MetaMask/core-platform
/packages/polling-controller @MetaMask/core-platform
/packages/preferences-controller @MetaMask/core-platform
diff --git a/.github/workflows/messenger-docs.yml b/.github/workflows/messenger-docs.yml
new file mode 100644
index 00000000000..89c95e98774
--- /dev/null
+++ b/.github/workflows/messenger-docs.yml
@@ -0,0 +1,60 @@
+name: Messenger API Docs
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ build-docs:
+ name: Build Messenger API docs
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout and setup environment
+ uses: MetaMask/action-checkout-and-setup@v2
+ with:
+ is-high-risk-environment: false
+
+ - name: Generate and build Messenger docs
+ run: yarn docs:messenger:build
+ env:
+ DOCS_URL: https://${{ github.repository_owner }}.github.io
+ DOCS_BASE_URL: /${{ github.event.repository.name }}/
+
+ - name: Upload build artifact (PR)
+ if: github.event_name == 'pull_request'
+ uses: actions/upload-artifact@v4
+ with:
+ name: messenger-api-docs
+ path: .messenger-docs/build/
+ retention-days: 7
+
+ - name: Upload Pages artifact (main)
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: .messenger-docs/build/
+
+ deploy:
+ name: Deploy to GitHub Pages
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ needs: build-docs
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index c19f8765313..c82eae0dafd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,9 @@ scripts/coverage
# typescript
packages/*/*.tsbuildinfo
+# Messenger API docs (generated)
+.messenger-docs/
+
# Emacs
\#*\#
.#*
diff --git a/README.md b/README.md
index 3987bb0dbe6..4ddb1ac634f 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ Each package in this repository has its own README where you can find installati
- [`@metamask/logging-controller`](packages/logging-controller)
- [`@metamask/message-manager`](packages/message-manager)
- [`@metamask/messenger`](packages/messenger)
+- [`@metamask/messenger-docs`](packages/messenger-docs)
- [`@metamask/multichain-account-service`](packages/multichain-account-service)
- [`@metamask/multichain-api-middleware`](packages/multichain-api-middleware)
- [`@metamask/multichain-network-controller`](packages/multichain-network-controller)
@@ -144,6 +145,7 @@ linkStyle default opacity:0.5
logging_controller(["@metamask/logging-controller"]);
message_manager(["@metamask/message-manager"]);
messenger(["@metamask/messenger"]);
+ messenger_docs(["@metamask/messenger-docs"]);
multichain_account_service(["@metamask/multichain-account-service"]);
multichain_api_middleware(["@metamask/multichain-api-middleware"]);
multichain_network_controller(["@metamask/multichain-network-controller"]);
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a124c6f9948..f19f8c82df9 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -15,6 +15,8 @@ const config = createConfig([
'merged-packages/**',
'.yarn/**',
'scripts/create-package/package-template/**',
+ '.messenger-docs/**',
+ 'packages/messenger-docs/template/**',
],
},
{
@@ -43,9 +45,18 @@ const config = createConfig([
'**/tests/**/*.{js,ts}',
'scripts/*.ts',
'scripts/create-package/**/*.ts',
+ 'packages/messenger-docs/src/**/*.ts',
],
extends: [nodejs],
},
+ {
+ files: ['packages/messenger-docs/src/cli.ts'],
+ rules: {
+ // The bin field points to dist/cli.mjs but the source is src/cli.ts.
+ // Without convertPath, n/hashbang cannot correlate the two.
+ 'n/hashbang': 'off',
+ },
+ },
{
files: ['**/*.{js,cjs}'],
languageOptions: {
@@ -125,7 +136,7 @@ const config = createConfig([
},
},
{
- files: ['scripts/*.ts'],
+ files: ['scripts/**/*.ts'],
rules: {
// Scripts may be self-executable and thus have hashbangs.
'n/hashbang': 'off',
diff --git a/package.json b/package.json
index 4078304ca15..19b63655e1f 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,10 @@
"changelog:update": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:update",
"changelog:validate": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:validate",
"create-package": "tsx scripts/create-package",
+ "docs:messenger:build": "yarn workspace @metamask/messenger-docs docs:build",
+ "docs:messenger:dev": "yarn workspace @metamask/messenger-docs docs:dev",
+ "docs:messenger:generate": "yarn workspace @metamask/messenger-docs docs:generate",
+ "docs:messenger:serve": "yarn workspace @metamask/messenger-docs docs:serve",
"generate-method-action-types": "yarn workspaces foreach --all --parallel --interlaced --verbose run generate-method-action-types",
"lint": "yarn lint:eslint && echo && yarn lint:misc --check && yarn constraints && yarn lint:dependencies && yarn lint:teams && yarn generate-method-action-types --check",
"lint:dependencies": "depcheck && yarn dedupe --check",
@@ -45,7 +49,11 @@
},
"resolutions": {
"elliptic@6.5.4": "^6.5.7",
+ "eslint-import-resolver-typescript": "3.7.0",
+ "eslint-plugin-import-x": "4.6.1",
+ "eslint-plugin-n": "17.15.1",
"fast-xml-parser@^4.3.4": "^4.4.1",
+ "prettier@npm:^3.3.3": "3.4.2",
"ws@7.4.6": "^7.5.10"
},
"devDependencies": {
@@ -107,6 +115,8 @@
"@lavamoat/preinstall-always-fail": false,
"@keystonehq/bc-ur-registry-eth>hdkey>secp256k1": true,
"babel-runtime>core-js": false,
+ "eslint-import-resolver-typescript>unrs-resolver": false,
+ "eslint-plugin-import-x>unrs-resolver": false,
"simple-git-hooks": false,
"tsx>esbuild": false
}
diff --git a/packages/messenger-docs/CHANGELOG.md b/packages/messenger-docs/CHANGELOG.md
new file mode 100644
index 00000000000..fe866b58dfa
--- /dev/null
+++ b/packages/messenger-docs/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+
+- Initial release of the messenger-docs package
+
+[Unreleased]: https://github.com/MetaMask/core/
diff --git a/packages/messenger-docs/LICENSE b/packages/messenger-docs/LICENSE
new file mode 100644
index 00000000000..c259cd7ebcf
--- /dev/null
+++ b/packages/messenger-docs/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 MetaMask
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/messenger-docs/README.md b/packages/messenger-docs/README.md
new file mode 100644
index 00000000000..0eb724ade61
--- /dev/null
+++ b/packages/messenger-docs/README.md
@@ -0,0 +1,82 @@
+# `@metamask/messenger-docs`
+
+Generate and serve Messenger API documentation for MetaMask controller packages.
+
+Scans TypeScript source files and declaration files for messenger action/event types, then generates a searchable Docusaurus site with per-namespace documentation.
+
+## Installation
+
+`yarn add @metamask/messenger-docs`
+
+or
+
+`npm install @metamask/messenger-docs`
+
+## Usage
+
+### Core monorepo
+
+The package includes workspace scripts for development:
+
+```bash
+# Generate docs from all packages
+yarn workspace @metamask/messenger-docs docs:generate
+
+# Generate + start dev server with hot reload
+yarn workspace @metamask/messenger-docs docs:dev
+
+# Generate + build static site
+yarn workspace @metamask/messenger-docs docs:build
+
+# Generate + build + serve
+yarn workspace @metamask/messenger-docs docs:serve
+```
+
+### Client projects (Extension, Mobile)
+
+Add `@metamask/messenger-docs` as a dev dependency, then add a script to your `package.json`:
+
+```json
+{
+ "scripts": {
+ "docs:messenger": "messenger-docs --serve"
+ }
+}
+```
+
+By default, the tool scans `src/` for `.ts` files and `node_modules/@metamask/` for `.d.cts` declaration files. If your project has source files in other directories, configure `scanDirs` in `package.json`:
+
+```json
+{
+ "messenger-docs": {
+ "scanDirs": ["app", "src"]
+ }
+}
+```
+
+Or pass `--scan-dir` flags:
+
+```bash
+messenger-docs --scan-dir app --scan-dir shared --serve
+```
+
+### CLI options
+
+```
+messenger-docs [project-path] [options]
+
+Arguments:
+ project-path Path to the project to scan (default: current directory)
+
+Options:
+ --build Generate docs and build static site
+ --serve Generate docs, build, and serve static site
+ --dev Generate docs and start dev server with hot reload
+ --scan-dir
Extra source directory to scan (repeatable)
+ --output Output directory (default: /.messenger-docs)
+ --help Show this help message
+```
+
+## Contributing
+
+This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
diff --git a/packages/messenger-docs/jest.config.js b/packages/messenger-docs/jest.config.js
new file mode 100644
index 00000000000..850528118cf
--- /dev/null
+++ b/packages/messenger-docs/jest.config.js
@@ -0,0 +1,29 @@
+/*
+ * For a detailed explanation regarding each configuration property and type check, visit:
+ * https://jestjs.io/docs/configuration
+ */
+
+const merge = require('deepmerge');
+const path = require('path');
+
+const baseConfig = require('../../jest.config.packages');
+
+const displayName = path.basename(__dirname);
+
+module.exports = merge(baseConfig, {
+ // The display name when running multiple projects
+ displayName,
+
+ // Allow running without any test files
+ passWithNoTests: true,
+
+ // An object that configures minimum threshold enforcement for coverage results
+ coverageThreshold: {
+ global: {
+ branches: 0,
+ functions: 0,
+ lines: 0,
+ statements: 0,
+ },
+ },
+});
diff --git a/packages/messenger-docs/package.json b/packages/messenger-docs/package.json
new file mode 100644
index 00000000000..0b5a8d59a4e
--- /dev/null
+++ b/packages/messenger-docs/package.json
@@ -0,0 +1,84 @@
+{
+ "name": "@metamask/messenger-docs",
+ "version": "0.0.0",
+ "description": "Generate and serve Messenger API documentation for MetaMask controller packages",
+ "keywords": [
+ "MetaMask",
+ "Ethereum"
+ ],
+ "homepage": "https://github.com/MetaMask/core/tree/main/packages/messenger-docs#readme",
+ "bugs": {
+ "url": "https://github.com/MetaMask/core/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/MetaMask/core.git"
+ },
+ "license": "MIT",
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "main": "./dist/index.cjs",
+ "types": "./dist/index.d.cts",
+ "bin": "./dist/cli.mjs",
+ "files": [
+ "dist/",
+ "template/"
+ ],
+ "scripts": {
+ "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
+ "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean",
+ "build:docs": "typedoc",
+ "changelog:update": "../../scripts/update-changelog.sh @metamask/messenger-docs",
+ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/messenger-docs",
+ "docs:build": "tsx src/cli.ts ../.. --build",
+ "docs:dev": "tsx src/cli.ts ../.. --dev",
+ "docs:generate": "tsx src/cli.ts ../..",
+ "docs:serve": "tsx src/cli.ts ../.. --serve",
+ "publish:preview": "yarn npm publish --tag preview",
+ "since-latest-release": "../../scripts/since-latest-release.sh",
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
+ "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
+ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
+ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
+ },
+ "dependencies": {
+ "@docusaurus/core": "^3.9.0",
+ "@docusaurus/preset-classic": "^3.9.0",
+ "@docusaurus/theme-common": "^3.9.0",
+ "@easyops-cn/docusaurus-search-local": "^0.55.0",
+ "@mdx-js/react": "^3.0.0",
+ "prism-react-renderer": "^2.4.1",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "typescript": "~5.3.3"
+ },
+ "devDependencies": {
+ "@docusaurus/types": "^3.9.0",
+ "@metamask/auto-changelog": "^3.4.4",
+ "@ts-bridge/cli": "^0.6.4",
+ "@types/node": "^16.18.54",
+ "@types/react": "^18.3.0",
+ "deepmerge": "^4.2.2",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.2.5"
+ },
+ "engines": {
+ "node": "^18.18 || >=20"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ }
+}
diff --git a/packages/messenger-docs/src/cli.ts b/packages/messenger-docs/src/cli.ts
new file mode 100644
index 00000000000..980b0e72d74
--- /dev/null
+++ b/packages/messenger-docs/src/cli.ts
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+
+import { spawn } from 'node:child_process';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+
+import { generate } from './generate';
+
+/**
+ * Run a command asynchronously with inherited stdio.
+ *
+ * @param command - The command to run.
+ * @param args - The command arguments.
+ * @param cwd - The working directory.
+ * @param env - Optional environment variables.
+ */
+async function run(
+ command: string,
+ args: string[],
+ cwd: string,
+ env?: NodeJS.ProcessEnv,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, { cwd, stdio: 'inherit', env });
+ child.on('close', (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(
+ new Error(
+ `Command failed with exit code ${code}: ${command} ${args.join(
+ ' ',
+ )}`,
+ ),
+ );
+ }
+ });
+ child.on('error', reject);
+ });
+}
+
+/**
+ * Run a Docusaurus command with NODE_PATH set so that the output directory
+ * can resolve packages from this package's node_modules.
+ *
+ * @param bin - Path to the docusaurus binary.
+ * @param command - The docusaurus command (start, build, serve).
+ * @param cwd - The site directory.
+ * @param nodeModules - The node_modules directory containing Docusaurus packages.
+ */
+async function runDocusaurus(
+ bin: string,
+ command: string,
+ cwd: string,
+ nodeModules: string,
+): Promise {
+ // eslint-disable-next-line n/no-process-env
+ const env = { ...process.env, NODE_PATH: nodeModules };
+ await run(process.execPath, [bin, command], cwd, env);
+}
+
+/**
+ * Resolve the path to the docusaurus CLI binary and the node_modules
+ * directory that contains the Docusaurus packages.
+ *
+ * @returns The docusaurus binary path and the node_modules directory.
+ */
+function resolveDocusaurus(): { bin: string; nodeModules: string } {
+ const bin = require.resolve('@docusaurus/core/bin/docusaurus.mjs');
+ // Walk up from @docusaurus/core/bin/docusaurus.mjs to find node_modules
+ const coreDir = path.dirname(path.dirname(bin));
+ const nodeModules = path.dirname(path.dirname(coreDir));
+ return { bin, nodeModules };
+}
+
+/**
+ * Parse CLI arguments and run the messenger docs generator.
+ */
+async function main(): Promise {
+ const args = process.argv.slice(2);
+
+ // Parse flags
+ let build = false;
+ let serve = false;
+ let dev = false;
+ let outputDir: string | undefined;
+ let projectPath: string | undefined;
+ const scanDirs: string[] = [];
+
+ for (let i = 0; i < args.length; i += 1) {
+ const arg = args[i];
+ switch (arg) {
+ case '--build':
+ build = true;
+ break;
+ case '--serve':
+ serve = true;
+ break;
+ case '--dev':
+ dev = true;
+ break;
+ case '--output':
+ i += 1;
+ outputDir = args[i];
+ if (!outputDir || outputDir.startsWith('-')) {
+ console.error('Error: --output requires a path argument');
+ process.exitCode = 1;
+ return;
+ }
+ break;
+ case '--scan-dir':
+ i += 1;
+ if (!args[i] || args[i].startsWith('-')) {
+ console.error('Error: --scan-dir requires a path argument');
+ process.exitCode = 1;
+ return;
+ }
+ scanDirs.push(args[i]);
+ break;
+ case '--help':
+ printHelp();
+ return;
+ default:
+ if (arg.startsWith('-')) {
+ console.error(`Unknown flag: ${arg}`);
+ printHelp();
+ process.exitCode = 1;
+ return;
+ }
+ projectPath = arg;
+ break;
+ }
+ }
+
+ // Resolve paths
+ const resolvedProjectPath = path.resolve(projectPath ?? process.cwd());
+ const resolvedOutputDir = path.resolve(
+ outputDir ?? path.join(resolvedProjectPath, '.messenger-docs'),
+ );
+
+ // Step 1: Generate docs
+ await generate({
+ projectPath: resolvedProjectPath,
+ outputDir: resolvedOutputDir,
+ ...(scanDirs.length > 0 ? { scanDirs } : {}),
+ });
+
+ // Step 2: If --build, --serve, or --dev, set up and run Docusaurus
+ if (build || serve || dev) {
+ await setupSite(resolvedOutputDir);
+
+ const { bin: docusaurus, nodeModules } = resolveDocusaurus();
+
+ if (dev) {
+ console.log('\nStarting dev server...');
+ await runDocusaurus(docusaurus, 'start', resolvedOutputDir, nodeModules);
+ } else if (build || serve) {
+ console.log('\nBuilding static site...');
+ await runDocusaurus(docusaurus, 'build', resolvedOutputDir, nodeModules);
+
+ if (serve) {
+ console.log('\nServing static site...');
+ await runDocusaurus(
+ docusaurus,
+ 'serve',
+ resolvedOutputDir,
+ nodeModules,
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Copy template files into the output directory.
+ *
+ * @param outDir - The output directory to set up.
+ */
+async function setupSite(outDir: string): Promise {
+ const templateDir = path.resolve(__dirname, '..', 'template');
+
+ console.log(`\nSetting up Docusaurus site in ${outDir}...`);
+
+ // Copy template files (skip node_modules and docs if they exist in template)
+ await copyDir(templateDir, outDir, new Set(['node_modules', 'docs']));
+
+ // Write a minimal package.json so Docusaurus doesn't warn about a missing one
+ const pkgJsonPath = path.join(outDir, 'package.json');
+ try {
+ await fs.access(pkgJsonPath);
+ } catch {
+ await fs.writeFile(
+ pkgJsonPath,
+ JSON.stringify({ name: 'messenger-docs-site', private: true }, null, 2),
+ );
+ }
+}
+
+/**
+ * Recursively copy a directory, skipping specified directory names.
+ *
+ * @param src - Source directory.
+ * @param dest - Destination directory.
+ * @param skip - Set of directory names to skip.
+ */
+async function copyDir(
+ src: string,
+ dest: string,
+ skip: Set,
+): Promise {
+ await fs.mkdir(dest, { recursive: true });
+ const entries = await fs.readdir(src, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const srcPath = path.join(src, entry.name);
+ const destPath = path.join(dest, entry.name);
+
+ if (entry.isDirectory()) {
+ if (skip.has(entry.name)) {
+ continue;
+ }
+ await copyDir(srcPath, destPath, skip);
+ } else {
+ await fs.copyFile(srcPath, destPath);
+ }
+ }
+}
+
+/**
+ * Print usage help.
+ */
+function printHelp(): void {
+ console.log(`
+Usage: messenger-docs [project-path] [options]
+
+Generate Messenger API documentation for MetaMask controller packages.
+Scans packages/*/src (.ts), configured source dirs, and node_modules/@metamask (.d.cts).
+
+Arguments:
+ project-path Path to the project to scan (default: current directory)
+
+Options:
+ --build Generate docs and build static site
+ --serve Generate docs, build, and serve static site
+ --dev Generate docs and start dev server with hot reload
+ --scan-dir Extra source directory to scan (repeatable, default: src)
+ --output Output directory (default: /.messenger-docs)
+ --help Show this help message
+
+Source directories can also be configured in package.json:
+ "messenger-docs": { "scanDirs": ["app", "src"] }
+
+Examples:
+ messenger-docs # Scan cwd
+ messenger-docs --serve # Generate, build, and serve
+ messenger-docs --scan-dir app --scan-dir shared # Scan app/ and shared/
+ messenger-docs --output ./my-docs # Custom output directory
+`);
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/packages/messenger-docs/src/discovery.ts b/packages/messenger-docs/src/discovery.ts
new file mode 100644
index 00000000000..8829537ae4c
--- /dev/null
+++ b/packages/messenger-docs/src/discovery.ts
@@ -0,0 +1,73 @@
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+
+export const SKIP_DIRS = new Set([
+ '__tests__',
+ 'tests',
+ 'test',
+ 'node_modules',
+ 'dist',
+ '__mocks__',
+]);
+
+/**
+ * Recursively find all non-test TypeScript files in a directory.
+ *
+ * @param dir - The directory to search.
+ * @returns A promise that resolves to an array of absolute file paths.
+ */
+export async function findTsFiles(dir: string): Promise {
+ const results: string[] = [];
+
+ async function walk(directory: string): Promise {
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
+ const full = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ // Skip test dirs, node_modules, dist
+ if (SKIP_DIRS.has(entry.name)) {
+ continue;
+ }
+ await walk(full);
+ } else if (
+ entry.name.endsWith('.ts') &&
+ !entry.name.endsWith('.test.ts') &&
+ !entry.name.endsWith('.test-d.ts') &&
+ !entry.name.endsWith('.spec.ts') &&
+ !entry.name.endsWith('.d.ts')
+ ) {
+ results.push(full);
+ }
+ }
+ }
+
+ await walk(dir);
+ return results;
+}
+
+/**
+ * Recursively find all `.d.cts` declaration files in a directory.
+ * Skips nested `node_modules` subdirectories.
+ *
+ * @param dir - The directory to search.
+ * @returns A promise that resolves to an array of absolute file paths.
+ */
+export async function findDtsFiles(dir: string): Promise {
+ const results: string[] = [];
+
+ async function walk(directory: string): Promise {
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
+ const full = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === 'node_modules') {
+ continue;
+ }
+ await walk(full);
+ } else if (entry.name.endsWith('.d.cts')) {
+ results.push(full);
+ }
+ }
+ }
+
+ await walk(dir);
+ return results;
+}
diff --git a/packages/messenger-docs/src/extraction.ts b/packages/messenger-docs/src/extraction.ts
new file mode 100644
index 00000000000..f2e52f44ef3
--- /dev/null
+++ b/packages/messenger-docs/src/extraction.ts
@@ -0,0 +1,698 @@
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import {
+ createSourceFile,
+ getJSDocCommentsAndTags,
+ getJSDocTags,
+ isAsExpression,
+ isClassDeclaration,
+ isIdentifier,
+ isImportDeclaration,
+ isInterfaceDeclaration,
+ isJSDoc,
+ isLiteralTypeNode,
+ isMethodDeclaration,
+ isNamedImports,
+ isNoSubstitutionTemplateLiteral,
+ isPropertySignature,
+ isStringLiteral,
+ isTemplateExpression,
+ isTemplateLiteralTypeNode,
+ isTypeAliasDeclaration,
+ isTypeLiteralNode,
+ isTypeOfExpression,
+ isTypeQueryNode,
+ isTypeReferenceNode,
+ isVariableStatement,
+ ScriptTarget,
+} from 'typescript';
+import type {
+ Expression,
+ InterfaceDeclaration,
+ Node as TsNode,
+ NodeArray,
+ SourceFile,
+ TemplateLiteralTypeNode as TemplateLiteralType,
+ TypeAliasDeclaration,
+ TypeElement,
+} from 'typescript';
+
+import type { MessengerItemDoc, MethodInfo } from './types';
+
+/**
+ * Check whether a file exists.
+ *
+ * @param filePath - The path to check.
+ * @returns A promise that resolves to true if the file exists.
+ */
+async function fileExists(filePath: string): Promise {
+ try {
+ await fs.access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Extract string constants from top-level variable declarations in a source file.
+ * Only looks at top-level `const x = 'string'` or `const x = 'string' as const`.
+ *
+ * @param sourceFile - The TypeScript source file to extract constants from.
+ * @returns A map of constant name to string value.
+ */
+function extractStringConstants(sourceFile: SourceFile): Map {
+ const names = new Map();
+
+ for (const statement of sourceFile.statements) {
+ if (!isVariableStatement(statement)) {
+ continue;
+ }
+ for (const decl of statement.declarationList.declarations) {
+ if (!isIdentifier(decl.name)) {
+ continue;
+ }
+
+ if (decl.initializer) {
+ const init = decl.initializer;
+ if (isStringLiteral(init)) {
+ names.set(decl.name.text, init.text);
+ } else if (isAsExpression(init) && isStringLiteral(init.expression)) {
+ names.set(decl.name.text, init.expression.text);
+ }
+ }
+
+ // Handle `declare const x: "value"` (common in .d.cts files)
+ if (
+ !decl.initializer &&
+ decl.type &&
+ isLiteralTypeNode(decl.type) &&
+ isStringLiteral(decl.type.literal)
+ ) {
+ names.set(decl.name.text, decl.type.literal.text);
+ }
+ }
+ }
+
+ return names;
+}
+
+/**
+ * Resolve the value of `controllerName` (or similar constant) defined in the
+ * same file or imported from a local `./constants*` module (single-hop only).
+ *
+ * @param sourceFile - The TypeScript source file to search.
+ * @param filePath - The absolute path of the source file on disk.
+ * @returns A promise that resolves to a map of constant name to resolved string value.
+ */
+async function resolveControllerName(
+ sourceFile: SourceFile,
+ filePath: string,
+): Promise