generate-config-docs.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. import fs from 'fs';
  2. import klawSync from 'klaw-sync';
  3. import path from 'path';
  4. import ts from 'typescript';
  5. import { assertNever, notNullOrUndefined } from '../shared/shared-utils';
  6. import { deleteGeneratedDocs, generateFrontMatter } from './docgen-utils';
  7. // The absolute URL to the generated docs section
  8. const docsUrl = '/docs/configuration/';
  9. // The directory in which the markdown files will be saved
  10. const outputPath = path.join(__dirname, '../docs/content/docs/configuration');
  11. // The directories to scan for TypeScript source files
  12. const tsSourceDirs = ['/server/src/', '/shared/'];
  13. // tslint:disable:no-console
  14. interface MethodParameterInfo {
  15. name: string;
  16. type: string;
  17. }
  18. interface MemberInfo {
  19. name: string;
  20. description: string;
  21. type: string;
  22. fullText: string;
  23. }
  24. interface PropertyInfo extends MemberInfo {
  25. kind: 'property';
  26. defaultValue: string;
  27. }
  28. interface MethodInfo extends MemberInfo {
  29. kind: 'method';
  30. parameters: MethodParameterInfo[];
  31. }
  32. interface DeclarationInfo {
  33. sourceFile: string;
  34. sourceLine: number;
  35. title: string;
  36. fullText: string;
  37. weight: number;
  38. category: string;
  39. description: string;
  40. fileName: string;
  41. }
  42. interface InterfaceInfo extends DeclarationInfo {
  43. kind: 'interface';
  44. extends?: string;
  45. members: Array<PropertyInfo | MethodInfo>;
  46. }
  47. interface ClassInfo extends DeclarationInfo {
  48. kind: 'class';
  49. implements?: string;
  50. extends?: string;
  51. members: Array<PropertyInfo | MethodInfo>;
  52. }
  53. interface TypeAliasInfo extends DeclarationInfo {
  54. kind: 'typeAlias';
  55. members?: Array<PropertyInfo | MethodInfo>;
  56. type: ts.TypeNode;
  57. }
  58. type ValidDeclaration = ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.ClassDeclaration;
  59. type TypeMap = Map<string, string>;
  60. /**
  61. * This map is used to cache types and their corresponding Hugo path. It is used to enable
  62. * hyperlinking from a member's "type" to the definition of that type.
  63. */
  64. const globalTypeMap: TypeMap = new Map();
  65. const tsFiles = tsSourceDirs
  66. .map(scanPath =>
  67. klawSync(path.join(__dirname, '../', scanPath), {
  68. nodir: true,
  69. filter: item => path.extname(item.path) === '.ts',
  70. traverseAll: true,
  71. }),
  72. )
  73. .reduce((allFiles, files) => [...allFiles, ...files], [])
  74. .map(item => item.path);
  75. deleteGeneratedDocs(outputPath);
  76. generateConfigDocs(tsFiles, outputPath, globalTypeMap);
  77. const watchMode = !!process.argv.find(arg => arg === '--watch' || arg === '-w');
  78. if (watchMode) {
  79. console.log(`Watching for changes to source files...`);
  80. tsFiles.forEach(file => {
  81. fs.watchFile(file, { interval: 1000 }, () => {
  82. generateConfigDocs([file], outputPath, globalTypeMap);
  83. });
  84. });
  85. }
  86. /**
  87. * Uses the TypeScript compiler API to parse the given files and extract out the documentation
  88. * into markdown files
  89. */
  90. function generateConfigDocs(filePaths: string[], hugoOutputPath: string, typeMap: TypeMap) {
  91. const timeStart = +new Date();
  92. let generatedCount = 0;
  93. const sourceFiles = filePaths.map(filePath => {
  94. return ts.createSourceFile(
  95. filePath,
  96. fs.readFileSync(filePath).toString(),
  97. ts.ScriptTarget.ES2015,
  98. true,
  99. );
  100. });
  101. const statements = getStatementsWithSourceLocation(sourceFiles);
  102. const declarationInfos = statements
  103. .map(statement => {
  104. const info = parseDeclaration(statement.statement, statement.sourceFile, statement.sourceLine);
  105. if (info) {
  106. typeMap.set(info.title, info.category + '/' + info.fileName);
  107. }
  108. return info;
  109. })
  110. .filter(notNullOrUndefined);
  111. for (const info of declarationInfos) {
  112. let markdown = '';
  113. switch (info.kind) {
  114. case 'interface':
  115. markdown = renderInterfaceOrClass(info, typeMap);
  116. break;
  117. case 'typeAlias':
  118. markdown = renderTypeAlias(info, typeMap);
  119. break;
  120. case 'class':
  121. markdown = renderInterfaceOrClass(info as any, typeMap);
  122. break;
  123. default:
  124. assertNever(info);
  125. }
  126. const categoryDir = path.join(hugoOutputPath, info.category);
  127. const indexFile = path.join(categoryDir, '_index.md');
  128. if (!fs.existsSync(categoryDir)) {
  129. fs.mkdirSync(categoryDir);
  130. }
  131. if (!fs.existsSync(indexFile)) {
  132. const indexFileContent = generateFrontMatter(info.category, 10, false) + `\n\n# ${info.category}`;
  133. fs.writeFileSync(indexFile, indexFileContent);
  134. generatedCount++;
  135. }
  136. fs.writeFileSync(path.join(categoryDir, info.fileName + '.md'), markdown);
  137. generatedCount++;
  138. }
  139. if (declarationInfos.length) {
  140. console.log(`Generated ${generatedCount} configuration docs in ${+new Date() - timeStart}ms`);
  141. }
  142. }
  143. /**
  144. * Maps an array of parsed SourceFiles into statements, including a reference to the original file each statement
  145. * came from.
  146. */
  147. function getStatementsWithSourceLocation(
  148. sourceFiles: ts.SourceFile[],
  149. ): Array<{ statement: ts.Statement; sourceFile: string; sourceLine: number }> {
  150. return sourceFiles.reduce(
  151. (st, sf) => {
  152. const statementsWithSources = sf.statements.map(statement => {
  153. const sourceFile = path.relative(path.join(__dirname, '..'), sf.fileName).replace(/\\/g, '/');
  154. const sourceLine = sf.getLineAndCharacterOfPosition(statement.getStart()).line + 1;
  155. return { statement, sourceFile, sourceLine };
  156. });
  157. return [...st, ...statementsWithSources];
  158. },
  159. [] as Array<{ statement: ts.Statement; sourceFile: string; sourceLine: number }>,
  160. );
  161. }
  162. /**
  163. * Parses an InterfaceDeclaration into a simple object which can be rendered into markdown.
  164. */
  165. function parseDeclaration(
  166. statement: ts.Statement,
  167. sourceFile: string,
  168. sourceLine: number,
  169. ): InterfaceInfo | TypeAliasInfo | ClassInfo | undefined {
  170. if (!isValidDeclaration(statement)) {
  171. return;
  172. }
  173. const category = getDocsCategory(statement);
  174. if (category === undefined) {
  175. return;
  176. }
  177. const title = statement.name ? statement.name.getText() : 'anonymous';
  178. const fullText = getDeclarationFullText(statement);
  179. const weight = getDeclarationWeight(statement);
  180. const description = getDeclarationDescription(statement);
  181. const fileName = title
  182. .split(/(?=[A-Z])/)
  183. .join('-')
  184. .toLowerCase();
  185. const info = {
  186. sourceFile,
  187. sourceLine,
  188. fullText,
  189. title,
  190. weight,
  191. category,
  192. description,
  193. fileName,
  194. };
  195. if (ts.isInterfaceDeclaration(statement)) {
  196. return {
  197. ...info,
  198. kind: 'interface',
  199. extends: getHeritageClauseText(statement, ts.SyntaxKind.ExtendsKeyword),
  200. members: parseMembers(statement.members),
  201. };
  202. } else if (ts.isTypeAliasDeclaration(statement)) {
  203. return {
  204. ...info,
  205. type: statement.type,
  206. kind: 'typeAlias',
  207. members: ts.isTypeLiteralNode(statement.type) ? parseMembers(statement.type.members) : undefined,
  208. };
  209. } else if (ts.isClassDeclaration(statement)) {
  210. return {
  211. ...info,
  212. kind: 'class',
  213. members: parseMembers(statement.members),
  214. extends: getHeritageClauseText(statement, ts.SyntaxKind.ExtendsKeyword),
  215. implements: getHeritageClauseText(statement, ts.SyntaxKind.ImplementsKeyword),
  216. };
  217. }
  218. }
  219. /**
  220. * Returns the text of any "extends" or "implements" clause of a class or interface.
  221. */
  222. function getHeritageClauseText(
  223. statement: ts.ClassDeclaration | ts.InterfaceDeclaration,
  224. kind: ts.SyntaxKind.ExtendsKeyword | ts.SyntaxKind.ImplementsKeyword,
  225. ): string | undefined {
  226. const { heritageClauses } = statement;
  227. if (!heritageClauses) {
  228. return;
  229. }
  230. const clause = heritageClauses.find(cl => cl.token === kind);
  231. if (!clause) {
  232. return;
  233. }
  234. return clause.getText();
  235. }
  236. /**
  237. * Returns the declaration name plus any type parameters.
  238. */
  239. function getDeclarationFullText(declaration: ValidDeclaration): string {
  240. const name = declaration.name ? declaration.name.getText() : 'anonymous';
  241. let typeParams = '';
  242. if (declaration.typeParameters) {
  243. typeParams = '<' + declaration.typeParameters.map(tp => tp.getText()).join(', ') + '>';
  244. }
  245. return name + typeParams;
  246. }
  247. /**
  248. * Parses an array of inteface members into a simple object which can be rendered into markdown.
  249. */
  250. function parseMembers(
  251. members: ts.NodeArray<ts.TypeElement | ts.ClassElement>,
  252. ): Array<PropertyInfo | MethodInfo> {
  253. const result: Array<PropertyInfo | MethodInfo> = [];
  254. for (const member of members) {
  255. const modifiers = member.modifiers ? member.modifiers.map(m => m.getText()) : [];
  256. const isPrivate = modifiers.includes('private');
  257. if (
  258. !isPrivate &&
  259. (ts.isPropertySignature(member) ||
  260. ts.isMethodSignature(member) ||
  261. ts.isPropertyDeclaration(member) ||
  262. ts.isMethodDeclaration(member) ||
  263. ts.isConstructorDeclaration(member))
  264. ) {
  265. const name = member.name ? member.name.getText() : 'constructor';
  266. let description = '';
  267. let type = '';
  268. let defaultValue = '';
  269. let parameters: MethodParameterInfo[] = [];
  270. let fullText = '';
  271. if (ts.isConstructorDeclaration(member)) {
  272. fullText = 'constructor';
  273. } else if (ts.isMethodDeclaration(member)) {
  274. fullText = member.name.getText();
  275. } else {
  276. fullText = member.getText();
  277. }
  278. parseTags(member, {
  279. description: tag => (description += tag.comment || ''),
  280. example: tag => (description += formatExampleCode(tag.comment)),
  281. default: tag => (defaultValue = tag.comment || ''),
  282. });
  283. if (member.type) {
  284. type = member.type.getText();
  285. }
  286. const memberInfo: MemberInfo = {
  287. fullText,
  288. name,
  289. description,
  290. type,
  291. };
  292. if (
  293. ts.isMethodSignature(member) ||
  294. ts.isMethodDeclaration(member) ||
  295. ts.isConstructorDeclaration(member)
  296. ) {
  297. parameters = member.parameters.map(p => ({
  298. name: p.name.getText(),
  299. type: p.type ? p.type.getText() : '',
  300. }));
  301. result.push({
  302. ...memberInfo,
  303. kind: 'method',
  304. parameters,
  305. });
  306. } else {
  307. result.push({
  308. ...memberInfo,
  309. kind: 'property',
  310. defaultValue,
  311. });
  312. }
  313. }
  314. }
  315. return result;
  316. }
  317. /**
  318. * Render the interface to a markdown string.
  319. */
  320. function renderInterfaceOrClass(info: InterfaceInfo | ClassInfo, knownTypeMap: Map<string, string>): string {
  321. const { title, weight, category, description, members } = info;
  322. let output = '';
  323. output += generateFrontMatter(title, weight);
  324. output += `\n\n# ${title}\n\n`;
  325. output += renderGenerationInfoShortcode(info);
  326. output += `${renderDescription(description, knownTypeMap)}\n\n`;
  327. output += `## Signature\n\n`;
  328. output += info.kind === 'interface' ? renderInterfaceSignature(info) : renderClassSignature(info);
  329. output += `## Members\n\n`;
  330. output += `${renderMembers(info, knownTypeMap)}\n`;
  331. return output;
  332. }
  333. /**
  334. * Render the type alias to a markdown string.
  335. */
  336. function renderTypeAlias(typeAliasInfo: TypeAliasInfo, knownTypeMap: Map<string, string>): string {
  337. const { title, weight, description, type, fullText } = typeAliasInfo;
  338. let output = '';
  339. output += generateFrontMatter(title, weight);
  340. output += `\n\n# ${title}\n\n`;
  341. output += renderGenerationInfoShortcode(typeAliasInfo);
  342. output += `${renderDescription(description, knownTypeMap)}\n\n`;
  343. output += `## Signature\n\n`;
  344. output += renderTypeAliasSignature(typeAliasInfo);
  345. if (typeAliasInfo.members) {
  346. output += `## Members\n\n`;
  347. output += `${renderMembers(typeAliasInfo, knownTypeMap)}\n`;
  348. }
  349. return output;
  350. }
  351. /**
  352. * Generates a markdown code block string for the interface signature.
  353. */
  354. function renderInterfaceSignature(interfaceInfo: InterfaceInfo): string {
  355. const { fullText, members } = interfaceInfo;
  356. let output = '';
  357. output += `\`\`\`TypeScript\n`;
  358. output += `interface ${fullText} `;
  359. if (interfaceInfo.extends) {
  360. output += interfaceInfo.extends + ' ';
  361. }
  362. output += `{\n`;
  363. output += members.map(member => ` ${member.fullText}`).join(`\n`);
  364. output += `\n}\n`;
  365. output += `\`\`\`\n`;
  366. return output;
  367. }
  368. function renderClassSignature(classInfo: ClassInfo): string {
  369. const { fullText, members } = classInfo;
  370. let output = '';
  371. output += `\`\`\`TypeScript\n`;
  372. output += `class ${fullText} `;
  373. if (classInfo.extends) {
  374. output += classInfo.extends + ' ';
  375. }
  376. if (classInfo.implements) {
  377. output += classInfo.implements + ' ';
  378. }
  379. output += `{\n`;
  380. output += members
  381. .map(member => {
  382. if (member.kind === 'method') {
  383. const args = member.parameters
  384. .map(p => {
  385. return `${p.name}: ${p.type}`;
  386. })
  387. .join(', ');
  388. if (member.fullText === 'constructor') {
  389. return ` constructor(${args})`;
  390. } else {
  391. return ` ${member.fullText}(${args}) => ${member.type};`;
  392. }
  393. } else {
  394. return ` ${member.fullText}`;
  395. }
  396. })
  397. .join(`\n`);
  398. output += `\n}\n`;
  399. output += `\`\`\`\n`;
  400. return output;
  401. }
  402. function renderTypeAliasSignature(typeAliasInfo: TypeAliasInfo): string {
  403. const { fullText, members, type } = typeAliasInfo;
  404. let output = '';
  405. output += `\`\`\`TypeScript\n`;
  406. output += `type ${fullText} = `;
  407. if (members) {
  408. output += `{\n`;
  409. output += members.map(member => ` ${member.fullText}`).join(`\n`);
  410. output += `\n}\n`;
  411. } else {
  412. output += type.getText() + `\n`;
  413. }
  414. output += `\`\`\`\n`;
  415. return output;
  416. }
  417. function renderMembers(info: InterfaceInfo | ClassInfo | TypeAliasInfo, knownTypeMap: TypeMap): string {
  418. const { members, title } = info;
  419. let output = '';
  420. for (const member of members || []) {
  421. let defaultParam = '';
  422. let type = '';
  423. if (member.kind === 'property') {
  424. type = renderType(member.type, knownTypeMap);
  425. defaultParam = member.defaultValue
  426. ? `default="${renderType(member.defaultValue, knownTypeMap)}" `
  427. : '';
  428. } else {
  429. const args = member.parameters
  430. .map(p => {
  431. return `${p.name}: ${renderType(p.type, knownTypeMap)}`;
  432. })
  433. .join(', ');
  434. if (member.fullText === 'constructor') {
  435. type = `(${args}) => ${title}`;
  436. } else {
  437. type = `(${args}) => ${renderType(member.type, knownTypeMap)}`;
  438. }
  439. }
  440. output += `### ${member.name}\n\n`;
  441. output += `{{< member-info kind="${member.kind}" type="${type}" ${defaultParam}>}}\n\n`;
  442. output += `${renderDescription(member.description, knownTypeMap)}\n\n`;
  443. }
  444. return output;
  445. }
  446. function renderGenerationInfoShortcode(info: DeclarationInfo): string {
  447. return `{{< generation-info sourceFile="${info.sourceFile}" sourceLine="${info.sourceLine}">}}\n\n`;
  448. }
  449. /**
  450. * Extracts the "@docsCategory" value from the JSDoc comments if present.
  451. */
  452. function getDocsCategory(statement: ValidDeclaration): string | undefined {
  453. let category: string | undefined;
  454. parseTags(statement, {
  455. docsCategory: tag => (category = tag.comment || ''),
  456. });
  457. return category;
  458. }
  459. /**
  460. * Parses the Node's JSDoc tags and invokes the supplied functions against any matching tag names.
  461. */
  462. function parseTags<T extends ts.Node>(
  463. node: T,
  464. tagMatcher: { [tagName: string]: (tag: ts.JSDocTag) => void },
  465. ): void {
  466. const jsDocTags = ts.getJSDocTags(node);
  467. for (const tag of jsDocTags) {
  468. const tagName = tag.tagName.text;
  469. if (tagMatcher[tagName]) {
  470. tagMatcher[tagName](tag);
  471. }
  472. }
  473. }
  474. /**
  475. * This function takes a string representing a type (e.g. "Array<ShippingMethod>") and turns
  476. * and known types (e.g. "ShippingMethod") into hyperlinks.
  477. */
  478. function renderType(type: string, knownTypeMap: TypeMap): string {
  479. let typeText = type
  480. .trim()
  481. // encode HTML entities
  482. .replace(/[\u00A0-\u9999<>\&]/gim, i => '&#' + i.charCodeAt(0) + ';')
  483. // remove newlines
  484. .replace(/\n/g, ' ');
  485. for (const [key, val] of knownTypeMap) {
  486. const re = new RegExp(`\\b${key}\\b`, 'g');
  487. typeText = typeText.replace(re, `<a href='${docsUrl}/${val}/'>${key}</a>`);
  488. }
  489. return typeText;
  490. }
  491. /**
  492. * Replaces any `{@link Foo}` references in the description with hyperlinks.
  493. */
  494. function renderDescription(description: string, knownTypeMap: TypeMap): string {
  495. for (const [key, val] of knownTypeMap) {
  496. const re = new RegExp(`{@link\\s*${key}}`, 'g');
  497. description = description.replace(re, `<a href='${docsUrl}/${val}/'>${key}</a>`);
  498. }
  499. return description;
  500. }
  501. /**
  502. * Reads the @docsWeight JSDoc tag from the interface.
  503. */
  504. function getDeclarationWeight(statement: ValidDeclaration): number {
  505. let weight = 10;
  506. parseTags(statement, {
  507. docsWeight: tag => (weight = Number.parseInt(tag.comment || '10', 10)),
  508. });
  509. return weight;
  510. }
  511. /**
  512. * Reads the @description JSDoc tag from the interface.
  513. */
  514. function getDeclarationDescription(statement: ValidDeclaration): string {
  515. let description = '';
  516. parseTags(statement, {
  517. description: tag => (description += tag.comment),
  518. example: tag => (description += formatExampleCode(tag.comment)),
  519. });
  520. return description;
  521. }
  522. /**
  523. * Cleans up a JSDoc "@example" block by removing leading whitespace and asterisk (TypeScript has an open issue
  524. * wherein the asterisks are not stripped as they should be, see https://github.com/Microsoft/TypeScript/issues/23517)
  525. */
  526. function formatExampleCode(example: string = ''): string {
  527. return '\n\n*Example*\n\n' + example.replace(/\n\s+\*\s/g, '\n');
  528. }
  529. /**
  530. * Type guard for the types of statement which can ge processed by the doc generator.
  531. */
  532. function isValidDeclaration(statement: ts.Statement): statement is ValidDeclaration {
  533. return (
  534. ts.isInterfaceDeclaration(statement) ||
  535. ts.isTypeAliasDeclaration(statement) ||
  536. ts.isClassDeclaration(statement)
  537. );
  538. }