generate-graphql-docs.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  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 (backticks and ${) and non-ASCII Unicode
  146. */
  147. function escapeTemplateString(str: string): string {
  148. return escapeUnicodeCharacters(str.replace(/`/g, '\\`').replace(/\$\{/g, '\\${'));
  149. }
  150. /**
  151. * Strips JSDoc-style tags from descriptions
  152. */
  153. function stripJSDocTags(description: string): string {
  154. const stringsToStrip = [/@docsCategory\s+[^\n]+/g, /@description\s+/g];
  155. let result = description;
  156. for (const pattern of stringsToStrip) {
  157. result = result.replace(pattern, '');
  158. }
  159. // Replace {@link SomeName} with just SomeName to avoid MDX JSX expression issues
  160. result = result.replace(/\{@link\s+([^}]+)\}/g, '$1');
  161. return result.trim();
  162. }
  163. /**
  164. * Renders a description as SDL triple-quote format
  165. */
  166. function renderSDLDescription(description: string | null | undefined, indent = ''): string {
  167. if (!description || description.trim() === '') {
  168. return '';
  169. }
  170. const cleanDescription = stripJSDocTags(description);
  171. if (cleanDescription === '') {
  172. return '';
  173. }
  174. const lines = cleanDescription.split('\n');
  175. if (lines.length === 1) {
  176. return `${indent}"""${cleanDescription}"""\n`;
  177. }
  178. return `${indent}"""\n${lines.map(line => `${indent}${line}`).join('\n')}\n${indent}"""\n`;
  179. }
  180. /**
  181. * Renders a GraphQL type as a string (e.g., [String!]!)
  182. */
  183. function renderTypeString(type: GraphQLType): string {
  184. return type.toString();
  185. }
  186. /**
  187. * Renders a single field as SDL
  188. */
  189. function renderFieldSDL(
  190. field: GraphQLField<any, any>,
  191. indent = ' ',
  192. includeDescription = true,
  193. ): string {
  194. let result = '';
  195. if (includeDescription && field.description) {
  196. result += renderSDLDescription(field.description, indent);
  197. }
  198. let fieldLine = `${indent}${field.name}`;
  199. if (field.args && field.args.length > 0) {
  200. const args = field.args.map(arg => `${arg.name}: ${renderTypeString(arg.type)}`).join(', ');
  201. fieldLine += `(${args})`;
  202. }
  203. fieldLine += `: ${renderTypeString(field.type)}`;
  204. result += fieldLine + '\n';
  205. return result;
  206. }
  207. /**
  208. * Renders an object type as SDL
  209. */
  210. function renderObjectTypeSDL(type: GraphQLObjectType): string {
  211. let result = '';
  212. if (type.description) {
  213. result += renderSDLDescription(type.description);
  214. }
  215. result += `type ${type.name} {\n`;
  216. for (const field of Object.values(type.getFields())) {
  217. result += renderFieldSDL(field);
  218. }
  219. result += '}';
  220. return result;
  221. }
  222. /**
  223. * Renders an input type as SDL
  224. */
  225. function renderInputTypeSDL(type: GraphQLInputObjectType): string {
  226. let result = '';
  227. if (type.description) {
  228. result += renderSDLDescription(type.description);
  229. }
  230. result += `input ${type.name} {\n`;
  231. for (const field of Object.values(type.getFields())) {
  232. let fieldResult = '';
  233. if (field.description) {
  234. fieldResult += renderSDLDescription(field.description, ' ');
  235. }
  236. fieldResult += ` ${field.name}: ${renderTypeString(field.type)}\n`;
  237. result += fieldResult;
  238. }
  239. result += '}';
  240. return result;
  241. }
  242. /**
  243. * Renders an enum type as SDL
  244. */
  245. function renderEnumTypeSDL(type: GraphQLEnumType): string {
  246. let result = '';
  247. if (type.description) {
  248. result += renderSDLDescription(type.description);
  249. }
  250. result += `enum ${type.name} {\n`;
  251. for (const value of type.getValues()) {
  252. if (value.description) {
  253. result += renderSDLDescription(value.description, ' ');
  254. }
  255. result += ` ${value.name}\n`;
  256. }
  257. result += '}';
  258. return result;
  259. }
  260. /**
  261. * Renders a scalar type as SDL
  262. */
  263. function renderScalarSDL(type: GraphQLScalarType): string {
  264. let result = '';
  265. if (type.description) {
  266. result += renderSDLDescription(type.description);
  267. }
  268. result += `scalar ${type.name}`;
  269. return result;
  270. }
  271. /**
  272. * Renders a union type as SDL
  273. */
  274. function renderUnionSDL(type: GraphQLUnionType): string {
  275. let result = '';
  276. if (type.description) {
  277. result += renderSDLDescription(type.description);
  278. }
  279. const members = type.getTypes().map(t => t.name).join(' | ');
  280. result += `union ${type.name} = ${members}`;
  281. return result;
  282. }
  283. /**
  284. * Renders a query/mutation field wrapped in its parent type as SDL
  285. * Note: Description is included at the top level, not inside the field
  286. */
  287. function renderQueryMutationFieldSDL(field: GraphQLField<any, any>, parentTypeName: 'Query' | 'Mutation'): string {
  288. let result = '';
  289. if (field.description) {
  290. result += renderSDLDescription(field.description);
  291. }
  292. result += `type ${parentTypeName} {\n`;
  293. // Skip description inside the field since it's already at the top level
  294. result += renderFieldSDL(field, ' ', false);
  295. result += '}';
  296. return result;
  297. }
  298. // ============================================================================
  299. // GraphQLDoc Component Generator
  300. // ============================================================================
  301. /**
  302. * Renders a GraphQLDoc component for MDX
  303. */
  304. function renderGraphQLDocComponent(options: {
  305. type: GraphQLDocType;
  306. typeName: string;
  307. sdlContent: string;
  308. typeLinks: Record<string, string>;
  309. deprecated?: boolean | string;
  310. }): string {
  311. const { type, typeName, sdlContent, typeLinks, deprecated } = options;
  312. const escapedSdl = escapeTemplateString(sdlContent);
  313. // Build typeLinks prop
  314. const typeLinksEntries = Object.entries(typeLinks);
  315. let typeLinksStr = '{}';
  316. if (typeLinksEntries.length > 0) {
  317. const entries = typeLinksEntries
  318. .map(([name, url]) => ` ${name}: '${url}'`)
  319. .join(',\n');
  320. typeLinksStr = `{\n${entries},\n }`;
  321. }
  322. let result = `<GraphQLDoc\n`;
  323. result += ` type="${type}"\n`;
  324. result += ` typeName="${typeName}"\n`;
  325. result += ` typeLinks={${typeLinksStr}}\n`;
  326. if (deprecated) {
  327. if (typeof deprecated === 'string') {
  328. result += ` deprecated="${escapeTemplateString(deprecated)}"\n`;
  329. } else {
  330. result += ` deprecated={true}\n`;
  331. }
  332. }
  333. result += `>\n`;
  334. result += `{\`${escapedSdl}\`}\n`;
  335. result += `</GraphQLDoc>`;
  336. return result;
  337. }
  338. deleteGeneratedDocs(outputPath);
  339. generateGraphqlDocs(outputPath);
  340. function generateGraphqlDocs(hugoOutputPath: string) {
  341. const timeStart = +new Date();
  342. if (!fs.existsSync(hugoOutputPath)) {
  343. fs.mkdirSync(hugoOutputPath, { recursive: true });
  344. }
  345. let queriesOutput = generateFrontMatter('Queries') + '\n\n';
  346. let mutationsOutput = generateFrontMatter('Mutations') + '\n\n';
  347. let objectTypesOutput = generateFrontMatter('Types') + '\n\n';
  348. let inputTypesOutput = generateFrontMatter('Input Objects') + '\n\n';
  349. let enumsOutput = generateFrontMatter('Enums') + '\n\n';
  350. const sortByName = (a: { name: string }, b: { name: string }) => (a.name < b.name ? -1 : 1);
  351. const sortedTypes = Object.values(schema.getTypeMap()).sort(sortByName);
  352. for (const type of sortedTypes) {
  353. if (type.name.substring(0, 2) === '__') {
  354. // ignore internal types
  355. continue;
  356. }
  357. if (isObjectType(type)) {
  358. if (type.name === 'Query') {
  359. // Handle Query fields
  360. for (const field of Object.values(type.getFields()).sort(sortByName)) {
  361. if (field.name === 'temp__') {
  362. continue;
  363. }
  364. const referencedTypes = collectFieldReferencedTypes(field);
  365. const typeLinks = buildTypeLinksMap(referencedTypes);
  366. const sdlContent = renderQueryMutationFieldSDL(field, 'Query');
  367. const deprecated = field.deprecationReason || undefined;
  368. queriesOutput += `\n<a name="${field.name.toLowerCase()}"></a>\n\n## ${field.name}\n\n`;
  369. queriesOutput += renderGraphQLDocComponent({
  370. type: 'query',
  371. typeName: field.name,
  372. sdlContent,
  373. typeLinks,
  374. deprecated,
  375. });
  376. queriesOutput += '\n';
  377. }
  378. } else if (type.name === 'Mutation') {
  379. // Handle Mutation fields
  380. for (const field of Object.values(type.getFields()).sort(sortByName)) {
  381. const referencedTypes = collectFieldReferencedTypes(field);
  382. const typeLinks = buildTypeLinksMap(referencedTypes);
  383. const sdlContent = renderQueryMutationFieldSDL(field, 'Mutation');
  384. const deprecated = field.deprecationReason || undefined;
  385. mutationsOutput += `\n<a name="${field.name.toLowerCase()}"></a>\n\n## ${field.name}\n\n`;
  386. mutationsOutput += renderGraphQLDocComponent({
  387. type: 'mutation',
  388. typeName: field.name,
  389. sdlContent,
  390. typeLinks,
  391. deprecated,
  392. });
  393. mutationsOutput += '\n';
  394. }
  395. } else {
  396. // Handle regular object types
  397. const referencedTypes = collectTypeReferencedTypes(type);
  398. const typeLinks = buildTypeLinksMap(referencedTypes);
  399. const sdlContent = renderObjectTypeSDL(type);
  400. objectTypesOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  401. objectTypesOutput += renderGraphQLDocComponent({
  402. type: 'type',
  403. typeName: type.name,
  404. sdlContent,
  405. typeLinks,
  406. });
  407. objectTypesOutput += '\n';
  408. }
  409. }
  410. if (isEnumType(type)) {
  411. const sdlContent = renderEnumTypeSDL(type);
  412. enumsOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  413. enumsOutput += renderGraphQLDocComponent({
  414. type: 'enum',
  415. typeName: type.name,
  416. sdlContent,
  417. typeLinks: {},
  418. });
  419. enumsOutput += '\n';
  420. }
  421. if (isScalarType(type)) {
  422. const sdlContent = renderScalarSDL(type);
  423. objectTypesOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  424. objectTypesOutput += renderGraphQLDocComponent({
  425. type: 'scalar',
  426. typeName: type.name,
  427. sdlContent,
  428. typeLinks: {},
  429. });
  430. objectTypesOutput += '\n';
  431. }
  432. if (isInputObjectType(type)) {
  433. const referencedTypes = collectTypeReferencedTypes(type);
  434. const typeLinks = buildTypeLinksMap(referencedTypes);
  435. const sdlContent = renderInputTypeSDL(type);
  436. inputTypesOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  437. inputTypesOutput += renderGraphQLDocComponent({
  438. type: 'input',
  439. typeName: type.name,
  440. sdlContent,
  441. typeLinks,
  442. });
  443. inputTypesOutput += '\n';
  444. }
  445. if (isUnionType(type)) {
  446. const referencedTypes = collectUnionReferencedTypes(type);
  447. const typeLinks = buildTypeLinksMap(referencedTypes);
  448. const sdlContent = renderUnionSDL(type);
  449. objectTypesOutput += `\n<a name="${type.name.toLowerCase()}"></a>\n\n## ${type.name}\n\n`;
  450. objectTypesOutput += renderGraphQLDocComponent({
  451. type: 'union',
  452. typeName: type.name,
  453. sdlContent,
  454. typeLinks,
  455. });
  456. objectTypesOutput += '\n';
  457. }
  458. }
  459. fs.writeFileSync(path.join(hugoOutputPath, FileName.QUERY + '.mdx'), queriesOutput);
  460. fs.writeFileSync(path.join(hugoOutputPath, FileName.MUTATION + '.mdx'), mutationsOutput);
  461. fs.writeFileSync(path.join(hugoOutputPath, FileName.OBJECT + '.mdx'), objectTypesOutput);
  462. fs.writeFileSync(path.join(hugoOutputPath, FileName.INPUT + '.mdx'), inputTypesOutput);
  463. fs.writeFileSync(path.join(hugoOutputPath, FileName.ENUM + '.mdx'), enumsOutput);
  464. console.log(`Generated 5 GraphQL API docs in ${+new Date() - timeStart}ms`);
  465. }
  466. function getTargetApiFromArgs(): TargetApi {
  467. const apiArg = process.argv.find(arg => /--api=(shop|admin)/.test(arg));
  468. if (!apiArg) {
  469. console.error('\nPlease specify which GraphQL API to generate docs for: --api=<shop|admin>\n');
  470. process.exit(1);
  471. return null as never;
  472. }
  473. return apiArg === '--api=shop' ? 'shop' : 'admin';
  474. }