generate-docs.ts 9.3 KB

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