UNPKG

10.1 kBPlain TextView Raw
1import * as ts from 'typescript';
2import * as path from 'path';
3import * as fs from 'fs';
4
5import { Component, ConverterComponent } from 'typedoc/dist/lib/converter/components';
6import { Context } from 'typedoc/dist/lib/converter/context';
7import { Converter } from 'typedoc/dist/lib/converter/converter';
8import { Comment } from 'typedoc/dist/lib/models';
9import { Reflection, ReflectionKind } from 'typedoc/dist/lib/models/reflections/abstract';
10import { ContainerReflection } from 'typedoc/dist/lib/models/reflections/container';
11import { DeclarationReflection } from 'typedoc/dist/lib/models/reflections/declaration';
12import {
13 createChildReflection,
14 isModuleOrNamespace,
15 removeReflection,
16 removeTags,
17 updateSymbolMapping,
18} from './typedocVersionCompatibility';
19import { getRawComment } from './getRawComment';
20
21const PLUGIN = 'typedoc-plugin-external-module-name';
22const CUSTOM_SCRIPT_FILENAMES = [`.${PLUGIN}.js`, `.${PLUGIN}.cjs`, `.${PLUGIN}.mjs`];
23
24type CustomModuleNameMappingFn = (
25 explicitModuleAnnotation: string,
26 implicitFromDirectory: string,
27 path: string,
28 reflection: Reflection,
29 context: Context,
30) => string;
31
32/**
33 * This plugin allows an ES6 module to specify its TypeDoc name.
34 * It also allows multiple ES6 modules to be merged together into a single TypeDoc module.
35 *
36 * @usage
37 * At the top of an ES6 module, add a "dynamic module comment". Insert "@module typedocModuleName" to
38 * specify that this ES6 module should be merged with module: "typedocModuleName".
39 *
40 * Similar to the [[DynamicModulePlugin]], ensure that there is a comment tag (even blank) for the
41 * first symbol in the file.
42 *
43 * @example
44 * ```
45 *
46 * /**
47 * * @module newModuleName
48 * */
49 * /** for typedoc /
50 * import {foo} from "../foo";
51 * export let bar = "bar";
52 * ```
53 *
54 * Also similar to [[DynamicModulePlugin]], if @preferred is found in a dynamic module comment, the comment
55 * will be used as the module comment, and documentation will be generated from it (note: this plugin does not
56 * attempt to count lengths of merged module comments in order to guess the best one)
57 */
58@Component({ name: 'external-module-name' })
59export class ExternalModuleNamePlugin extends ConverterComponent {
60 /** List of module reflections which are models to rename */
61 private moduleRenames: ModuleRename[] = [];
62 private baseDir = '';
63 private customGetModuleNameFn: CustomModuleNameMappingFn;
64 private defaultGetModuleNameFn: CustomModuleNameMappingFn = (match, guess) => match || guess;
65 private disableAutoModuleName = false;
66
67 initialize() {
68 this.listenTo(this.owner, {
69 [Converter.EVENT_BEGIN]: this.onBegin,
70 [Converter.EVENT_CREATE_DECLARATION]: this.onDeclaration,
71 [Converter.EVENT_RESOLVE_BEGIN]: this.onBeginResolve,
72 });
73
74 for (const filename of CUSTOM_SCRIPT_FILENAMES) {
75 const pathToScript = path.join(process.cwd(), filename);
76 try {
77 if (fs.existsSync(pathToScript)) {
78 const relativePath = path.relative(__dirname, pathToScript);
79 this.customGetModuleNameFn = require(relativePath);
80 console.log(`${PLUGIN}: Using custom module name mapping function from ${pathToScript}`);
81 return;
82 }
83 } catch (error) {
84 console.error(`${PLUGIN}: Failed to load custom module name mapping function from ${pathToScript}`);
85 throw error;
86 }
87 }
88 }
89
90 private onBegin(context: Context) {
91 /** Get the program entry points */
92 const dir = context.program.getCurrentDirectory();
93 const rootFileNames = context.program.getRootFileNames() ?? [];
94 const options = context.getCompilerOptions();
95
96 function commonPrefix(string1: string, string2: string) {
97 let idx = 0;
98 while (idx < string1.length && string1[idx] === string2[idx]) {
99 idx++;
100 }
101 return string1.substr(0, idx);
102 }
103
104 const commonParent = rootFileNames.reduce(
105 (acc, entry) => commonPrefix(acc, path.dirname(path.resolve(dir, entry))),
106 path.resolve(rootFileNames[0] ?? dir),
107 );
108
109 this.baseDir = options.rootDir || options.baseUrl || commonParent;
110
111 /** Process options */
112 const option = this.application.options.getValue('disableAutoModuleName');
113 let disableSources: boolean;
114 try {
115 disableSources = this.application.options.getValue('disableSources');
116 } catch (ignored) {}
117 this.disableAutoModuleName = option === 'true' || option === true || disableSources === true;
118 }
119
120 /**
121 * Gets the module name for a reflection
122 *
123 * Order of precedence:
124 * 1) custom function found in .typedoc-plugin-external-module-name.js
125 * 2) explicit @module tag
126 * 3) auto-create a module name based on the directory
127 */
128 private getModuleName(context: Context, reflection: Reflection, node): [string, boolean] {
129 const comment = getRawComment(node);
130 const preferred = /@preferred/.exec(comment) !== null;
131 // Look for @module
132 const [, match] = /@module\s+([\w\u4e00-\u9fa5\.\-_/@"]+)/.exec(comment) || [];
133
134 let guess: string;
135 let filename: string;
136 if (!this.disableAutoModuleName) {
137 // Make a guess based on enclosing directory structure
138 filename = reflection.sources[0].file.fullFileName;
139 guess = this.disableAutoModuleName ? undefined : path.dirname(path.relative(this.baseDir, filename));
140
141 if (guess === '.') {
142 guess = 'root';
143 }
144 }
145
146 // Try the custom function
147 const mapper: CustomModuleNameMappingFn = this.customGetModuleNameFn || this.defaultGetModuleNameFn;
148 const moduleName = mapper(match, guess, filename, reflection, context);
149 return [moduleName, preferred];
150 }
151
152 /**
153 * Process a reflection.
154 * Determine the module name and add it to a list of renames
155 */
156 private onDeclaration(context: Context, reflection: Reflection, node?) {
157 if (isModuleOrNamespace(reflection)) {
158 const [moduleName, preferred] = this.getModuleName(context, reflection, node);
159 if (moduleName) {
160 // Set up a list of renames operations to perform when the resolve phase starts
161 this.moduleRenames.push({
162 renameTo: moduleName,
163 preferred: preferred,
164 symbol: node.symbol,
165 reflection: <ContainerReflection>reflection,
166 });
167 }
168 }
169
170 // Remove the tags
171 if (reflection.comment) {
172 removeTags(reflection.comment, 'module');
173 removeTags(reflection.comment, 'preferred');
174 if (isEmptyComment(reflection.comment)) {
175 delete reflection.comment;
176 }
177 }
178 }
179
180 /**
181 * OK, we saw all the reflections.
182 * Now process the renames
183 */
184 private onBeginResolve(context: Context) {
185 let projRefs = context.project.reflections;
186 let refsArray: Reflection[] = Object.values(projRefs);
187
188 // Process each rename
189 this.moduleRenames.forEach((item) => {
190 let renaming = item.reflection as ContainerReflection;
191
192 // Find or create the module tree until the child's parent (each level is separated by .)
193 let nameParts = item.renameTo.split('.');
194 let parent: ContainerReflection = context.project;
195 for (let i = 0; i < nameParts.length - 1; ++i) {
196 let child: DeclarationReflection = parent.children.filter((ref) => ref.name === nameParts[i])[0];
197 if (!child) {
198 child = createChildReflection(parent, nameParts[i]);
199 child.parent = parent;
200 child.children = [];
201 context.project.reflections[child.id] = child;
202 parent.children.push(child);
203 }
204 parent = child;
205 }
206
207 // Find an existing module with the child's name in the last parent. Use it as the merge target.
208 let mergeTarget = parent.children.filter(
209 (ref) => ref.kind === renaming.kind && ref.name === nameParts[nameParts.length - 1],
210 )[0] as ContainerReflection;
211
212 // If there wasn't a merge target, change the name of the current module, connect it to the right parent and exit.
213 if (!mergeTarget) {
214 renaming.name = nameParts[nameParts.length - 1];
215 let oldParent = <ContainerReflection>renaming.parent;
216 for (let i = 0; i < oldParent.children.length; ++i) {
217 if (oldParent.children[i] === renaming) {
218 oldParent.children.splice(i, 1);
219 break;
220 }
221 }
222 item.reflection.parent = parent;
223 parent.children.push(<DeclarationReflection>renaming);
224 updateSymbolMapping(context, item.symbol, parent);
225 return;
226 }
227
228 updateSymbolMapping(context, item.symbol, mergeTarget);
229 if (!mergeTarget.children) {
230 mergeTarget.children = [];
231 }
232
233 // Since there is a merge target, relocate all the renaming module's children to the mergeTarget.
234 let childrenOfRenamed = refsArray.filter((ref) => ref.parent === renaming);
235 childrenOfRenamed.forEach((ref: Reflection) => {
236 // update links in both directions
237 ref.parent = mergeTarget;
238 mergeTarget.children.push(<any>ref);
239 });
240
241 // If @preferred was found on the current item, update the mergeTarget's comment
242 // with comment from the renaming module
243 if (item.preferred) mergeTarget.comment = renaming.comment;
244
245 // Now that all the children have been relocated to the mergeTarget, delete the empty module
246 // Make sure the module being renamed doesn't have children, or they will be deleted
247 if (renaming.children) renaming.children.length = 0;
248 removeReflection(context.project, renaming);
249
250 // Remove @module and @preferred from the comment, if found.
251 if (mergeTarget.comment) {
252 removeTags(mergeTarget.comment, 'module');
253 removeTags(mergeTarget.comment, 'preferred');
254 }
255 if (isEmptyComment(mergeTarget.comment)) {
256 delete mergeTarget.comment;
257 }
258 });
259 }
260}
261
262function isEmptyComment(comment: Comment) {
263 return !comment || (!comment.text && !comment.shortText && (!comment.tags || comment.tags.length === 0));
264}
265
266interface ModuleRename {
267 renameTo: string;
268 preferred: boolean;
269 symbol: ts.Symbol;
270 reflection: ContainerReflection;
271}