generate-docs.ts 9.8 KB

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