generate-docs.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import fs from 'fs';
  2. import klawSync from 'klaw-sync';
  3. import path from 'path';
  4. import ts from 'typescript';
  5. import { notNullOrUndefined } from '../shared/shared-utils';
  6. // tslint:disable:no-console
  7. interface MethodParameterInfo {
  8. name: string;
  9. type: string;
  10. }
  11. interface MemberInfo {
  12. name: string;
  13. description: string;
  14. type: string;
  15. }
  16. interface PropertyInfo extends MemberInfo {
  17. kind: 'property';
  18. defaultValue: string;
  19. }
  20. interface MethodInfo extends MemberInfo {
  21. kind: 'method';
  22. parameters: MethodParameterInfo[];
  23. }
  24. interface InterfaceInfo {
  25. sourceFile: string;
  26. title: string;
  27. weight: number;
  28. category: string;
  29. description: string;
  30. fileName: string;
  31. members: Array<PropertyInfo | MethodInfo>;
  32. }
  33. type TypeMap = Map<string, string>;
  34. const docsPath = '/docs/api/';
  35. const outputPath = path.join(__dirname, '../docs/content/docs/api');
  36. /**
  37. * This map is used to cache types and their corresponding Hugo path. It is used to enable
  38. * hyperlinking from a member's "type" to the definition of that type.
  39. */
  40. const globalTypeMap: TypeMap = new Map();
  41. const tsFiles = klawSync(path.join(__dirname, '../server/src/'), {
  42. nodir: true,
  43. filter: item => path.extname(item.path) === '.ts',
  44. traverseAll: true,
  45. }).map(item => item.path);
  46. deleteGeneratedDocs();
  47. generateDocs(tsFiles, outputPath, globalTypeMap);
  48. const watchMode = !!process.argv.find(arg => arg === '--watch' || arg === '-w');
  49. if (watchMode) {
  50. console.log(`Watching for changes to source files...`);
  51. tsFiles.forEach(file => {
  52. fs.watchFile(file, { interval: 1000 }, () => {
  53. generateDocs([file], outputPath, globalTypeMap);
  54. });
  55. });
  56. }
  57. /**
  58. * Delete all generated docs found in the outputPath.
  59. */
  60. function deleteGeneratedDocs() {
  61. let deleteCount = 0;
  62. const files = klawSync(outputPath, { nodir: true });
  63. for (const file of files) {
  64. const content = fs.readFileSync(file.path, 'utf-8');
  65. if (isGenerated(content)) {
  66. fs.unlinkSync(file.path);
  67. deleteCount++;
  68. }
  69. }
  70. console.log(`Deleted ${deleteCount} generated docs`);
  71. }
  72. /**
  73. * Returns true if the content matches that of a generated document.
  74. */
  75. function isGenerated(content: string) {
  76. return /generated\: true\n---\n/.test(content);
  77. }
  78. /**
  79. * Uses the TypeScript compiler API to parse the given files and extract out the documentation
  80. * into markdown files
  81. * @param filePaths
  82. * @param typeMap
  83. */
  84. function generateDocs(filePaths: string[], hugoApiDocsPath: string, typeMap: TypeMap) {
  85. const timeStart = +new Date();
  86. const sourceFiles = filePaths.map(filePath => {
  87. return ts.createSourceFile(
  88. filePath,
  89. fs.readFileSync(filePath).toString(),
  90. ts.ScriptTarget.ES2015,
  91. true,
  92. );
  93. });
  94. const interfaces = getStatementsWithSourceFile(sourceFiles)
  95. .filter(s => ts.isInterfaceDeclaration(s.statement))
  96. .map(statement => {
  97. const info = parseInterface(statement.statement as ts.InterfaceDeclaration, statement.sourceFile);
  98. if (info) {
  99. typeMap.set(info.title, info.category + '/' + info.fileName);
  100. }
  101. return info;
  102. })
  103. .filter(notNullOrUndefined);
  104. for (const info of interfaces) {
  105. const markdown = renderInterface(info, typeMap);
  106. const categoryDir = path.join(hugoApiDocsPath, info.category);
  107. const indexFile = path.join(categoryDir, '_index.md');
  108. if (!fs.existsSync(categoryDir)) {
  109. fs.mkdirSync(categoryDir);
  110. }
  111. if (!fs.existsSync(indexFile)) {
  112. const indexFileContent = generateFrontMatter(info.category, 10) + `\n\n# ${info.category}`;
  113. fs.writeFileSync(indexFile, indexFileContent);
  114. }
  115. fs.writeFileSync(path.join(categoryDir, info.fileName + '.md'), markdown);
  116. }
  117. if (interfaces.length) {
  118. console.log(`Generated ${interfaces.length} docs in ${+new Date() - timeStart}ms`);
  119. }
  120. }
  121. /**
  122. * Maps an array of parsed SourceFiles into statements, including a reference to the original file each statement
  123. * came from.
  124. */
  125. function getStatementsWithSourceFile(sourceFiles: ts.SourceFile[]): Array<{ statement: ts.Statement; sourceFile: string }> {
  126. return sourceFiles.reduce(
  127. (st, sf) => {
  128. const statementsWithSources = sf.statements.map(statement => {
  129. const serverSourceRoot = '/server/src';
  130. const sourceFile = sf.fileName.substring(sf.fileName.indexOf(serverSourceRoot));
  131. return {statement, sourceFile };
  132. });
  133. return [...st, ...statementsWithSources];
  134. },
  135. [] as Array<{ statement: ts.Statement; sourceFile: string }>,
  136. );
  137. }
  138. /**
  139. * Parses an InterfaceDeclaration into a simple object which can be rendered into markdown.
  140. */
  141. function parseInterface(statement: ts.InterfaceDeclaration, sourceFile: string): InterfaceInfo | undefined {
  142. const category = getDocsCategory(statement);
  143. if (category === undefined) {
  144. return;
  145. }
  146. const title = statement.name.text;
  147. const weight = getInterfaceWeight(statement);
  148. const description = getInterfaceDescription(statement);
  149. const fileName = title
  150. .split(/(?=[A-Z])/)
  151. .join('-')
  152. .toLowerCase();
  153. const members = parseMembers(statement.members);
  154. return {
  155. sourceFile,
  156. title,
  157. weight,
  158. category,
  159. description,
  160. fileName,
  161. members,
  162. };
  163. }
  164. /**
  165. * Parses an array of inteface members into a simple object which can be rendered into markdown.
  166. */
  167. function parseMembers(members: ts.NodeArray<ts.TypeElement>): Array<PropertyInfo | MethodInfo> {
  168. const result: Array<PropertyInfo | MethodInfo> = [];
  169. for (const member of members) {
  170. if (ts.isPropertySignature(member) || ts.isMethodSignature(member)) {
  171. const name = member.name.getText();
  172. let description = '';
  173. let type = '';
  174. let defaultValue = '';
  175. let parameters: MethodParameterInfo[] = [];
  176. parseTags(member, {
  177. description: tag => (description += tag.comment || ''),
  178. example: tag => (description += formatExampleCode(tag.comment)),
  179. default: tag => (defaultValue = tag.comment || ''),
  180. });
  181. if (member.type) {
  182. type = member.type.getFullText();
  183. }
  184. if (ts.isMethodSignature(member)) {
  185. parameters = member.parameters.map(p => ({
  186. name: p.name.getText(),
  187. type: p.type ? p.type.getFullText() : '',
  188. }));
  189. result.push({
  190. kind: 'method',
  191. name,
  192. description,
  193. type,
  194. parameters,
  195. });
  196. } else {
  197. result.push({
  198. kind: 'property',
  199. name,
  200. description,
  201. type,
  202. defaultValue,
  203. });
  204. }
  205. }
  206. }
  207. return result;
  208. }
  209. function renderInterface(interfaceInfo: InterfaceInfo, knownTypeMap: Map<string, string>): string {
  210. const { title, weight, category, description, members } = interfaceInfo;
  211. let output = '';
  212. output += generateFrontMatter(title, weight);
  213. output += `\n\n# ${title}\n\n`;
  214. output += `{{< generation-info source="${interfaceInfo.sourceFile}">}}\n\n`;
  215. output += `${description}\n\n`;
  216. for (const member of members) {
  217. let defaultParam = '';
  218. let type = '';
  219. if (member.kind === 'property') {
  220. type = renderType(member.type, knownTypeMap);
  221. defaultParam = member.defaultValue ? `default="${member.defaultValue}" ` : '';
  222. } else {
  223. const args = member.parameters
  224. .map(p => {
  225. return `${p.name}: ${renderType(p.type, knownTypeMap)}`;
  226. })
  227. .join(', ');
  228. type = `(${args}) => ${renderType(member.type, knownTypeMap)}`;
  229. }
  230. output += `### ${member.name}\n\n`;
  231. output += `{{< member-info kind="${member.kind}" type="${type}" ${defaultParam}>}}\n\n`;
  232. output += `${member.description}\n\n`;
  233. }
  234. return output;
  235. }
  236. /**
  237. * Extracts the "@docsCategory" value from the JSDoc comments if present.
  238. */
  239. function getDocsCategory(statement: ts.InterfaceDeclaration): string | undefined {
  240. let category: string | undefined;
  241. parseTags(statement, {
  242. docsCategory: tag => (category = tag.comment || ''),
  243. });
  244. return category;
  245. }
  246. /**
  247. * Parses the Node's JSDoc tags and invokes the supplied functions against any matching tag names.
  248. */
  249. function parseTags<T extends ts.Node>(
  250. node: T,
  251. tagMatcher: { [tagName: string]: (tag: ts.JSDocTag) => void },
  252. ): void {
  253. const jsDocTags = ts.getJSDocTags(node);
  254. for (const tag of jsDocTags) {
  255. const tagName = tag.tagName.text;
  256. if (tagMatcher[tagName]) {
  257. tagMatcher[tagName](tag);
  258. }
  259. }
  260. }
  261. /**
  262. * This function takes a string representing a type (e.g. "Array<ShippingMethod>") and turns
  263. * and known types (e.g. "ShippingMethod") into hyperlinks.
  264. */
  265. function renderType(type: string, knownTypeMap: Map<string, string>): string {
  266. let typeText = type
  267. .trim()
  268. // encode HTML entities
  269. .replace(/[\u00A0-\u9999<>\&]/gim, i => '&#' + i.charCodeAt(0) + ';')
  270. // remove newlines
  271. .replace(/\n/g, ' ');
  272. for (const [key, val] of knownTypeMap) {
  273. typeText = typeText.replace(key, `<a href='${docsPath}/${val}/'>${key}</a>`);
  274. }
  275. return typeText;
  276. }
  277. /**
  278. * Generates the Hugo front matter with the title of the document
  279. */
  280. function generateFrontMatter(title: string, weight: number): string {
  281. return `---
  282. title: "${title}"
  283. weight: ${weight}
  284. date: ${new Date().toISOString()}
  285. generated: true
  286. ---
  287. <!-- This file was generated from the Vendure TypeScript source. Do not modify. Instead, re-run "generate-docs" -->
  288. `;
  289. }
  290. /**
  291. * Reads the @docsWeight JSDoc tag from the interface.
  292. */
  293. function getInterfaceWeight(statement: ts.InterfaceDeclaration): number {
  294. let weight = 10;
  295. parseTags(statement, {
  296. docsWeight: tag => (weight = Number.parseInt(tag.comment || '10', 10)),
  297. });
  298. return weight;
  299. }
  300. /**
  301. * Reads the @description JSDoc tag from the interface.
  302. */
  303. function getInterfaceDescription(statement: ts.InterfaceDeclaration): string {
  304. let description = '';
  305. parseTags(statement, {
  306. description: tag => (description += tag.comment),
  307. });
  308. return description;
  309. }
  310. /**
  311. * Cleans up a JSDoc "@example" block by removing leading whitespace and asterisk (TypeScript has an open issue
  312. * wherein the asterisks are not stripped as they should be, see https://github.com/Microsoft/TypeScript/issues/23517)
  313. */
  314. function formatExampleCode(example: string = ''): string {
  315. return '\n\n' + example.replace(/\n\s+\*\s/g, '');
  316. }