generate-graphql-docs.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import fs from 'fs';
  2. import {
  3. buildClientSchema,
  4. GraphQLEnumType,
  5. GraphQLField,
  6. GraphQLInputObjectType,
  7. GraphQLNamedType,
  8. GraphQLObjectType,
  9. GraphQLScalarType,
  10. GraphQLType,
  11. GraphQLUnionType,
  12. isEnumType,
  13. isInputObjectType,
  14. isListType,
  15. isNamedType,
  16. isNonNullType,
  17. isObjectType,
  18. isScalarType,
  19. isUnionType,
  20. } from 'graphql';
  21. import path from 'path';
  22. import { deleteGeneratedDocs, generateFrontMatter } from './docgen-utils';
  23. /* eslint-disable no-console */
  24. type TargetApi = 'shop' | 'admin';
  25. const targetApi: TargetApi = getTargetApiFromArgs();
  26. // The path to the introspection schema json file
  27. const SCHEMA_FILE = path.join(__dirname, `../../schema-${targetApi}.json`);
  28. // The absolute URL to the generated api docs section
  29. const docsUrl = `/reference/graphql-api/${targetApi}/`;
  30. // The directory in which the markdown files will be saved
  31. const outputPath = path.join(__dirname, `../../docs/docs/reference/graphql-api/${targetApi}`);
  32. const enum FileName {
  33. ENUM = 'enums',
  34. INPUT = 'input-types',
  35. MUTATION = 'mutations',
  36. QUERY = 'queries',
  37. OBJECT = 'object-types',
  38. }
  39. type GraphQLDocType = 'query' | 'mutation' | 'type' | 'input' | 'enum' | 'scalar' | 'union';
  40. const schemaJson = fs.readFileSync(SCHEMA_FILE, 'utf8');
  41. const parsed = JSON.parse(schemaJson);
  42. const schema = buildClientSchema(parsed.data ? parsed.data : parsed);
  43. // ============================================================================
  44. // Type Reference Collection Helpers
  45. // ============================================================================
  46. /**
  47. * Collects all referenced type names from a GraphQL type (unwrapping NonNull/List wrappers)
  48. */
  49. function collectReferencedTypes(type: GraphQLType): Set<string> {
  50. const types = new Set<string>();
  51. let current = type;
  52. while (!isNamedType(current)) {
  53. if (isNonNullType(current) || isListType(current)) {
  54. current = current.ofType;
  55. }
  56. }
  57. if (isNamedType(current)) {
  58. types.add(current.name);
  59. }
  60. return types;
  61. }
  62. /**
  63. * Collects all referenced types from a field (including arguments and return type)
  64. */
  65. function collectFieldReferencedTypes(field: GraphQLField<any, any>): Set<string> {
  66. const types = new Set<string>();
  67. // Return type
  68. for (const t of collectReferencedTypes(field.type)) {
  69. types.add(t);
  70. }
  71. // Argument types
  72. if (field.args) {
  73. for (const arg of field.args) {
  74. for (const t of collectReferencedTypes(arg.type)) {
  75. types.add(t);
  76. }
  77. }
  78. }
  79. return types;
  80. }
  81. /**
  82. * Collects all referenced types from an object or input type's fields
  83. */
  84. function collectTypeReferencedTypes(type: GraphQLObjectType | GraphQLInputObjectType): Set<string> {
  85. const types = new Set<string>();
  86. for (const field of Object.values(type.getFields())) {
  87. for (const t of collectFieldReferencedTypes(field as GraphQLField<any, any>)) {
  88. types.add(t);
  89. }
  90. }
  91. return types;
  92. }
  93. /**
  94. * Collects all types from a union
  95. */
  96. function collectUnionReferencedTypes(type: GraphQLUnionType): Set<string> {
  97. const types = new Set<string>();
  98. for (const t of type.getTypes()) {
  99. types.add(t.name);
  100. }
  101. return types;
  102. }
  103. /**
  104. * Builds a map of type names to their documentation URLs
  105. */
  106. function buildTypeLinksMap(referencedTypes: Set<string>): Record<string, string> {
  107. const typeLinks: Record<string, string> = {};
  108. for (const typeName of referencedTypes) {
  109. const schemaType = schema.getType(typeName);
  110. if (!schemaType) continue;
  111. let fileName: FileName;
  112. if (isEnumType(schemaType)) {
  113. fileName = FileName.ENUM;
  114. } else if (isInputObjectType(schemaType)) {
  115. fileName = FileName.INPUT;
  116. } else {
  117. fileName = FileName.OBJECT;
  118. }
  119. typeLinks[typeName] = `${docsUrl}${fileName}#${typeName.toLowerCase()}`;
  120. }
  121. return typeLinks;
  122. }
  123. // ============================================================================
  124. // SDL Rendering Helpers (plain GraphQL text, no HTML)
  125. // ============================================================================
  126. /**
  127. * Escapes non-ASCII Unicode characters to \uXXXX format
  128. */
  129. function escapeUnicodeCharacters(str: string): string {
  130. return str.replace(/[^\x00-\x7F]/g, char => {
  131. const codePoint = char.codePointAt(0);
  132. if (codePoint !== undefined && codePoint <= 0xFFFF) {
  133. return `\\u${codePoint.toString(16).toUpperCase().padStart(4, '0')}`;
  134. }
  135. // For characters outside BMP, use surrogate pairs
  136. if (codePoint !== undefined) {
  137. const highSurrogate = Math.floor((codePoint - 0x10000) / 0x400) + 0xD800;
  138. const lowSurrogate = ((codePoint - 0x10000) % 0x400) + 0xDC00;
  139. return `\\u${highSurrogate.toString(16).toUpperCase()}\\u${lowSurrogate.toString(16).toUpperCase()}`;
  140. }
  141. return char;
  142. });
  143. }
  144. /**
  145. * Escapes template string special characters (backslashes, backticks and ${) and non-ASCII Unicode
  146. */
  147. function escapeTemplateString(str: string): string {
  148. // Escape backslashes first, then backticks and ${
  149. return escapeUnicodeCharacters(
  150. str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'),
  151. );
  152. }
  153. /**
  154. * Strips JSDoc-style tags from descriptions
  155. */
  156. function stripJSDocTags(description: string): string {
  157. const stringsToStrip = [/@docsCategory\s+[^\n]+/g, /@description\s+/g];
  158. let result = description;
  159. for (const pattern of stringsToStrip) {
  160. result = result.replace(pattern, '');
  161. }
  162. // Replace {@link SomeName} with just SomeName to avoid MDX JSX expression issues
  163. // Use non-greedy +? to prevent ReDoS via backtracking
  164. result = result.replace(/\{@link\s+([^}]+?)\}/g, '$1');
  165. return result.trim();
  166. }
  167. /**
  168. * Renders a description as SDL triple-quote format
  169. */
  170. function renderSDLDescription(description: string | null | undefined, indent = ''): string {
  171. if (!description || description.trim() === '') {
  172. return '';
  173. }
  174. const cleanDescription = stripJSDocTags(description);
  175. if (cleanDescription === '') {
  176. return '';
  177. }
  178. const lines = cleanDescription.split('\n');
  179. if (lines.length === 1) {
  180. return `${indent}"""${cleanDescription}"""\n`;
  181. }
  182. return `${indent}"""\n${lines.map(line => `${indent}${line}`).join('\n')}\n${indent}"""\n`;
  183. }
  184. /**
  185. * Renders a GraphQL type as a string (e.g., [String!]!)
  186. */
  187. function renderTypeString(type: GraphQLType): string {
  188. return type.toString();
  189. }
  190. /**
  191. * Renders a single field as SDL
  192. */
  193. function renderFieldSDL(
  194. field: GraphQLField<any, any>,
  195. indent = ' ',
  196. includeDescription = true,
  197. ): string {
  198. let result = '';
  199. if (includeDescription && field.description) {
  200. result += renderSDLDescription(field.description, indent);
  201. }
  202. let fieldLine = `${indent}${field.name}`;
  203. if (field.args && field.args.length > 0) {
  204. const args = field.args.map(arg => `${arg.name}: ${renderTypeString(arg.type)}`).join(', ');
  205. fieldLine += `(${args})`;
  206. }
  207. fieldLine += `: ${renderTypeString(field.type)}`;
  208. result += fieldLine + '\n';
  209. return result;
  210. }
  211. /**
  212. * Renders an object type as SDL
  213. */
  214. function renderObjectTypeSDL(type: GraphQLObjectType): string {
  215. let result = '';
  216. if (type.description) {
  217. result += renderSDLDescription(type.description);
  218. }
  219. result += `type ${type.name} {\n`;
  220. for (const field of Object.values(type.getFields())) {
  221. result += renderFieldSDL(field);
  222. }
  223. result += '}';
  224. return result;
  225. }
  226. /**
  227. * Renders an input type as SDL
  228. */
  229. function renderInputTypeSDL(type: GraphQLInputObjectType): string {
  230. let result = '';
  231. if (type.description) {
  232. result += renderSDLDescription(type.description);
  233. }
  234. result += `input ${type.name} {\n`;
  235. for (const field of Object.values(type.getFields())) {
  236. let fieldResult = '';
  237. if (field.description) {
  238. fieldResult += renderSDLDescription(field.description, ' ');
  239. }
  240. fieldResult += ` ${field.name}: ${renderTypeString(field.type)}\n`;
  241. result += fieldResult;
  242. }
  243. result += '}';
  244. return result;
  245. }
  246. /**
  247. * Renders an enum type as SDL
  248. */
  249. function renderEnumTypeSDL(type: GraphQLEnumType): string {
  250. let result = '';
  251. if (type.description) {
  252. result += renderSDLDescription(type.description);
  253. }
  254. result += `enum ${type.name} {\n`;
  255. for (const value of type.getValues()) {
  256. if (value.description) {
  257. result += renderSDLDescription(value.description, ' ');
  258. }
  259. result += ` ${value.name}\n`;
  260. }
  261. result += '}';
  262. return result;
  263. }
  264. /**
  265. * Renders a scalar type as SDL
  266. */
  267. function renderScalarSDL(type: GraphQLScalarType): string {
  268. let result = '';
  269. if (type.description) {
  270. result += renderSDLDescription(type.description);
  271. }
  272. result += `scalar ${type.name}`;
  273. return result;
  274. }
  275. /**
  276. * Renders a union type as SDL
  277. */
  278. function renderUnionSDL(type: GraphQLUnionType): string {
  279. let result = '';
  280. if (type.description) {
  281. result += renderSDLDescription(type.description);
  282. }
  283. const members = type.getTypes().map(t => t.name).join(' | ');
  284. result += `union ${type.name} = ${members}`;
  285. return result;
  286. }
  287. /**
  288. * Renders a query/mutation field wrapped in its parent type as SDL
  289. * Note: Description is included at the top level, not inside the field
  290. */
  291. function renderQueryMutationFieldSDL(field: GraphQLField<any, any>, parentTypeName: 'Query' | 'Mutation'): string {
  292. let result = '';
  293. if (field.description) {
  294. result += renderSDLDescription(field.description);
  295. }
  296. result += `type ${parentTypeName} {\n`;
  297. // Skip description inside the field since it's already at the top level
  298. result += renderFieldSDL(field, ' ', false);
  299. result += '}';
  300. return result;
  301. }
  302. // ============================================================================
  303. // GraphQLDoc Component Generator
  304. // ============================================================================
  305. /**
  306. * Renders a GraphQLDoc component for MDX
  307. */
  308. function renderGraphQLDocComponent(options: {
  309. type: GraphQLDocType;
  310. typeName: string;
  311. sdlContent: string;
  312. typeLinks: Record<string, string>;
  313. deprecated?: boolean | string;
  314. }): string {
  315. const { type, typeName, sdlContent, typeLinks, deprecated } = options;
  316. const escapedSdl = escapeTemplateString(sdlContent);
  317. // Build typeLinks prop
  318. const typeLinksEntries = Object.entries(typeLinks);
  319. let typeLinksStr = '{}';
  320. if (typeLinksEntries.length > 0) {
  321. const entries = typeLinksEntries
  322. .map(([name, url]) => ` ${name}: '${url}'`)
  323. .join(',\n');
  324. typeLinksStr = `{\n${entries},\n }`;
  325. }
  326. let result = `<GraphQLDoc\n`;
  327. result += ` type="${type}"\n`;
  328. result += ` typeName="${typeName}"\n`;
  329. result += ` typeLinks={${typeLinksStr}}\n`;
  330. if (deprecated) {
  331. if (typeof deprecated === 'string') {
  332. result += ` deprecated="${escapeTemplateString(deprecated)}"\n`;
  333. } else {
  334. result += ` deprecated={true}\n`;
  335. }
  336. }
  337. result += `>\n`;
  338. result += `{\`${escapedSdl}\`}\n`;
  339. result += `</GraphQLDoc>`;
  340. return result;
  341. }
  342. deleteGeneratedDocs(outputPath);
  343. generateGraphqlDocs(outputPath);
  344. function generateGraphqlDocs(hugoOutputPath: string) {
  345. const timeStart = +new Date();
  346. if (!fs.existsSync(hugoOutputPath)) {
  347. fs.mkdirSync(hugoOutputPath, { recursive: true });
  348. }
  349. let queriesOutput = generateFrontMatter('Queries') + '\n\n';
  350. let mutationsOutput = generateFrontMatter('Mutations') + '\n\n';
  351. let objectTypesOutput = generateFrontMatter('Types') + '\n\n';
  352. let inputTypesOutput = generateFrontMatter('Input Objects') + '\n\n';
  353. let enumsOutput = generateFrontMatter('Enums') + '\n\n';
  354. const sortByName = (a: { name: string }, b: { name: string }) => (a.name < b.name ? -1 : 1);
  355. const sortedTypes = Object.values(schema.getTypeMap()).sort(sortByName);
  356. for (const type of sortedTypes) {
  357. if (type.name.substring(0, 2) === '__') {
  358. // ignore internal types
  359. continue;
  360. }
  361. if (isObjectType(type)) {
  362. if (type.name === 'Query') {
  363. // Handle Query fields
  364. for (const field of Object.values(type.getFields()).sort(sortByName)) {
  365. if (field.name === 'temp__') {
  366. continue;
  367. }
  368. const referencedTypes = collectFieldReferencedTypes(field);
  369. const typeLinks = buildTypeLinksMap(referencedTypes);
  370. const sdlContent = renderQueryMutationFieldSDL(field, 'Query');
  371. const deprecated = field.deprecationReason || undefined;
  372. queriesOutput += `\n<a name="${field.name.toLowerCase()}"></a>\n\n## ${field.name}\n\n`;
  373. queriesOutput += renderGraphQLDocComponent({
  374. type: 'query',
  375. typeName: field.name,
  376. sdlContent,
  377. typeLinks,
  378. deprecated,
  379. });
  380. queriesOutput += '\n';
  381. }
  382. } else if (type.name === 'Mutation') {
  383. // Handle Mutation fields
  384. for (const field of Object.values(type.getFields()).sort(sortByName)) {
  385. const referencedTypes = collectFieldReferencedTypes(field);
  386. const typeLinks = buildTypeLinksMap(referencedTypes);
  387. const sdlContent = renderQueryMutationFieldSDL(field, 'Mutation');
  388. const deprecated = field.deprecationReason || undefined;
  389. mutationsOutput += `\n<a name="${field.name.toLowerCase()}"></a>\n\n## ${field.name}\n\n`;
  390. mutationsOutput += renderGraphQLDocComponent({
  391. type: 'mutation',
  392. typeName: field.name,
  393. sdlContent,
  394. typeLinks,
  395. deprecated,
  396. });
  397. mutationsOutput += '\n';
  398. }
  399. } else {
  400. // Handle regular object types
  401. const referencedTypes = collectTypeReferencedTypes(type);
  402. const typeLinks = buildTypeLinksMap(referencedTypes);
  403. const sdlContent = renderObjectTypeSDL(type);
  404. objectTypesOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  405. objectTypesOutput += renderGraphQLDocComponent({
  406. type: 'type',
  407. typeName: type.name,
  408. sdlContent,
  409. typeLinks,
  410. });
  411. objectTypesOutput += '\n';
  412. }
  413. }
  414. if (isEnumType(type)) {
  415. const sdlContent = renderEnumTypeSDL(type);
  416. enumsOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  417. enumsOutput += renderGraphQLDocComponent({
  418. type: 'enum',
  419. typeName: type.name,
  420. sdlContent,
  421. typeLinks: {},
  422. });
  423. enumsOutput += '\n';
  424. }
  425. if (isScalarType(type)) {
  426. const sdlContent = renderScalarSDL(type);
  427. objectTypesOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  428. objectTypesOutput += renderGraphQLDocComponent({
  429. type: 'scalar',
  430. typeName: type.name,
  431. sdlContent,
  432. typeLinks: {},
  433. });
  434. objectTypesOutput += '\n';
  435. }
  436. if (isInputObjectType(type)) {
  437. const referencedTypes = collectTypeReferencedTypes(type);
  438. const typeLinks = buildTypeLinksMap(referencedTypes);
  439. const sdlContent = renderInputTypeSDL(type);
  440. inputTypesOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  441. inputTypesOutput += renderGraphQLDocComponent({
  442. type: 'input',
  443. typeName: type.name,
  444. sdlContent,
  445. typeLinks,
  446. });
  447. inputTypesOutput += '\n';
  448. }
  449. if (isUnionType(type)) {
  450. const referencedTypes = collectUnionReferencedTypes(type);
  451. const typeLinks = buildTypeLinksMap(referencedTypes);
  452. const sdlContent = renderUnionSDL(type);
  453. objectTypesOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  454. objectTypesOutput += renderGraphQLDocComponent({
  455. type: 'union',
  456. typeName: type.name,
  457. sdlContent,
  458. typeLinks,
  459. });
  460. objectTypesOutput += '\n';
  461. }
  462. }
  463. fs.writeFileSync(path.join(hugoOutputPath, FileName.QUERY + '.mdx'), queriesOutput);
  464. fs.writeFileSync(path.join(hugoOutputPath, FileName.MUTATION + '.mdx'), mutationsOutput);
  465. fs.writeFileSync(path.join(hugoOutputPath, FileName.OBJECT + '.mdx'), objectTypesOutput);
  466. fs.writeFileSync(path.join(hugoOutputPath, FileName.INPUT + '.mdx'), inputTypesOutput);
  467. fs.writeFileSync(path.join(hugoOutputPath, FileName.ENUM + '.mdx'), enumsOutput);
  468. // Generate _index.mdx for the API section
  469. const apiTitle = targetApi === 'admin' ? 'Admin API' : 'Shop API';
  470. const indexOutput = generateFrontMatter(apiTitle) + '\n\n' +
  471. `<LinkCard href="${docsUrl}${FileName.QUERY}" title="Queries" />\n` +
  472. `<LinkCard href="${docsUrl}${FileName.MUTATION}" title="Mutations" />\n` +
  473. `<LinkCard href="${docsUrl}${FileName.OBJECT}" title="Types" />\n` +
  474. `<LinkCard href="${docsUrl}${FileName.INPUT}" title="Input Objects" />\n` +
  475. `<LinkCard href="${docsUrl}${FileName.ENUM}" title="Enums" />\n`;
  476. fs.writeFileSync(path.join(hugoOutputPath, '_index.mdx'), indexOutput);
  477. console.log(`Generated 5 GraphQL API docs in ${+new Date() - timeStart}ms`);
  478. }
  479. function getTargetApiFromArgs(): TargetApi {
  480. const apiArg = process.argv.find(arg => /--api=(shop|admin)/.test(arg));
  481. if (!apiArg) {
  482. console.error('\nPlease specify which GraphQL API to generate docs for: --api=<shop|admin>\n');
  483. process.exit(1);
  484. return null as never;
  485. }
  486. return apiArg === '--api=shop' ? 'shop' : 'admin';
  487. }