Sfoglia il codice sorgente

feat(docs): Add auto-generated GraphQL API docs

Michael Bromley 7 anni fa
parent
commit
d9d064a662

+ 2 - 0
.gitignore

@@ -399,3 +399,5 @@ docs/static/main.css
 docs/public
 docs/content/docs/configuration/*
 !docs/content/docs/configuration/_index.md
+docs/content/docs/graphql-api/*
+!docs/content/docs/graphql-api/_index.md

+ 41 - 0
codegen/docgen-utils.ts

@@ -0,0 +1,41 @@
+import fs from 'fs';
+import klawSync from 'klaw-sync';
+// tslint:disable:no-console
+
+/**
+ * Generates the Hugo front matter with the title of the document
+ */
+export function generateFrontMatter(title: string, weight: number, showToc: boolean = true): string {
+    return `---
+title: "${title.replace('-', ' ')}"
+weight: ${weight}
+date: ${new Date().toISOString()}
+showtoc: ${showToc}
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+`;
+}
+
+/**
+ * Delete all generated docs found in the outputPath.
+ */
+export function deleteGeneratedDocs(outputPath: string) {
+    let deleteCount = 0;
+    const files = klawSync(outputPath, {nodir: true});
+    for (const file of files) {
+        const content = fs.readFileSync(file.path, 'utf-8');
+        if (isGenerated(content)) {
+            fs.unlinkSync(file.path);
+            deleteCount++;
+        }
+    }
+    console.log(`Deleted ${deleteCount} generated docs`);
+}
+
+/**
+ * Returns true if the content matches that of a generated document.
+ */
+function isGenerated(content: string) {
+    return /generated\: true\n---\n/.test(content);
+}

+ 174 - 0
codegen/generate-api-docs.ts

@@ -0,0 +1,174 @@
+import fs from 'fs';
+import {
+    buildClientSchema,
+    GraphQLField,
+    GraphQLInputObjectType,
+    GraphQLNamedType,
+    GraphQLObjectType,
+    GraphQLType,
+    isEnumType,
+    isInputObjectType,
+    isNamedType,
+    isObjectType,
+    isScalarType,
+} from 'graphql';
+import path from 'path';
+
+import { deleteGeneratedDocs, generateFrontMatter } from './docgen-utils';
+
+// tslint:disable:no-console
+
+// The path to the introspection schema json file
+const SCHEMA_FILE = path.join(__dirname, '../schema.json');
+// The absolute URL to the generated api docs section
+const docsUrl = '/docs/graphql-api/';
+// The directory in which the markdown files will be saved
+const outputPath = path.join(__dirname, '../docs/content/docs/graphql-api');
+
+const enum FileName {
+    ENUM = 'enums',
+    INPUT = 'input-types',
+    MUTATION = 'mutations',
+    QUERY = 'queries',
+    OBJECT = 'object-types',
+}
+
+const schemaJson = fs.readFileSync(SCHEMA_FILE, 'utf8');
+const parsed = JSON.parse(schemaJson);
+const schema = buildClientSchema(parsed.data);
+
+deleteGeneratedDocs(outputPath);
+generateApiDocs(outputPath);
+
+function generateApiDocs(hugoOutputPath: string) {
+    const timeStart = +new Date();
+    let queriesOutput = generateFrontMatter('Queries', 1) + `\n\n# Queries\n\n`;
+    let mutationsOutput = generateFrontMatter('Mutations', 2) + `\n\n# Mutations\n\n`;
+    let objectTypesOutput = generateFrontMatter('Types', 3) + `\n\n# Types\n\n`;
+    let inputTypesOutput = generateFrontMatter('Input Objects', 4) + `\n\n# Input Objects\n\n`;
+    let enumsOutput = generateFrontMatter('Enums', 5) + `\n\n# Enums\n\n`;
+
+    for (const type of Object.values(schema.getTypeMap())) {
+        if (type.name.substring(0, 2) === '__') {
+            // ignore internal types
+            continue;
+        }
+
+        if (isObjectType(type)) {
+            if (type.name === 'Query') {
+                for (const field of Object.values(type.getFields())) {
+                    queriesOutput += `## ${field.name}\n`;
+                    queriesOutput += renderDescription(field);
+                    queriesOutput += renderFields([field], false) + '\n\n';
+                }
+            } else if (type.name === 'Mutation') {
+                for (const field of Object.values(type.getFields())) {
+                    mutationsOutput += `## ${field.name}\n`;
+                    mutationsOutput += renderDescription(field);
+                    mutationsOutput += renderFields([field], false) + '\n\n';
+                }
+            } else {
+                objectTypesOutput += `## ${type.name}\n\n`;
+                objectTypesOutput += renderDescription(type);
+                objectTypesOutput += renderFields(type);
+                objectTypesOutput += `\n`;
+            }
+        }
+
+        if (isEnumType(type)) {
+            enumsOutput += `## ${type.name}\n\n`;
+            enumsOutput += renderDescription(type) + '\n\n';
+            enumsOutput += '{{% gql-enum-values %}}\n';
+            for (const value of type.getValues()) {
+                enumsOutput += value.description ? ` * *// ${value.description.trim()}*\n` : '';
+                enumsOutput += ` * ${value.name}\n`;
+            }
+            enumsOutput += '{{% /gql-enum-values %}}\n';
+            enumsOutput += '\n';
+        }
+
+        if (isScalarType(type)) {
+            objectTypesOutput += `## ${type.name}\n\n`;
+            objectTypesOutput += renderDescription(type);
+        }
+
+        if (isInputObjectType(type)) {
+            inputTypesOutput += `## ${type.name}\n\n`;
+            inputTypesOutput += renderDescription(type);
+            inputTypesOutput += renderFields(type);
+            inputTypesOutput += `\n`;
+        }
+    }
+
+    fs.writeFileSync(path.join(hugoOutputPath, FileName.QUERY + '.md'), queriesOutput);
+    fs.writeFileSync(path.join(hugoOutputPath, FileName.MUTATION + '.md'), mutationsOutput);
+    fs.writeFileSync(path.join(hugoOutputPath, FileName.OBJECT + '.md'), objectTypesOutput);
+    fs.writeFileSync(path.join(hugoOutputPath, FileName.INPUT + '.md'), inputTypesOutput);
+    fs.writeFileSync(path.join(hugoOutputPath, FileName.ENUM + '.md'), enumsOutput);
+
+    console.log(`Generated 5 GraphQL API docs in ${+new Date() - timeStart}ms`);
+}
+
+/**
+ * Renders the type description if it exists.
+ */
+function renderDescription(type: { description?: string | null }, appendNewlines = true): string {
+    return type.description ? `${type.description + (appendNewlines ? '\n\n' : '')}` : '';
+}
+
+function renderFields(
+    typeOrFields: (GraphQLObjectType | GraphQLInputObjectType) | Array<GraphQLField<any, any>>,
+    includeDescription = true,
+): string {
+    let output = '{{% gql-fields %}}\n';
+    const fieldsArray: Array<GraphQLField<any, any>> = Array.isArray(typeOrFields)
+        ? typeOrFields
+        : Object.values(typeOrFields.getFields());
+    for (const field of fieldsArray) {
+        if (includeDescription) {
+            output += field.description ? `* *// ${field.description.trim()}*\n` : '';
+        }
+        output += ` * ${renderFieldSignature(field)}\n`;
+    }
+    output += '{{% /gql-fields %}}\n\n';
+    return output;
+}
+
+/**
+ * Renders a field signature including any argument and output type
+ */
+function renderFieldSignature(field: GraphQLField<any, any>): string {
+    let name = field.name;
+    if (field.args && field.args.length) {
+        name += `(${field.args.map(arg => arg.name + ': ' + renderTypeAsLink(arg.type)).join(', ')})`;
+    }
+    return `${name}: ${renderTypeAsLink(field.type)}`;
+}
+
+/**
+ * Renders a type as a markdown link.
+ */
+function renderTypeAsLink(type: GraphQLType): string {
+    const innerType = unwrapType(type);
+    const fileName = isEnumType(innerType)
+        ? FileName.ENUM
+        : isInputObjectType(innerType)
+        ? FileName.INPUT
+        : FileName.OBJECT;
+    const url = `${docsUrl}${fileName}#${innerType.name.toLowerCase()}`;
+    return type.toString().replace(innerType.name, `[${innerType.name}](${url})`);
+}
+
+/**
+ * Unwraps the inner type from a higher-order type, e.g. [Address!]! => Address
+ */
+function unwrapType(type: GraphQLType): GraphQLNamedType {
+    if (isNamedType(type)) {
+        return type;
+    }
+    let innerType = type;
+    while (!isNamedType(innerType)) {
+        innerType = innerType.ofType;
+    }
+    return innerType;
+}

+ 11 - 44
codegen/generate-docs.ts → codegen/generate-config-docs.ts

@@ -5,6 +5,8 @@ import ts from 'typescript';
 
 import { assertNever, notNullOrUndefined } from '../shared/shared-utils';
 
+import { deleteGeneratedDocs, generateFrontMatter } from './docgen-utils';
+
 // The absolute URL to the generated docs section
 const docsUrl = '/docs/configuration/';
 // The directory in which the markdown files will be saved
@@ -82,47 +84,25 @@ const tsFiles = tsSourceDirs
     .reduce((allFiles, files) => [...allFiles, ...files], [])
     .map(item => item.path);
 
-deleteGeneratedDocs();
-generateDocs(tsFiles, outputPath, globalTypeMap);
+deleteGeneratedDocs(outputPath);
+generateConfigDocs(tsFiles, outputPath, globalTypeMap);
 const watchMode = !!process.argv.find(arg => arg === '--watch' || arg === '-w');
 if (watchMode) {
     console.log(`Watching for changes to source files...`);
     tsFiles.forEach(file => {
         fs.watchFile(file, { interval: 1000 }, () => {
-            generateDocs([file], outputPath, globalTypeMap);
+            generateConfigDocs([file], outputPath, globalTypeMap);
         });
     });
 }
 
-/**
- * Delete all generated docs found in the outputPath.
- */
-function deleteGeneratedDocs() {
-    let deleteCount = 0;
-    const files = klawSync(outputPath, { nodir: true });
-    for (const file of files) {
-        const content = fs.readFileSync(file.path, 'utf-8');
-        if (isGenerated(content)) {
-            fs.unlinkSync(file.path);
-            deleteCount++;
-        }
-    }
-    console.log(`Deleted ${deleteCount} generated docs`);
-}
-
-/**
- * Returns true if the content matches that of a generated document.
- */
-function isGenerated(content: string) {
-    return /generated\: true\n---\n/.test(content);
-}
-
 /**
  * Uses the TypeScript compiler API to parse the given files and extract out the documentation
  * into markdown files
  */
-function generateDocs(filePaths: string[], hugoApiDocsPath: string, typeMap: TypeMap) {
+function generateConfigDocs(filePaths: string[], hugoOutputPath: string, typeMap: TypeMap) {
     const timeStart = +new Date();
+    let generatedCount = 0;
     const sourceFiles = filePaths.map(filePath => {
         return ts.createSourceFile(
             filePath,
@@ -160,7 +140,7 @@ function generateDocs(filePaths: string[], hugoApiDocsPath: string, typeMap: Typ
                 assertNever(info);
         }
 
-        const categoryDir = path.join(hugoApiDocsPath, info.category);
+        const categoryDir = path.join(hugoOutputPath, info.category);
         const indexFile = path.join(categoryDir, '_index.md');
         if (!fs.existsSync(categoryDir)) {
             fs.mkdirSync(categoryDir);
@@ -168,13 +148,15 @@ function generateDocs(filePaths: string[], hugoApiDocsPath: string, typeMap: Typ
         if (!fs.existsSync(indexFile)) {
             const indexFileContent = generateFrontMatter(info.category, 10, false) + `\n\n# ${info.category}`;
             fs.writeFileSync(indexFile, indexFileContent);
+            generatedCount ++;
         }
 
         fs.writeFileSync(path.join(categoryDir, info.fileName + '.md'), markdown);
+        generatedCount ++;
     }
 
     if (declarationInfos.length) {
-        console.log(`Generated ${declarationInfos.length} docs in ${+new Date() - timeStart}ms`);
+        console.log(`Generated ${generatedCount} configuration docs in ${+new Date() - timeStart}ms`);
     }
 }
 
@@ -500,21 +482,6 @@ function renderDescription(description: string, knownTypeMap: TypeMap): string {
     return description;
 }
 
-/**
- * Generates the Hugo front matter with the title of the document
- */
-function generateFrontMatter(title: string, weight: number, showToc: boolean = true): string {
-    return `---
-title: "${title.replace('-', ' ')}"
-weight: ${weight}
-date: ${new Date().toISOString()}
-showtoc: ${showToc}
-generated: true
----
-<!-- This file was generated from the Vendure TypeScript source. Do not modify. Instead, re-run "generate-docs" -->
-`;
-}
-
 /**
  * Reads the @docsWeight JSDoc tag from the interface.
  */

+ 19 - 11
docs/README.md

@@ -18,41 +18,49 @@ This task will:
 
 Run `docs:watch` when developing the docs site. This will run all of the above in watch mode, so you can go to [http://localhost:1313](http://localhost:1313) to view the docs site. It will auto-reload the browser on any changes to the server source, the docs script/styles assets, or the Hugo templates.
 
-## API Docs Generation
+## Docs Generation
 
-The API docs are generated from the TypeScript source files by running the "generate-docs" script:
+All of the documentation for the interal APIs (configuration docs) and the GraphQL API is auto-generated.
+
+### GraphQL Docs
+
+The GraphQL API docs are generated from the `schema.json` file which is created as part of the "generate-gql-types" script.
+
+### Configuration Docs
+
+The configuration docs are generated from the TypeScript source files by running the "generate-config-docs" script:
 
 ```bash
-yarn generate-docs [-w]
+yarn generate-config-docs [-w]
 ```
 
 This script uses the TypeScript compiler API to traverse the server source code and extract data about the types as well as other information such as descriptions and default values.
 
-Currently, any `interface` which includes the JSDoc `@docCategory` tag will be extracted into a markdown file in the [content/docs/api](./content/docs/api) directory. Hugo can then build the API documentation from these markdown files. This will probably be expanded to be able to parse `class` and `type` declarations too.
+Currently, any `interface`, `class` or `type` which includes the JSDoc `@docCategory` tag will be extracted into a markdown file in the [content/docs/api](./content/docs/api) directory. Hugo can then build the API documentation from these markdown files.
 
-### Docs-specific JSDoc tags
+#### Docs-specific JSDoc tags
 
-#### `@docsCategory`
+##### `@docsCategory`
 
 This is required as its presence determines whether the declaration is extracted into the docs. Its value should be a string corresponding to the API sub-section that this declaration belongs to, e.g. "payment", "shipping" etc.
 
-#### `@description`
+##### `@description`
 
 This tag specifies the text description of the declaration. It supports markdown, but should not be used for code blocks, which should be tagged with `@example` (see below). Links to other declarations can be made with the `{@link SomeOtherDeclaration}` syntax. Also applies to class/interface members.
 
-#### `@example`
+##### `@example`
 
 This tag should be used to include any code blocks. Remember to specify the language after the opening delimiter for correct highlighting. Also applies to class/interface members.
 
-#### `@docsWeight`
+##### `@docsWeight`
 
 This is optional and when present, sets the "weight" of the markdown file in the Hugo front matter. A lower value makes the resulting doc page appear higher in the menu. If not specified, a default value of `10` is used.
 
-#### `@default`
+##### `@default`
 
 This is used to specify the default value of a property, e.g. when documenting an optional configuration option.
 
-#### Example
+##### Example
 
 ````ts
 /**

+ 21 - 0
docs/assets/styles/_mixins.scss

@@ -0,0 +1,21 @@
+@import "variables";
+
+@mixin code-block {
+    padding: 12px;
+    font-size: 14px;
+    border-radius: 3px;
+    overflow-x: auto;
+    border: 1px solid $color-code-border;
+    color: $color-code-text;
+    background-color: $color-code-bg;
+    position: relative;
+}
+
+@mixin code-block-lang {
+    position: absolute;
+    right: 3px;
+    top: 0;
+    font-size: 12px;
+    color: $gray-400;
+    text-transform: uppercase;
+}

+ 43 - 0
docs/assets/styles/_shortcodes.scss

@@ -1,4 +1,5 @@
 @import "variables";
+@import "mixins";
 
 /**
  Alert
@@ -55,3 +56,45 @@
     font-size: $font-size-12;
     border-bottom: 1px dashed $gray-400;
 }
+
+/**
+ GraphQL field list
+ */
+.gql-fields {
+    @include code-block;
+    &::before {
+        content: 'sdl';
+        @include code-block-lang;
+    }
+    ul {
+        padding: 0;
+        margin: 0;
+        list-style-type: none;
+        font-family: 'Oxygen Mono', monospace;
+    }
+    em {
+        color: #776e71;
+    }
+}
+
+/**
+ GraphQL enum values
+ */
+.gql-enum-values {
+    @include code-block;
+    &::before {
+        content: 'sdl';
+        @include code-block-lang;
+    }
+    max-height: 80vh;
+    overflow-y: auto;
+    ul {
+        padding: 0;
+        margin: 0;
+        list-style-type: none;
+        font-family: 'Oxygen Mono', monospace;
+    }
+    em {
+        color: #776e71;
+    }
+}

+ 3 - 14
docs/assets/styles/_syntax.scss

@@ -1,3 +1,4 @@
+@import "mixins";
 @import "variables";
 
 /**
@@ -5,26 +6,14 @@
  */
 
 pre.chroma {
-    padding: 12px;
-    font-size: 14px;
-    border-radius: 3px;
-    overflow-x: auto;
-    border: 1px solid $color-code-border;
-    position: relative;
+    @include code-block;
 
     > code::before {
         content: attr(data-lang);
-        position: absolute;
-        right: 3px;
-        top: 0;
-        font-size: 12px;
-        color: $gray-400;
-        text-transform: uppercase;
+        @include code-block-lang;
     }
 }
 
-/* Background */ .chroma { color: $color-code-text; background-color: $color-code-bg;
-                 }
 /* Error */ .chroma .err { color: #ef6155 }
 /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
 /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; width: auto; overflow: auto; display: block; }

+ 1 - 1
docs/assets/styles/main.scss

@@ -12,7 +12,7 @@
 html {
     font-size: $font-size-base;
     letter-spacing: 0.33px;
-    scroll-behavior: smooth;
+    // scroll-behavior: smooth;
 }
 
 html,

+ 12 - 0
docs/content/docs/graphql-api/_index.md

@@ -0,0 +1,12 @@
+---
+title: "GraphQL API"
+weight: 3
+---
+
+# GraphQL API Docs
+
+This section contains a description of all queries, mutations and related types available in the Vendure GraphQL API.
+
+{{% alert %}}
+All documentation in this section is auto-generated from the Vendure GraphQL schema.
+{{% /alert %}}

+ 3 - 0
docs/layouts/shortcodes/gql-enum-values.html

@@ -0,0 +1,3 @@
+<div class="gql-enum-values">
+    {{ .Inner }}
+</div>

+ 3 - 0
docs/layouts/shortcodes/gql-fields.html

@@ -0,0 +1,3 @@
+<div class="gql-fields">
+    {{ .Inner }}
+</div>

+ 4 - 3
package.json

@@ -2,10 +2,11 @@
   "name": "vendure",
   "version": "0.1.0",
   "scripts": {
-    "docs:watch": "concurrently -n docgen,hugo,webpack \"yarn generate-docs -w\" \"cd docs && hugo server\" \"cd docs && yarn webpack -w\"",
-    "docs:build": "yarn generate-docs && cd docs && yarn webpack --prod && hugo",
+    "docs:watch": "concurrently -n docgen,hugo,webpack \"yarn generate-api-docs && yarn generate-config-docs -w\" \"cd docs && hugo server\" \"cd docs && yarn webpack -w\"",
+    "docs:build": "yarn generate-api-docs && yarn generate-config-docs && cd docs && yarn webpack --prod && hugo",
     "generate-gql-types": "ts-node ./codegen/generate-graphql-types.ts",
-    "generate-docs": "ts-node ./codegen/generate-docs.ts",
+    "generate-config-docs": "ts-node ./codegen/generate-config-docs.ts",
+    "generate-api-docs": "ts-node ./codegen/generate-api-docs.ts",
     "postinstall": "cd admin-ui && yarn && cd ../server && yarn",
     "test": "cd admin-ui && yarn test --watch=false --browsers=ChromeHeadlessCI --progress=false && cd ../server && yarn test && yarn test:e2e",
     "format": "prettier --write --html-whitespace-sensitivity ignore",

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 3 - 14
shared/generated-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-01-25T15:42:49+01:00
+// Generated in 2019-02-01T14:23:55+01:00
 export type Maybe<T> = T | null;
 
 
@@ -807,12 +807,7 @@ export interface UpdateGlobalSettingsInput {
   
   availableLanguages?: Maybe<LanguageCode[]>;
   
-  customFields?: Maybe<UpdateGlobalSettingsCustomFieldsInput>;
-}
-
-export interface UpdateGlobalSettingsCustomFieldsInput {
-  
-  royalMailId?: Maybe<string>;
+  customFields?: Maybe<Json>;
 }
 
 export interface PaymentInput {
@@ -5205,7 +5200,7 @@ export interface GlobalSettings {
   
   serverConfig: ServerConfig;
   
-  customFields?: Maybe<GlobalSettingsCustomFields>;
+  customFields?: Maybe<Json>;
 }
 
 
@@ -5215,12 +5210,6 @@ export interface ServerConfig {
 }
 
 
-export interface GlobalSettingsCustomFields {
-  
-  royalMailId?: Maybe<string>;
-}
-
-
 export interface ShippingMethodQuote {
   
   id: string;

Some files were not shown because too many files changed in this diff