generate-docs.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import fs from 'fs';
  2. import klawSync from 'klaw-sync';
  3. import path from 'path';
  4. import ts from 'typescript';
  5. // tslint:disable:no-console
  6. interface MemberInfo {
  7. name: string;
  8. description: string;
  9. type: string;
  10. defaultValue: string;
  11. }
  12. interface InterfaceInfo {
  13. title: string;
  14. weight: number;
  15. category: string;
  16. description: string;
  17. fileName: string;
  18. members: MemberInfo[];
  19. }
  20. const docsPath = '/docs/api/';
  21. const outputPath = path.join(__dirname, '../docs/content/docs/api');
  22. const vendureConfig = path.join(__dirname, '../server/src/config/vendure-config.ts');
  23. deleteGeneratedDocs();
  24. generateDocs();
  25. console.log(`Watching for changes to ${vendureConfig}`);
  26. fs.watchFile(vendureConfig, { interval: 1000 }, () => {
  27. generateDocs();
  28. });
  29. /**
  30. * Delete all generated docs found in the outputPath.
  31. */
  32. function deleteGeneratedDocs() {
  33. let deleteCount = 0;
  34. const files = klawSync(outputPath, { nodir: true });
  35. for (const file of files) {
  36. const content = fs.readFileSync(file.path, 'utf-8');
  37. if (isGenerated(content)) {
  38. fs.unlinkSync(file.path);
  39. deleteCount ++;
  40. }
  41. }
  42. console.log(`Deleted ${deleteCount} generated docs`);
  43. }
  44. function isGenerated(content: string) {
  45. return /generated\: true\n---\n/.test(content);
  46. }
  47. function generateDocs() {
  48. const timeStart = +new Date();
  49. const sourceFile = ts.createSourceFile(
  50. vendureConfig,
  51. fs.readFileSync(vendureConfig).toString(),
  52. ts.ScriptTarget.ES2015,
  53. true,
  54. );
  55. const knownTypeMap = new Map<string, string>();
  56. const interfaces = [...sourceFile.statements]
  57. .filter(ts.isInterfaceDeclaration)
  58. .map(statement => {
  59. const info = parseInterface(statement);
  60. knownTypeMap.set(info.title, info.category + '/' + info.fileName);
  61. return info;
  62. });
  63. for (const info of interfaces) {
  64. const markdown = renderInterface(info, knownTypeMap);
  65. fs.writeFileSync(path.join(outputPath, info.category, info.fileName + '.md'), markdown);
  66. }
  67. console.log(`Generated ${interfaces.length} docs in ${+new Date() - timeStart}ms`);
  68. }
  69. /**
  70. * Parses an InterfaceDeclaration into a simple object which can be rendered into markdown.
  71. */
  72. function parseInterface(statement: ts.InterfaceDeclaration): InterfaceInfo {
  73. const title = statement.name.text;
  74. const weight = getInterfaceWeight(statement);
  75. const category = getDocsCategory(statement);
  76. const description = getInterfaceDescription(statement);
  77. const fileName = title.split(/(?=[A-Z])/).join('-').toLowerCase();
  78. const members = parseMembers(statement.members);
  79. return {
  80. title,
  81. weight,
  82. category,
  83. description,
  84. fileName,
  85. members,
  86. };
  87. }
  88. /**
  89. * Parses an array of inteface members into a simple object which can be rendered into markdown.
  90. */
  91. function parseMembers(members: ts.NodeArray<ts.TypeElement>): MemberInfo[] {
  92. const result: MemberInfo[] = [];
  93. for (const member of members) {
  94. if (ts.isPropertySignature(member)) {
  95. const name = member.name.getText();
  96. let description = '';
  97. let type = '';
  98. let defaultValue = '';
  99. parseTags(member, {
  100. description: tag => description += tag.comment || '',
  101. example: tag => description += formatExampleCode(tag.comment),
  102. default: tag => defaultValue = tag.comment || '',
  103. });
  104. if (member.type) {
  105. type = member.type.getFullText();
  106. }
  107. result.push({
  108. name,
  109. description,
  110. type,
  111. defaultValue,
  112. });
  113. }
  114. }
  115. return result;
  116. }
  117. function renderInterface(interfaceInfo: InterfaceInfo, knownTypeMap: Map<string, string>): string {
  118. const { title, weight, category, description, members } = interfaceInfo;
  119. let output = '';
  120. output += generateFrontMatter(title, weight);
  121. output += `\n\n# ${title}\n\n`;
  122. output += `${description}\n\n`;
  123. for (const member of members) {
  124. const type = renderType(member.type, knownTypeMap);
  125. output += `### ${member.name}\n\n`;
  126. output += `{{< member-info type="${type}" ${member.defaultValue ? `default="${member.defaultValue}" ` : ''}>}}\n\n`;
  127. output += `${member.description}\n\n`;
  128. }
  129. return output;
  130. }
  131. /**
  132. * Extracts the "@docsCategory" value from the JSDoc comments if present.
  133. */
  134. function getDocsCategory(statement: ts.InterfaceDeclaration): string {
  135. let category = '';
  136. parseTags(statement, {
  137. docsCategory: tag => category = tag.comment || '',
  138. });
  139. return category;
  140. }
  141. /**
  142. * Parses the Node's JSDoc tags and invokes the supplied functions against any matching tag names.
  143. */
  144. function parseTags<T extends ts.Node>(node: T, tagMatcher: { [tagName: string]: (tag: ts.JSDocTag) => void; }): void {
  145. const jsDocTags = ts.getJSDocTags(node);
  146. for (const tag of jsDocTags) {
  147. const tagName = tag.tagName.text;
  148. if (tagMatcher[tagName]) {
  149. tagMatcher[tagName](tag);
  150. }
  151. }
  152. }
  153. function renderType(type: string, knownTypeMap: Map<string, string>): string {
  154. let typeText = type.trim().replace(/[\u00A0-\u9999<>\&]/gim, i => {
  155. return '&#' + i.charCodeAt(0) + ';';
  156. });
  157. for (const [key, val] of knownTypeMap) {
  158. typeText = typeText.replace(key, `<a href='${docsPath}${val}/'>${key}</a>`);
  159. }
  160. return typeText;
  161. }
  162. /**
  163. * Generates the Hugo front matter with the title of the document
  164. */
  165. function generateFrontMatter(title: string, weight: number): string {
  166. return `---
  167. title: "${title}"
  168. weight: ${weight}
  169. generated: true
  170. ---
  171. <!-- This file was generated from the Vendure TypeScript source. Do not modify. Instead, re-run "generate-docs" -->
  172. `;
  173. }
  174. /**
  175. * Reads the @docsWeight JSDoc tag from the interface.
  176. */
  177. function getInterfaceWeight(statement: ts.InterfaceDeclaration): number {
  178. let weight = 10;
  179. parseTags(statement, {
  180. docsWeight: tag => weight = Number.parseInt(tag.comment || '10', 10),
  181. });
  182. return weight;
  183. }
  184. /**
  185. * Reads the @description JSDoc tag from the interface.
  186. */
  187. function getInterfaceDescription(statement: ts.InterfaceDeclaration): string {
  188. let description = '';
  189. parseTags(statement, {
  190. description: tag => description += tag.comment,
  191. });
  192. return description;
  193. }
  194. /**
  195. * Cleans up a JSDoc "@example" block by removing leading whitespace and asterisk (TypeScript has an open issue
  196. * wherein the asterisks are not stripped as they should be, see https://github.com/Microsoft/TypeScript/issues/23517)
  197. */
  198. function formatExampleCode(example: string = ''): string {
  199. return '\n\n' + example.replace(/\n\s+\*\s/g, '');
  200. }