diff --git a/README.md b/README.md index c4fc051..326bf62 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,14 @@ JSDoc accepts plugins by simply installing their npm package: To configure JSDoc to use the plugin, add the following to the JSDoc configuration file, e.g. `conf.json`: -```json +```jsonc "plugins": [ "jsdoc-plugin-typescript" ], -"typescript": { - "moduleRoot": "src" -} ``` See http://usejsdoc.org/about-configuring-jsdoc.html for more details on how to configure JSDoc. -In the above snippet, `"src"` is the directory that contains the source files. Inside that directory, each `.js` file needs a `@module` annotation with a path relative to that `"moduleRoot"`, e.g. - -```js -/** @module ol/proj **/ -``` - ## What this plugin does When using the `class` keyword for defining classes (required by TypeScript), JSDoc requires `@classdesc` and `@extends` annotations. With this plugin, no `@classdesc` and `@extends` annotations are needed. @@ -40,6 +31,7 @@ TypeScript and JSDoc use a different syntax for imported types. This plugin conv ### TypeScript **Named export:** + ```js /** * @type {import("./path/to/module").exportName} @@ -47,6 +39,7 @@ TypeScript and JSDoc use a different syntax for imported types. This plugin conv ``` **Default export:** + ```js /** * @type {import("./path/to/module").default} @@ -54,6 +47,7 @@ TypeScript and JSDoc use a different syntax for imported types. This plugin conv ``` **typeof type:** + ```js /** * @type {typeof import("./path/to/module").exportName} @@ -61,10 +55,12 @@ TypeScript and JSDoc use a different syntax for imported types. This plugin conv ``` **Template literal type** + ```js /** * @type {`static:${dynamic}`} */ +``` **@override annotations** @@ -73,6 +69,7 @@ are removed because they make JSDoc stop inheritance ### JSDoc **Named export:** + ```js /** * @type {module:path/to/module.exportName} @@ -80,6 +77,7 @@ are removed because they make JSDoc stop inheritance ``` **Default export assigned to a variable in the exporting module:** + ```js /** * @type {module:path/to/module~variableOfDefaultExport} @@ -89,6 +87,7 @@ are removed because they make JSDoc stop inheritance This syntax is also used when referring to types of `@typedef`s and `@enum`s. **Anonymous default export:** + ```js /** * @type {module:path/to/module} @@ -96,6 +95,7 @@ This syntax is also used when referring to types of `@typedef`s and `@enum`s. ``` **typeof type:** + ```js /** * @type {Class} @@ -103,12 +103,21 @@ This syntax is also used when referring to types of `@typedef`s and `@enum`s. ``` **Template literal type** + ```js /** * @type {'static:${dynamic}'} */ ``` +## Module id resolution + +For resolving module ids, this plugin mirrors the method used by JSDoc: + +1. Parse the referenced module for an `@module` tag. +2. If a tag is found and it has an explicit id, use that. +3. If a tag is found, but it doesn't have an explicit id, use the module's file path relative to the nearest shared parent directory, and remove the file extension. + ## Contributing If you are interested in making a contribution to the project, please see the [contributing page](./contributing.md) for details on getting your development environment set up. diff --git a/index.js b/index.js index d9e8174..d7e7b44 100644 --- a/index.js +++ b/index.js @@ -4,60 +4,132 @@ const fs = require('fs'); const env = require('jsdoc/env'); // eslint-disable-line import/no-unresolved const addInherited = require('jsdoc/augment').addInherited; // eslint-disable-line import/no-unresolved -const config = env.conf.typescript; -if (!config) { - throw new Error( - 'Configuration "typescript" for jsdoc-plugin-typescript missing.', - ); -} -if (!('moduleRoot' in config)) { - throw new Error( - 'Configuration "typescript.moduleRoot" for jsdoc-plugin-typescript missing.', - ); -} -const moduleRoot = config.moduleRoot; -const moduleRootAbsolute = path.join(process.cwd(), moduleRoot); -if (!fs.existsSync(moduleRootAbsolute)) { - throw new Error( - 'Directory "' + - moduleRootAbsolute + - '" does not exist. Check the "typescript.moduleRoot" config option for jsdoc-plugin-typescript', - ); -} - const importRegEx = /import\(["']([^"']*)["']\)(?:\.([^ \.\|\}><,\)=#\n]*))?([ \.\|\}><,\)=#\n])/g; const typedefRegEx = /@typedef \{[^\}]*\} (\S+)/g; const noClassdescRegEx = /@(typedef|module|type)/; const extensionReplaceRegEx = /\.m?js$/; +const extensionEnsureRegEx = /(\.js)?$/; const slashRegEx = /\\/g; const moduleInfos = {}; const fileNodes = {}; +const resolvedPathCache = new Set(); + +/** @type {string} */ +let implicitModuleRoot; + +/** + * Without explicit module ids, JSDoc will use the nearest shared parent directory. + * @return {string} The implicit root path with which to resolve all module ids against. + */ +function getImplicitModuleRoot() { + if (implicitModuleRoot) { + return implicitModuleRoot; + } + + if (!env.sourceFiles || env.sourceFiles.length === 0) { + return process.cwd(); + } + + // Find the nearest shared parent directory + implicitModuleRoot = path.dirname(env.sourceFiles[0]); + + env.sourceFiles.slice(1).forEach((filePath) => { + if (filePath.startsWith(implicitModuleRoot)) { + return; + } + + const currParts = filePath.split(path.sep); + const nearestParts = implicitModuleRoot.split(path.sep); -function getExtension(absolutePath) { - return extensionReplaceRegEx.test(absolutePath) - ? extensionReplaceRegEx.exec(absolutePath)[0] - : '.js'; + for (let i = 0; i < currParts.length; ++i) { + if (currParts[i] !== nearestParts[i]) { + implicitModuleRoot = currParts.slice(0, i).join(path.sep); + + return; + } + } + }); + + return implicitModuleRoot; } -function getModuleInfo(moduleId, extension, parser) { - if (!moduleInfos[moduleId]) { - if (!fileNodes[moduleId]) { - const absolutePath = path.join( - process.cwd(), - moduleRoot, - moduleId + extension, - ); - if (!fs.existsSync(absolutePath)) { - return null; +function getModuleId(modulePath) { + if (moduleInfos[modulePath]) { + return moduleInfos[modulePath].id; + } + + // Search for explicit module id + if (fileNodes[modulePath]) { + for (const comment of fileNodes[modulePath].comments) { + if (!/@module(?=\s)/.test(comment.value)) { + continue; } - const file = fs.readFileSync(absolutePath, 'UTF-8'); - fileNodes[moduleId] = parser.astBuilder.build(file, absolutePath); + + const explicitModuleId = comment.value + .split(/@module(?=\s)/)[1] + .split(/\n+\s*\*\s*@\w+/)[0] // Split before the next tag + .replace(/\n+\s*\*|\{[^\}]*\}/g, '') // Remove new lines with asterisks, and type annotations + .trim(); + + if (explicitModuleId) { + return explicitModuleId; + } + } + } + + return path + .relative(getImplicitModuleRoot(), modulePath) + .replace(extensionReplaceRegEx, ''); +} + +/** + * Checks for the existence of `modulePath`, and if it doesn't exist, checks for `modulePath/index.js`. + * @param {string} from The path to the module. + * @param {string} to The path to the module. + * @return {string | null} The resolved path or null if it can't be resolved. + */ +function getResolvedPath(from, to) { + let resolvedPath = path.resolve(from, to); + + if (resolvedPathCache.has(resolvedPath) || fs.existsSync(resolvedPath)) { + resolvedPathCache.add(resolvedPath); + + return resolvedPath; + } + + resolvedPath = resolvedPath.replace(extensionEnsureRegEx, '/index.js'); + + if (resolvedPathCache.has(resolvedPath) || fs.existsSync(resolvedPath)) { + resolvedPathCache.add(resolvedPath); + + return resolvedPath; + } + + return null; +} + +function getModuleInfo(modulePath, parser) { + if (!modulePath) { + return null; + } + + if (!moduleInfos[modulePath]) { + if (!fileNodes[modulePath]) { + const file = fs.readFileSync(modulePath, 'UTF-8'); + + fileNodes[modulePath] = parser.astBuilder.build(file, modulePath); } - moduleInfos[moduleId] = {namedExports: {}}; - const moduleInfo = moduleInfos[moduleId]; - const node = fileNodes[moduleId]; + + moduleInfos[modulePath] = { + id: getModuleId(modulePath), + namedExports: {}, + }; + + const moduleInfo = moduleInfos[modulePath]; + const node = fileNodes[modulePath]; + if (node.program && node.program.body) { const classDeclarations = {}; const nodes = node.program.body; @@ -80,15 +152,20 @@ function getModuleInfo(moduleId, extension, parser) { } } } - return moduleInfos[moduleId]; + + return moduleInfos[modulePath]; } -function getDefaultExportName(moduleId, parser) { - return getModuleInfo(moduleId, parser).defaultExport; +function getDefaultExportName(modulePath) { + return getModuleInfo(modulePath).defaultExport; } -function getDelimiter(moduleId, symbol, parser) { - return getModuleInfo(moduleId, parser).namedExports[symbol] ? '.' : '~'; +function getDelimiter(modulePath, symbol) { + return getModuleInfo(modulePath).namedExports[symbol] ? '.' : '~'; +} + +function ensureJsExt(filePath) { + return filePath.replace(extensionEnsureRegEx, '.js'); } exports.defineTags = function (dictionary) { @@ -142,10 +219,7 @@ exports.defineTags = function (dictionary) { exports.astNodeVisitor = { visitNode: function (node, e, parser, currentSourceName) { if (node.type === 'File') { - const modulePath = path - .relative(path.join(process.cwd(), moduleRoot), currentSourceName) - .replace(extensionReplaceRegEx, ''); - fileNodes[modulePath] = node; + fileNodes[currentSourceName] = node; const identifiers = {}; if (node.program && node.program.body) { const nodes = node.program.body; @@ -260,25 +334,24 @@ exports.astNodeVisitor = { lines.push(lines[lines.length - 1]); const identifier = identifiers[node.superClass.name]; if (identifier) { - const absolutePath = path.resolve( + const absolutePath = getResolvedPath( path.dirname(currentSourceName), - identifier.value, + ensureJsExt(identifier.value), ); - // default to js extension since .js extention is assumed implicitly - const extension = getExtension(absolutePath); - const moduleId = path - .relative(path.join(process.cwd(), moduleRoot), absolutePath) - .replace(extensionReplaceRegEx, ''); - if (getModuleInfo(moduleId, extension, parser)) { + const moduleInfo = getModuleInfo(absolutePath, parser); + + if (moduleInfo) { const exportName = identifier.defaultImport - ? getDefaultExportName(moduleId, parser) + ? getDefaultExportName(absolutePath) : node.superClass.name; + const delimiter = identifier.defaultImport ? '~' - : getDelimiter(moduleId, exportName, parser); + : getDelimiter(absolutePath, exportName); + lines[lines.length - 2] = ' * @extends ' + - `module:${moduleId.replace(slashRegEx, '/')}${ + `module:${moduleInfo.id.replace(slashRegEx, '/')}${ exportName ? delimiter + exportName : '' }`; } @@ -330,27 +403,26 @@ exports.astNodeVisitor = { replaceAttempt = 0; } lastImportPath = importExpression; - const rel = path.resolve( + + const rel = getResolvedPath( path.dirname(currentSourceName), - importSource, + ensureJsExt(importSource), ); - // default to js extension since .js extention is assumed implicitly - const extension = getExtension(rel); - const moduleId = path - .relative(path.join(process.cwd(), moduleRoot), rel) - .replace(extensionReplaceRegEx, ''); - if (getModuleInfo(moduleId, extension, parser)) { + const moduleInfo = getModuleInfo(rel, parser); + + if (moduleInfo) { const name = exportName === 'default' - ? getDefaultExportName(moduleId, parser) + ? getDefaultExportName(rel) : exportName; + const delimiter = - exportName === 'default' - ? '~' - : getDelimiter(moduleId, name, parser); - replacement = `module:${moduleId.replace(slashRegEx, '/')}${ - name ? delimiter + name : '' - }`; + exportName === 'default' ? '~' : getDelimiter(rel, name); + + replacement = `module:${moduleInfo.id.replace( + slashRegEx, + '/', + )}${name ? delimiter + name : ''}`; } } if (replacement) { @@ -390,26 +462,26 @@ exports.astNodeVisitor = { function replace(regex) { if (regex.test(comment.value)) { const identifier = identifiers[key]; - const absolutePath = path.resolve( + const absolutePath = getResolvedPath( path.dirname(currentSourceName), - identifier.value, + ensureJsExt(identifier.value), ); - // default to js extension since .js extention is assumed implicitly - const extension = getExtension(absolutePath); - const moduleId = path - .relative(path.join(process.cwd(), moduleRoot), absolutePath) - .replace(extensionReplaceRegEx, ''); - if (getModuleInfo(moduleId, extension, parser)) { + const moduleInfo = getModuleInfo(absolutePath, parser); + + if (moduleInfo) { const exportName = identifier.defaultImport - ? getDefaultExportName(moduleId, parser) + ? getDefaultExportName(absolutePath) : key; + const delimiter = identifier.defaultImport ? '~' - : getDelimiter(moduleId, exportName, parser); - const replacement = `module:${moduleId.replace( + : getDelimiter(absolutePath, exportName); + + const replacement = `module:${moduleInfo.id.replace( slashRegEx, '/', )}${exportName ? delimiter + exportName : ''}`; + comment.value = comment.value.replace( regex, '@$1' + replacement + '$2', diff --git a/test/dest/expected.json b/test/dest/expected.json index b616185..c46750a 100644 --- a/test/dest/expected.json +++ b/test/dest/expected.json @@ -144,7 +144,7 @@ "params": [] }, { - "comment": "/**\n * @param {number} number A number.\n * @return {module:sub/NumberStore~NumberStore} A number store.\n */", + "comment": "/**\n * @param {number} number A number.\n * @return {module:test/sub/NumberStore~NumberStore} A number store.\n */", "meta": { "range": [ 495, @@ -176,7 +176,7 @@ { "type": { "names": [ - "module:sub/NumberStore~NumberStore" + "module:test/sub/NumberStore~NumberStore" ] }, "description": "A number store." @@ -306,7 +306,7 @@ "undocumented": true }, { - "comment": "/**\n * @param {module:sub/NumberStore~Options} options The options.\n */", + "comment": "/**\n * @param {module:test/sub/NumberStore~Options} options The options.\n */", "meta": { "range": [ 231, @@ -330,7 +330,7 @@ { "type": { "names": [ - "module:sub/NumberStore~Options" + "module:test/sub/NumberStore~Options" ] }, "description": "The options.", @@ -372,7 +372,7 @@ { "type": { "names": [ - "module:sub/NumberStore~Options" + "module:test/sub/NumberStore~Options" ] }, "description": "The options.", @@ -465,4 +465,4 @@ "longname": "module:test/sub/NumberStore", "kind": "member" } -] +] \ No newline at end of file diff --git a/test/template/config.json b/test/template/config.json index 14b4f3c..402840d 100644 --- a/test/template/config.json +++ b/test/template/config.json @@ -5,17 +5,10 @@ "destination": "test/dest/actual.json" }, "source": { - "include": [ - "test/src" - ] + "include": ["test/src"] }, "tags": { "allowUnknownTags": true }, - "plugins": [ - "index.js" - ], - "typescript": { - "moduleRoot": "test/src" - } + "plugins": ["index.js"] }