1
0

generate-graphql-docs.ts 16 KB

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