diff --git a/.eslintrc.js b/.eslintrc.js index e159ae8b..56d763e9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = { ], }, ignorePatterns: [ + 'vendor-src/**/*.*js', 'src/**/javascripts/**/*.js', 'examples/output/javascripts/**/*.js', ], diff --git a/.github/dependabot.yml b/.github/dependabot.yml index da3309c9..097ef389 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,3 +7,20 @@ updates: open-pull-requests-limit: 10 reviewers: - newhouse +- package-ecosystem: npm + target-branch: 1.x + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + reviewers: + - newhouse +- package-ecosystem: npm + target-branch: rc-2-0-0 + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + reviewers: + - newhouse + diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index cf2f5b32..80d8f93a 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -1,42 +1,102 @@ -# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions - name: CI Lint And Test on: push: branches: [ main ] pull_request: - # Leave out to test branches off of branches - # branches: [ main ] + +env: + NODE_VERSION_MAJOR: 16 + GITHUB_SHA: ${{ github.event.pull_request.head.sha }} + TARBALL_PATH: test/e2e/spectaql.tgz jobs: + + prepare-node: + name: Prepare Node + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ env.NODE_VERSION_MAJOR }}.x + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION_MAJOR }} + cache: 'yarn' + - run: yarn install + lint: name: Lint runs-on: ubuntu-latest + needs: prepare-node + steps: - - uses: actions/checkout@v2 - - name: Use Node.js 16.x - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ env.NODE_VERSION_MAJOR }}.x + uses: actions/setup-node@v3 with: - node-version: 16.x + node-version: ${{ env.NODE_VERSION_MAJOR }} cache: 'yarn' + - run: yarn install - run: yarn lint:quiet - test: - name: Test + build-e2e-package: + name: Build E2E Package + runs-on: ubuntu-latest + needs: prepare-node + + steps: + - uses: actions/checkout@v3 + + - name: Cache Package Build + id: cache-package-build + uses: actions/cache@v3 + with: + # We'll cache this file + path: ${{ env.TARBALL_PATH }} + key: ${{ runner.os }}-node-v${{ env.NODE_VERSION_MAJOR }}-${{ env.GITHUB_SHA }} + + - name: Use Node.js ${{ env.NODE_VERSION_MAJOR }}.x + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION_MAJOR }} + cache: 'yarn' + + - run: yarn install + - run: yarn test-e2e:build + + unit-test-and-package-test: + name: Unit Test & Package Test runs-on: ubuntu-latest + needs: build-e2e-package strategy: matrix: - node-version: [12.x, 14.x, 16.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + node-version: [14, 16, 18] + steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + + - name: Cache Package Build + id: cache-package-build + uses: actions/cache@v3 + with: + # This is the file to cache / restore + path: ${{ env.TARBALL_PATH }} + key: ${{ runner.os }}-node-v${{ env.NODE_VERSION_MAJOR }}-${{ env.GITHUB_SHA }} + - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: 'yarn' + + # Node 14 ships with a version of NPM that does not work for us + - name: Optionally update NPM if needed + if: ${{ matrix.node-version == '14' }} + run: npm i -g npm@7 + - run: yarn test-e2e:install-and-test - run: yarn install - run: yarn test diff --git a/.gitignore b/.gitignore index 5fffd4bd..a6566739 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ dist/ +vendor/ public node_modules scratch config-local.yml +**/*.tgz **/.DS_STORE **/.DS_Store ~* diff --git a/.nvmrc b/.nvmrc index dae199ae..6f7f377b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v12 +v16 diff --git a/.prettierignore b/.prettierignore index 7925d9ed..8f2f3c89 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,3 @@ +vendor/**/*.js +vendor-src/**/*.js src/javascripts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21312e05..a7adf281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +### 2.0.0 + - BREAKING CHANGE: Drops support for Node 12. Requires Node `>=14` + - BREAKING CHANGE: Requires `npm >= 7` + - BREAKING CHANGE: The `run` and `loadData` commands are now asynchronous and return a promise. + - Fixes bugs and adds better support for various `example` generation scenarios. + - BREAKING CHANGE: `Subscriptions` are now listed under `Operations` alongside `Queries` and `Mutations` in the default theme. + - BREAKING CHANGE: No external fonts are loaded in the included themes, so the `loadExternalFont` option was removed. + - `config` yaml will have environment variable substitution performed. + - `config` yaml can now be specified as a CLI option, instead of only as the first argument. + - Themes written as ESM/`.mjs` modules are now supported. + - BREAKING CHANGE: Accessibility improvements for `` tags deeper than `
` using the `aria-level` attribute. + - `embeddable`, `oneFile` and `targetDir` are now options that can be specified in the config yaml (previously was only CLI). + - `targetDir` can be set to `null` in order to not write any output to a user directory. + - BREAKING CHANGE: `headers` CLI option is now `-H` instead of `-A` + - Moves a number of unmaintained Grunt dependencies "in-house" so that they can be updated for vulnerabilities and bugs. + - BREAKING CHANGE: Dropped support for accidental `queryNameStategy` option. 🤦 + +### 1.5.9 +- Updated dependencies. + +### 1.5.8 +- Add option to disable 3rd party font request in some built-in themes via the `loadExternalFont` option. https://github.com/anvilco/spectaql/pull/556 + +### 1.5.7 +- Default a few deconstructed object params to be an empty object. https://github.com/anvilco/spectaql/pull/553 +- Fix bug where config and SDL files were not being properly watched in some cases. https://github.com/anvilco/spectaql/pull/554 +- Fix bug, now allow anything but `undefined` to be used as an example. https://github.com/anvilco/spectaql/issues/547 + ### 1.5.6 - Re-publish after some reverts diff --git a/README.md b/README.md index b1abd779..c03d0a88 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ That config will direct a build that flexes the most interesting parts of Specta To generate your documentation, SpectaQL requires a configuration YAML. This file is where you can specify most of the options to make your output the way you'd like it. All the supported options and their descriptions can be found in the [`config-example.yml`](https://github.com/anvilco/spectaql/blob/master/config-example.yml) file. +Environment variable substitution will be performed, so feel free to use environment variables in your config. + You can also see a minimal-ish working example YAML in the [examples/config.yml](https://github.com/anvilco/spectaql/blob/master/examples/config.yml) file. ## Command Line Options @@ -169,9 +171,23 @@ Here's what you need to know: { key: String!, value: String! } ``` - All the `value` fields should be provided as strings, and they will be appropriately parsed based on the supported value of the `key` field. -- You do not need to add the definition of the `spectaql` directive, nor its `SpectaQLOption` input type. They will be added (and removed) by SpectaQL automatically if you enable the feature. +- SpectaQL does not need you to add the definition of the `spectaql` directive, nor its `SpectaQLOption` input type to your SDL. They will be added (and removed) by SpectaQL automatically if you enable the feature. However, if you are using that same SDL to create an executable schema, you will need to add the directive and options definitions. - The directive can be added to your SDL anywhere that directives are supported by GraphQL SDL syntax, but they may only have an impact on the areas that SpectaQL supports. +The directive-related SDL is: +```sdl +directive @spectaql(options: [SpectaQLOption]) on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION | SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION +input SpectaQLOption { key: String!, value: String! } +```` + +Or you can generate the required directive SDL programmatically like so: +```node +import { generateSpectaqlSdl } from 'spectaql' + +const spectaqlSdl = generateSpectaqlSdl() +// Do something with this SDL +``` + Once enabled, the directive can be used like so: ```sdl type MyType { diff --git a/babel.config.js b/babel.config.js index bd7784c7..f0c2276f 100644 --- a/babel.config.js +++ b/babel.config.js @@ -15,8 +15,13 @@ module.exports = { // modules: 'commonjs', targets: { // Keep this roughly in-line with our "engines.node" value in package.json - node: '12', + node: '14', }, + exclude: [ + // Node 14+ supports this natively AND we need it to operate natively + // so do NOT transpile it + 'proposal-dynamic-import', + ], }, ], ], diff --git a/bin/build-vendor.mjs b/bin/build-vendor.mjs new file mode 100644 index 00000000..576ee5b1 --- /dev/null +++ b/bin/build-vendor.mjs @@ -0,0 +1,198 @@ +import path from 'path' +import fs from 'fs' +import { readdir } from 'fs/promises' +import { fileURLToPath } from 'url' +import { execSync as exec } from 'child_process' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export const root = path.join(__dirname, '..') + +const CACHE_FILE_NAME = '.spectaql-cache' +const vendorSrcDir = path.join(root, 'vendor-src') + +if (!pathExists(vendorSrcDir)) { + console.warn(`No vendor-src directory. Not building vendor packages.`) + process.exit() +} +const vendorTargetDir = path.join(root, 'vendor') + +let isDryRun = isDryRunFn() +// isDryRun = true + +ensureDirectory(vendorTargetDir) +;(async function () { + const sourceDirectoryNames = ( + await readdir(vendorSrcDir, { withFileTypes: true }) + ) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + + for (const sourceDirectoryName of sourceDirectoryNames) { + // Pack the thing.... + const packageName = sourceDirectoryName + const packageDirectory = path.join(vendorTargetDir, packageName) + const sourceDirectory = path.join(vendorSrcDir, sourceDirectoryName) + + // Only set this value if we want/need to write to the FS + let newCacheValue + + const entry = await getOldestFileInDirectoryTs(sourceDirectory) + const calculatedValue = Math.max( + entry.stats.ctime.getTime(), + entry.stats.mtime.getTime() + ).toString() + const existingValue = getCacheValue(sourceDirectory) + if (!pathExists(packageDirectory)) { + // Always write it and always do it if the thing did not exist + newCacheValue = calculatedValue + } else { + if (existingValue === calculatedValue) { + if (!shouldForce()) { + continue + } + } else { + newCacheValue = calculatedValue + } + } + + console.log(`Building managed vendor dependency: ${packageName}`) + + // See if we can skip doing this again + if (pathExists(packageDirectory)) { + const entry = await getOldestFileInDirectoryTs(sourceDirectory) + const calculatedValue = Math.max( + entry.stats.ctime.getTime(), + entry.stats.mtime.getTime() + ).toString() + const existingValue = getCacheValue(sourceDirectory) + if (existingValue === calculatedValue) { + if (!shouldForce()) { + continue + } + } else { + newCacheValue = calculatedValue + } + } + + let options = [`--pack-destination ${vendorTargetDir}`] + + if (isDryRun) { + options.push('--dry-run') + } + + let args = options.join(' ') + let command = `npm pack ${args}` + + let tarballName = await exec(command, { + cwd: path.join(vendorSrcDir, sourceDirectoryName), + stdio: [undefined, undefined, 'ignore'], + }) + + tarballName = getTarballNameFromOutput(tarballName.toString()) + + // Unpack the thing... + const tarballPath = path.join(vendorTargetDir, tarballName) + + ensureDirectory(packageDirectory) + + options = ['--strip-components 1', `-C ${packageDirectory}`, '-xvf'] + + args = options.join(' ') + command = `tar ${args} ${tarballPath}` + + if (isDryRun) { + continue + } + + await exec(command, { stdio: [undefined, undefined, 'ignore'] }) + + // Remove the tarball + await exec(`rm ${tarballPath}`) + + // Only should be set when we should write it to the file system + if (newCacheValue) { + setCacheValue(sourceDirectory, newCacheValue) + } + + console.log('Done.') + } +})() + +function isDryRunFn() { + return process.env.npm_config_dry_run === true +} + +function stripSpecial(str) { + while (['\n', '\t'].includes(str[str.length - 1])) { + str = str.slice(0, -1) + } + return str +} + +function pathExists(pth) { + return fs.existsSync(pth) +} + +function ensureDirectory(pth) { + if (!pathExists(pth)) { + fs.mkdirSync(pth) + } +} + +function getTarballNameFromOutput(str) { + str = stripSpecial(str) + return str.split('\n').pop() +} + +function shouldForce() { + return process.argv.includes('--force') +} + +async function getOldestFileInDirectoryTs(pth) { + const fileStats = await gatherFileStats(pth) + const orderedFileStats = orderFileStats(fileStats) + return orderedFileStats?.[0] +} + +function orderFileStats(fileStats) { + return fileStats.sort((a, b) => b.stats.mtimeMs - a.stats.mtimeMs) +} + +async function gatherFileStats(pth, files = []) { + for (const entry of await readdir(pth, { withFileTypes: true })) { + const entryPath = path.join(pth, entry.name) + if (entry.isDirectory()) { + // Don't go into node_modules + if (entry.name === 'node_modules') { + continue + } + await gatherFileStats(entryPath, files) + } else { + // Don't count our cache file + if (entry.name === CACHE_FILE_NAME) { + continue + } + files.push({ + name: entry.name, + stats: fs.lstatSync(entryPath), + }) + } + } + + return files +} + +function getCacheValue(dir) { + const pth = path.join(dir, CACHE_FILE_NAME) + if (!pathExists(pth)) { + return + } + return fs.readFileSync(pth, 'utf8') +} + +function setCacheValue(dir, value) { + const pth = path.join(dir, CACHE_FILE_NAME) + return fs.writeFileSync(pth, value.toString()) +} diff --git a/config-example.yml b/config-example.yml index 1bb4e9c0..79970f67 100644 --- a/config-example.yml +++ b/config-example.yml @@ -1,7 +1,23 @@ # This config will not run "as-is" and will need to be modified. You can see a minimal working # config in /examples/config.yml +# +# Environment variable substitution will be performed by SpectaQL on all strings encountered spectaql: + # Optional Boolean indicating whether to omit the HTML and generate the documentation content only + # Default: false + embeddable: false + + # Optional Boolean indicating whether to embed all resources (CSS and JS) into the same file + # Default: false + oneFile: false + + # Optional path to the target build directory. + # Set to null to not write the output to the filesystem, making it only available via the API (default: public) + # + # Default: public + targetDir: path/to/output + # Optional path to an image to use as the logo in the top-left of the output logoFile: path/to/logo.png # Optional boolean indicating whether to preserve the filname of the logo provided. diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 00000000..629c2611 --- /dev/null +++ b/dev/README.md @@ -0,0 +1,5 @@ +#### Publishing beta releases: +- `npm version M.m.p-beta.x` +- `npm prepublish` +- `npm publish --tag beta` + diff --git a/dev/build-e2e.mjs b/dev/build-e2e.mjs new file mode 100644 index 00000000..1f9ee870 --- /dev/null +++ b/dev/build-e2e.mjs @@ -0,0 +1,48 @@ +import path from 'path' +import fs from 'fs' +import { readdir } from 'fs/promises' +import {fileURLToPath} from 'url' +import { execSync } from 'child_process' + +import { + root, + isDryRun as isDryRunFn, + getTarballNameFromOutput, + ensureDirectory, +} from './utils.mjs' + + +let isDryRun = isDryRunFn() +// isDryRun = true + +const e2eDir = path.join(root, 'test/e2e') +ensureDirectory(e2eDir) + +// Pack the thing.... +const packageName = 'spectaql' +let options = [ + `--pack-destination ${e2eDir}` +] + +if (isDryRun) { + options.push( + '--dry-run' + ) +} + +let args = options.join(' ') +let command = `npm pack ${args}` + +let tarballName = await execSync( + command, + { + cwd: root, + } +) +tarballName = getTarballNameFromOutput(tarballName.toString()) + +// Rename the thing +const orginalPath = path.join(e2eDir, tarballName) +const newPath = path.join(e2eDir, 'spectaql.tgz') + +await execSync(`mv ${orginalPath} ${newPath}`) diff --git a/dev/echoDirectiveSdl.mjs b/dev/echoDirectiveSdl.mjs new file mode 100644 index 00000000..39821730 --- /dev/null +++ b/dev/echoDirectiveSdl.mjs @@ -0,0 +1,3 @@ +import { generateSpectaqlSdl }from '../dist/index.js' + +console.log(generateSpectaqlSdl()) diff --git a/dev/utils.mjs b/dev/utils.mjs new file mode 100644 index 00000000..254570c5 --- /dev/null +++ b/dev/utils.mjs @@ -0,0 +1,31 @@ +import path from 'path' +import fs from 'fs' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const root = path.join(__dirname, '..') + +export function isDryRun () { + return process.env.npm_config_dry_run === true +} + +export function stripSpecial (str) { + while (['\n', '\t'].includes(str[str.length - 1])) { + str = str.slice(0, -1) + } + return str +} + +export function ensureDirectory (path) { + if (!fs.existsSync(path)){ + fs.mkdirSync(path); + } +} + +export function getTarballNameFromOutput (str) { + str = stripSpecial(str) + return str.split('\n').pop() +} + diff --git a/examples/data/schema.gql b/examples/data/schema.gql index fc018242..16220972 100644 --- a/examples/data/schema.gql +++ b/examples/data/schema.gql @@ -1,10 +1,13 @@ +directive @spectaql(options: [SpectaQLOption]) on QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | VARIABLE_DEFINITION | SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION +input SpectaQLOption { key: String!, value: String! } + "This is a `DateTime` scalar" scalar DateTime "This is a Phone" scalar PhoneNumber -scalar URL +scalar URL @spectaql(options: [{key: "example", value: "https://work.com" }]) scalar JSON scalar JSONObject scalar Void @@ -17,7 +20,7 @@ interface Node { } "`Markdown` and reference interpolation like [`[String!]!`]({{Types.String}}) are supported" -type SimpleTypeOne implements Node { +type SimpleTypeOne implements Node @spectaql(options: [{key: "example", value: "{\"id\": \"123\", \"myType\":[{\"id\": \"myTypeId\", \"name\": \"myTypeName\"}]}" }, {key: "undocumented", value: "false" }]) { id: String! "`Markdown` and reference interpolation like [`[String!]!`]({{Types.String}}) are supported" myType: [MyType!]! diff --git a/examples/themes/deep-nesting-data/README.md b/examples/themes/deep-nesting-data/README.md new file mode 100644 index 00000000..1c8f6007 --- /dev/null +++ b/examples/themes/deep-nesting-data/README.md @@ -0,0 +1 @@ +Will highlight what happens when deep-nesting things to the point where we go beyond `
` and the `aria-leval` attribute will come into play. diff --git a/examples/themes/deep-nesting-data/data.mjs b/examples/themes/deep-nesting-data/data.mjs new file mode 100644 index 00000000..1034fef6 --- /dev/null +++ b/examples/themes/deep-nesting-data/data.mjs @@ -0,0 +1,117 @@ +import _ from 'lodash' + +import { Microfiber as IntrospectionManipulator } from 'microfiber' + +const { get, sortBy } = _ + +export default ({ + introspectionResponse, + graphQLSchema: _graphQLSchema, + allOptions: _allOptions, + introspectionOptions, +}) => { + const introspectionManipulator = new IntrospectionManipulator( + introspectionResponse, + introspectionOptions?.microfiberOptions + ) + const queryType = introspectionManipulator.getQueryType() + const mutationType = introspectionManipulator.getMutationType() + const subscriptionType = introspectionManipulator.getSubscriptionType() + const otherTypes = introspectionManipulator.getAllTypes({ + includeQuery: false, + includeMutation: false, + includeSubscription: false, + }) + + const hasQueries = get(queryType, 'fields.length') + const hasMutations = get(mutationType, 'fields.length') + const hasQueriesOrMutations = hasQueries || hasMutations + const hasSubscriptions = get(subscriptionType, 'fields.length') + const hasOtherTypes = get(otherTypes, 'length') + + return [ + hasQueriesOrMutations + ? { + name: 'Operations', + hideInContent: true, + items: [ + hasQueries + ? { + name: 'Queries Outside', + makeNavSection: true, + makeContentSection: true, + items: [ + { + name: 'Queries Middle Out', + makeNavSection: true, + makeContentSection: true, + items: [ + { + name: 'Queries Middle In', + makeNavSection: true, + makeContentSection: true, + items: [ + { + name: 'Queries Inside', + makeNavSection: true, + makeContentSection: true, + items: sortBy( + queryType.fields.map((query) => ({ + ...query, + isQuery: true, + })), + 'name' + ), + }, + ] + } + ], + }, + ], + } + : null, + hasMutations + ? { + name: 'Mutations', + makeNavSection: true, + makeContentSection: true, + items: sortBy( + mutationType.fields.map((query) => ({ + ...query, + isMutation: true, + })), + 'name' + ), + } + : null, + ], + } + : null, + hasOtherTypes + ? { + name: 'Types', + makeContentSection: true, + items: sortBy( + otherTypes.map((type) => ({ + ...type, + isType: true, + })), + 'name' + ), + } + : null, + hasSubscriptions + ? { + name: 'Subscriptions', + makeContentSection: true, + items: sortBy( + subscriptionType.fields.map((type) => ({ + ...type, + isSubscription: true, + })), + 'name' + ), + } + : null, + ].filter(Boolean) +} diff --git a/examples/themes/my-partial-theme/data/index.js b/examples/themes/my-partial-theme/data/index.js index 4a79ca36..fbfe8321 100644 --- a/examples/themes/my-partial-theme/data/index.js +++ b/examples/themes/my-partial-theme/data/index.js @@ -2,7 +2,6 @@ // MANNER BEFORE A MAJOR RELEASE. // // USE AT YOUR OWN RISK. - const { Microfiber: IntrospectionManipulator } = require('microfiber') function sortByName(a, b) { diff --git a/nodemon.json b/nodemon.json index b2ad0735..51f19c24 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,11 +1,12 @@ { "delay": 1000, "verbose": true, - "ext": "js,json,yaml,yml,css,scss,hbs,txt,gql,graphql,graphqls", + "ext": "js,mjs,json,yaml,yml,css,scss,hbs,txt,gql,graphql,graphqls", "ignore": [ ".git", - "docs", - "public", - "dist" + "docs/", + "public/", + "vendor/", + "dist/" ] } diff --git a/package.json b/package.json index 73511dd2..5f0098e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spectaql", - "version": "1.5.6", + "version": "2.0.0-beta.3", "description": "A powerful library for autogenerating static GraphQL API documentation", "author": "Anvil Foundry Inc. ", "homepage": "https://github.com/anvilco/spectaql", @@ -23,7 +23,8 @@ "generator" ], "engines": { - "node": ">=12" + "node": ">=14", + "npm": ">=7" }, "main": "index.js", "bin": { @@ -34,27 +35,46 @@ "README.md", "LICENSE.md", "CHANGELOG.md", + "bin/", + "vendor/", "dist/", "examples/", "!**/.DS_STORE", "!**/.DS_Store" ], "scripts": { - "clean": "yarn rimraf ./dist", - "build": "babel src --out-dir ./dist --copy-files", - "build:with-maps": "yarn build --source-maps", - "clean-build": "yarn clean && yarn build", - "prepare": "husky install && yarn clean && yarn build", - "pub:dry-run": "yarn prepare && npm publish --dry-run", - "test": "mocha --config ./test/mocha-config.js", - "test:debug": "yarn test --node-option inspect=0.0.0.0:9223", - "test:watch": "nodemon -x 'yarn clean-build && yarn test'", - "test:debug:watch": "nodemon -x 'yarn clean-build && yarn test:debug'", - "develop": "nodemon -x 'yarn clean-build && node bin/spectaql' -D", + "clean:dist": "yarn rimraf ./dist", + "clean:vendor": "yarn rimraf ./vendor", + "clean": "yarn clean:dist && yarn clean:vendor", + "build:with-maps": "yarn build:src --source-maps", "build:site": "node bin/spectaql", "build:example": "yarn build:site --target-dir ./examples/output ./examples/config.yml", + "build:src": "babel src --out-dir ./dist --copy-files", + "build:vendor": "node bin/build-vendor.mjs", + "build": "yarn build:vendor && yarn build:src", + "clean-build:src": "yarn clean:dist && yarn build:src", + "clean-build:vendor": "yarn clean:vendor && yarn build:vendor", + "clean-build": "yarn clean && yarn build", + "preinstall": "yarn build:vendor", + "prepack": "husky install && yarn clean-build", + "publish:dry-run": "npm pack --dry-run", + "publish:version": "npm version", + "publish:beta": "npm publish --tag beta", + "test": "NODE_ENV=test yarn build:vendor && yarn clean-build:src && NODE_ENV=test yarn mocha --config ./test/mocha-config.js", + "test:watch": "nodemon -x 'yarn test'", + "test:debug": "yarn test --node-option inspect=0.0.0.0:9223", + "test:debug:watch": "nodemon -x 'yarn test:debug'", + "develop": "nodemon -x 'yarn clean-build:src && node bin/spectaql' -- -D", + "develop:deep-nesting": "yarn develop --theme-dir ./examples/themes/deep-nesting-data --config ./examples/config.yml", + "develop:echo-directive-sdl": "yarn node dev/echoDirectiveSdl.mjs", + "test-e2e:build": "yarn clean-build && rm -rf test/e2e/spectaql.tgz && yarn node dev/build-e2e.mjs", + "test-e2e:install": "yarn --cwd test/e2e prep && yarn --cwd test/e2e install", + "test-e2e:test": "yarn --cwd test/e2e test", + "test-e2e:build-and-install": "yarn test-e2e:build && yarn test-e2e:install", + "test-e2e:install-and-test": "yarn test-e2e:install && yarn test-e2e:test", + "test-e2e:all": "yarn test-e2e:build && yarn test-e2e:install && yarn test-e2e:test", "prettify": "prettier 'src/**/*.js' 'bin/**/*.js' 'test/**/*.js'", - "lint": "eslint 'src/**/*.js' 'bin/**/*.js' 'test/**/*.js'", + "lint": "eslint 'src/**/*.*js' 'bin/**/*.*js' 'test/**/*.*js'", "lint:fix": "yarn lint --fix", "lint:quiet": "yarn lint --quiet", "lint:quiet:fix": "yarn lint:quiet --fix", @@ -64,7 +84,6 @@ }, "dependencies": { "@anvilco/apollo-server-plugin-introspection-metadata": "^1.2.0", - "@anvilco/grunt-embed": "^0.0.1", "@graphql-tools/load-files": "^6.3.2", "@graphql-tools/merge": "^8.1.2", "@graphql-tools/schema": "^9.0.1", @@ -72,29 +91,31 @@ "cheerio": "^1.0.0-rc.10", "coffeescript": "^2.6.1", "commander": "^9.1.0", + "fast-glob": "^3.2.12", "foundation-sites": "^6.7.2", + "graceful-fs": "~2.0.2", "graphql": "^16.3.0", "graphql-scalars": "^1.15.0", - "grunt": "^1.4.1", - "grunt-compile-handlebars": "^2.0.2", + "grunt": "^1.5.3", "grunt-contrib-clean": "^2.0.0", "grunt-contrib-concat": "^2.1.0", "grunt-contrib-connect": "^3.0.0", "grunt-contrib-copy": "^1.0.0", "grunt-contrib-cssmin": "^4.0.0", - "grunt-contrib-handlebars": "^3.0.0", "grunt-contrib-uglify": "^5.0.1", "grunt-contrib-watch": "^1.1.0", - "grunt-prettify": "^0.4.0", "grunt-sass": "^3.0.2", - "handlebars": "^4.3.0", + "handlebars": "^4.7.7", "highlight.js": "^11.4.0", + "htmlparser2": "~3.5.0", + "js-beautify": "~1.5.4", "js-yaml": "^4.1.0", "json-stringify-pretty-compact": "^3.0.0", "json5": "^2.2.0", "lodash": "^4.17.12", "marked": "^4.0.12", "microfiber": "^1.3.1", + "postcss": "^7.0.36", "sass": "^1.32.13", "sync-request": "^6.1.0", "tmp": "0.2.1" @@ -107,6 +128,7 @@ "app-module-path": "^2.2.0", "bdd-lazy-var": "^2.6.0", "chai": "^4.3.0", + "chai-as-promised": "^7.1.1", "chai-exclude": "^2.0.2", "eslint": "^8.10.0", "eslint-config-standard": "^16.0.3", @@ -115,12 +137,12 @@ "eslint-plugin-standard": "^5.0.0", "husky": ">=6", "lint-staged": ">=10", - "mocha": "^9.2.1", - "nodemon": "^2.0.15", + "mocha": "^10.1.0", + "nodemon": "^2.0.20", "prettier": "^2.5.1", "rewire": "^6.0.0", "rimraf": "^3.0.2", - "sinon": "^13.0.1", + "sinon": "^14.0.1", "sinon-chai": "^3.7.0" }, "resolutions": { diff --git a/src/cli.js b/src/cli.js index 9153de25..5030050c 100644 --- a/src/cli.js +++ b/src/cli.js @@ -9,11 +9,39 @@ export default function () { program .version(pkg.version) - .usage('[options] ') + .usage('[options] [-c | ]') .description(pkg.description) .option( - '-N, --noop', - 'This option does nothing, but may be useful in complex CLI scenarios to get argument parsing correct' + '-q, --quiet', + 'Silence the output from the generator (default: false)' + ) + .option( + '-c, --config ', + 'Specify the config yaml. Will take precedence over passing via the first argument.' + ) + + .option( + '-t, --target-dir ', + 'the target build directory. Set to "null" to not write the output to the filesystem, making it only available via the API (default: public)', + String + ) + + // This option specifies where the generated documentation HTML files will be output. + .option( + '-f, --target-file ', + 'the target build HTML file (default: index.html)', + String + ) + + // This option lets you build a minimal version of the documentation without the HTML `` tags, so you can embed + // SpectaQL into your own website template. + .option( + '-e, --embeddable', + 'omit the HTML and generate the documentation content only (default: false)' + ) + .option( + '-1, --one-file', + 'Embed all resources (CSS and JS) into the same file (default: false)' ) //************************************** @@ -50,62 +78,6 @@ export default function () { // //************************************** - // This option lets you build a minimal version of the documentation without the HTML `` tags, so you can embed - // SpectaQL into your own website template. - .option( - '-e, --embeddable', - 'omit the HTML and generate the documentation content only (default: false)' - ) - - .option( - '-1, --one-file', - 'Embed all resources (CSS and JS) into the same file (default: false)' - ) - .option( - '-d, --development-mode', - 'start HTTP server with the file watcher (default: false)' - ) - .option( - '-D, --development-mode-live', - 'start HTTP server with the file watcher and live reload (default: false)' - ) - .option( - '-s, --start-server', - 'start the HTTP server without any development features' - ) - .option( - '-p, --port ', - 'the port number for the HTTP server to listen on (default: 4400)', - Number - ) - .option( - '-P, --port-live ', - 'the port number for the live reload to listen on (default: 4401)', - Number - ) - .option( - '-t, --target-dir ', - 'the target build directory (default: public)', - String - ) - - // This option specifies where the generated documentation HTML files will be output. - .option( - '-f, --target-file ', - 'the target build HTML file (default: index.html)', - String - ) - - // This option overrides the default directory which contains all the Handlebars templates, SCSS, and JavaScript - // source files. This option is useful if you've copied the contents of `app` to a remote location or a separate - // repo and customized it. Probably not something you need to use if you have cloned/forked the repo and customized - // it - .option( - '-a, --app-dir ', - 'the application source directory (default: app)', - String - ) - .option( '--logo-file ', 'specify a custom logo file (default: null)', @@ -150,35 +122,68 @@ export default function () { 'specify a JS module that will dynamically generate schema examples (default: none', String ) + .option( - '-g, --grunt-config-file ', - 'specify a custom Grunt configuration file (default: dist/lib/gruntConfig.js)', + '-H, --headers ', + 'specify arbitrary HTTP headers for the Introspection Query as a JSON string (default: none)', String ) - // TODO: remove this option in favor of --headers as part of a breaking change + + .option( + '-d, --development-mode', + 'start HTTP server with the file watcher (default: false)' + ) + .option( + '-D, --development-mode-live', + 'start HTTP server with the file watcher and live reload (default: false)' + ) .option( - '-H, --header
', - 'specify a custom auth token for the header (default: none)' + '-s, --start-server', + 'start the HTTP server without any development features' + ) + .option( + '-p, --port ', + 'the port number for the HTTP server to listen on (default: 4400)', + Number + ) + .option( + '-P, --port-live ', + 'the port number for the live reload to listen on (default: 4401)', + Number + ) + + // This option overrides the default directory which contains all the Handlebars templates, SCSS, and JavaScript + // source files. This option is useful if you've copied the contents of `app` to a remote location or a separate + // repo and customized it. Probably not something you need to use if you have cloned/forked the repo and customized + // it + .option( + '-a, --app-dir ', + 'the application source directory (default: app)', + String ) .option( - '-A, --headers ', - 'specify arbitrary headers for the Introspection Query as a JSON string (default: none)', + '-g, --grunt-config-file ', + 'specify a custom Grunt configuration file (default: dist/lib/gruntConfig.js)', String ) .option( - '-q, --quiet', - 'Silence the output from the generator (default: false)' + '-N, --noop', + 'This option does nothing, but may be useful in complex CLI scenarios to get argument parsing correct' ) - // .option('-f, --spec-file ', 'the input OpenAPI/Swagger spec file (default: test/fixtures/petstore.json)', String, 'test/fixtures/petstore.json') .parse(process.argv) - // Show help if no specfile or options are specified - if (program.args.length < 1) { + const options = program.opts() + + if (options.config) { + // config => specFile + options.specFile = options.config + delete options.config + } else if (program.args.length >= 1) { + options.specFile = program.args[0] + } else { + // Show help if no specfile or options are specified program.help() } - const options = program.opts() - options.specFile = program.args[0] - return options } diff --git a/src/index.js b/src/index.js index 50a32319..ea7e9da5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,5 @@ import path from 'path' import _ from 'lodash' -import tmp from 'tmp' import grunt from 'grunt' import pkg from '../package.json' @@ -10,6 +9,7 @@ import { normalizePathFromCwd, pathToRoot, readTextFile, + tmpFolder, writeTextFile, } from './spectaql/utils' @@ -25,9 +25,6 @@ const defaultAppDir = normalizePathFromRoot('dist') let spectaql = require(path.resolve(defaultAppDir, 'spectaql/index')) let gruntConfigFn -// Ensures temporary files are cleaned up on program close, even if errors are encountered. -tmp.setGracefulCleanup() - //********************************************************************* // // These possible "themeDir" values are special and get translated @@ -56,17 +53,15 @@ const defaults = Object.freeze({ gruntConfigFile: normalizePathFromRoot('dist/lib/gruntConfig.js'), themeDir: defaultThemeDir, defaultThemeDir, - cacheDir: tmp.dirSync({ - unsafeCleanup: true, - prefix: 'spectaql-', - }).name, - oneFile: false, + cacheDir: tmpFolder(), specData: {}, }) // Things that may get set from either the CLI or the YAML.spectaql area, but if nothing // is set, then use these: const spectaqlOptionDefaults = Object.freeze({ + oneFile: false, + embeddable: false, errorOnInterpolationReferenceNotFound: true, displayAllServers: false, resolveWithOutput: true, @@ -76,6 +71,7 @@ const spectaqlDirectiveDefault = Object.freeze({ enable: true, directiveName: 'spectaql', optionsTypeName: 'SpectaQLOption', + onlyAddIfMissing: true, }) const introspectionOptionDefaults = Object.freeze({ @@ -283,6 +279,10 @@ export function resolveOptions(cliOptions) { // OK, layer in any defaults that may be set by the CLI and the YAML, but may not have been: opts = _.defaults({}, opts, spectaqlOptionDefaults) + if (!opts.targetDir || opts.targetDir.endsWith('/null')) { + opts.targetDir = tmpFolder() + } + if (opts.logoFile) { // Keep or don't keep the original logoFile name when copying to the target opts.logoFileTargetName = opts.preserveLogoName @@ -312,13 +312,13 @@ export function resolveOptions(cliOptions) { /** * Run SpectaQL and configured tasks **/ -export const run = function (cliOptions = {}) { +export const run = async function (cliOptions = {}) { const opts = resolveOptions(cliOptions) // //= Load the specification and init configuration - const gruntConfig = gruntConfigFn(grunt, opts, loadData(opts)) + const gruntConfig = gruntConfigFn(grunt, opts, await loadData(opts)) // //= Setup Grunt to do the heavy lifting @@ -348,10 +348,14 @@ export const run = function (cliOptions = {}) { grunt.loadNpmTasks('grunt-contrib-clean') grunt.loadNpmTasks('grunt-contrib-copy') grunt.loadNpmTasks('grunt-contrib-connect') - grunt.loadNpmTasks('grunt-compile-handlebars') - grunt.loadNpmTasks('grunt-prettify') grunt.loadNpmTasks('grunt-sass') - grunt.loadNpmTasks('@anvilco/grunt-embed') + + // These are the "local" grunt tasks that we maintain + grunt.loadTasks( + normalizePathFromRoot('vendor/grunt-compile-handlebars/tasks') + ) + grunt.loadTasks(normalizePathFromRoot('vendor/grunt-prettify/tasks')) + grunt.loadTasks(normalizePathFromRoot('vendor/grunt-embed/tasks')) process.chdir(cwd) @@ -421,26 +425,21 @@ export const run = function (cliOptions = {}) { 'prettify', ]) - grunt.registerTask('default', [ - 'clean-things', - 'copy-theme-stuff', - 'stylesheets', - 'javascripts', - 'templates', - ]) + const defaultTasks = [] grunt.registerTask('server', ['connect']) grunt.registerTask('develop', ['server', 'watch']) // Reload template data when watch files change - grunt.event.on('watch', function () { + grunt.event.on('watch', async function () { try { grunt.config.set( 'compile-handlebars.compile.templateData', - loadData(opts) + await loadData(opts) ) } catch (e) { + console.error(e) grunt.fatal(e) } }) @@ -478,13 +477,14 @@ export const run = function (cliOptions = {}) { const copiesToTarget = ['html-to-target'] + let doDevelop = false if (opts.startServer) { - grunt.task.run('server') + defaultTasks.push('server') } else { - grunt.task.run('clean-things') - grunt.task.run('copy-theme-stuff') + defaultTasks.push('clean-things') + defaultTasks.push('copy-theme-stuff') - grunt.task.run('stylesheets') + defaultTasks.push('stylesheets') // If not oneFile/embedding JS/CSS, then we'll need to copy the files if (!opts.oneFile) { @@ -492,7 +492,7 @@ export const run = function (cliOptions = {}) { } if (!opts.disableJs) { - grunt.task.run('javascripts') + defaultTasks.push('javascripts') // If not oneFile/embedding JS/CSS, then we'll need to copy the files if (!opts.oneFile) { @@ -506,16 +506,16 @@ export const run = function (cliOptions = {}) { copiesToTarget.unshift('favicon-to-target') } - grunt.task.run('templates') + defaultTasks.push('templates') // Resolve all the (local) JS and CSS requests and put them into the HTML // file itself if (opts.oneFile) { - grunt.task.run('embed') + defaultTasks.push('embed') } copiesToTarget.forEach((flavor) => { - grunt.task.run(`copy:${flavor}`) + defaultTasks.push(`copy:${flavor}`) }) // Delete the entire cache/temp directory @@ -525,10 +525,18 @@ export const run = function (cliOptions = {}) { // I don't know why, but if you drop into this block and run 'develop' // then the 'embed' task will not run...and vice versa` if (opts.developmentMode || opts.developmentModeLive) { - grunt.task.run('develop') + doDevelop = true } } + grunt.registerTask('default', defaultTasks) + grunt.task.run('default') + + if (doDevelop) { + // This one seems to freeze everything else up, so it should be done last + grunt.task.run('develop') + } + grunt.task.start() return donePromise diff --git a/src/lib/common.js b/src/lib/common.js index e517b117..debecb18 100644 --- a/src/lib/common.js +++ b/src/lib/common.js @@ -9,6 +9,7 @@ import { // hljsFunction as hljsGraphqlLang, } from '../spectaql/graphql-hl' import { analyzeTypeIntrospection } from '../spectaql/type-helpers' +import { isUndef, firstNonUndef } from '../spectaql/utils' import { Microfiber as IntrospectionManipulator } from 'microfiber' import { getExampleForGraphQLScalar } from '../themes/default/helpers/graphql-scalars' @@ -262,7 +263,7 @@ function introspectionArgToVariable({ } // If there is an example, use it. - if (arg.example) { + if (!isUndef(arg.example)) { return arg.example } @@ -367,19 +368,23 @@ function generateIntrospectionReturnTypeExample( { thing, underlyingTypeDefinition, originalType, quoteEnum = false }, otherOptions ) { - let example = - thing.example || - originalType.example || - underlyingTypeDefinition.example || - (underlyingTypeDefinition.kind === 'ENUM' && - underlyingTypeDefinition.enumValues.length && - (quoteEnum - ? addQuoteTags(underlyingTypeDefinition.enumValues[0].name) - : underlyingTypeDefinition.enumValues[0].name)) || - (underlyingTypeDefinition.kind === 'UNION' && - underlyingTypeDefinition.possibleTypes.length && - addSpecialTags(underlyingTypeDefinition.possibleTypes[0].name)) || - getExampleForScalarDefinition(underlyingTypeDefinition, otherOptions) + let example = firstNonUndef([ + thing.example, + originalType.example, + underlyingTypeDefinition.example, + ]) + if (isUndef(example)) { + example = + (underlyingTypeDefinition.kind === 'ENUM' && + underlyingTypeDefinition.enumValues.length && + (quoteEnum + ? addQuoteTags(underlyingTypeDefinition.enumValues[0].name) + : underlyingTypeDefinition.enumValues[0].name)) || + (underlyingTypeDefinition.kind === 'UNION' && + underlyingTypeDefinition.possibleTypes.length && + addSpecialTags(underlyingTypeDefinition.possibleTypes[0].name)) || + getExampleForScalarDefinition(underlyingTypeDefinition, otherOptions) + } if (typeof example !== 'undefined') { // example = unwindTags(example) diff --git a/src/lib/gruntConfig.js b/src/lib/gruntConfig.js index 53dc35f5..1a6e62d0 100644 --- a/src/lib/gruntConfig.js +++ b/src/lib/gruntConfig.js @@ -2,7 +2,22 @@ import path from 'path' import sass from 'sass' // Gotta keep this a commonjs export because of dynamic requiring -module.exports = function (grunt, options, spec) { +module.exports = function (grunt, options, spectaqlData) { + // Watch them schema file(s) + let schemaFiles = options.specData.introspection.schemaFile + if (!schemaFiles) { + schemaFiles = [] + } else if (Array.isArray(schemaFiles)) { + // Copy the Array so that the addition of the specFile does not get passed to + // the graphql schema merger + schemaFiles = [...schemaFiles] + } else { + schemaFiles = [schemaFiles] + } + + // And the spec file + schemaFiles.push(options.specFile) + return { // Compile SCSS source files into the cache directory sass: { @@ -91,7 +106,7 @@ module.exports = function (grunt, options, spec) { dest: options.cacheDir + '/' + options.targetFile, }, ], - templateData: spec, + templateData: spectaqlData, partials: options.cacheDir + '/views/partials/**/*.hbs', helpers: [ // You get all the built-in helpers for free @@ -218,13 +233,16 @@ module.exports = function (grunt, options, spec) { }, templates: { files: [ - options.specFile, options.themeDir + '/views/**/*.hbs', options.themeDir + '/helpers/**/*.js', options.themeDir + '/lib/**/*.js', ], tasks: ['templates'], }, + inputs: { + files: schemaFiles, + tasks: ['default'], + }, }, } } diff --git a/src/lib/interpolation.js b/src/lib/interpolation.js new file mode 100644 index 00000000..efc50a41 --- /dev/null +++ b/src/lib/interpolation.js @@ -0,0 +1,62 @@ +export function substituteEnvOnObject(obj) { + if (obj?.constructor.name !== 'Object') { + return obj + } + + return Object.entries(obj).reduce((acc, [key, value]) => { + acc[substituteEnv(key)] = substituteEnv(value) + return acc + }, {}) +} + +export function substituteEnv(valueIn) { + if (Array.isArray(valueIn)) { + return valueIn.map(substituteEnv) + } + if (valueIn.constructor.name === 'Object') { + return substituteEnvOnObject(valueIn) + } + if (!valueIn || typeof valueIn !== 'string') { + return valueIn + } + + // Quite heavily borrowed from https://github.com/motdotla/dotenv-expand + // which has over 10mm weekly downloads, so this feels solid. + const matches = valueIn.match(/(.?\${*[\w]*(?::-[\w/]*)?}*)/g) || [] + + return matches.reduce((newValue, match, index) => { + const parts = /(.?)\${*([\w]*(?::-[\w/]*)?)?}*/g.exec(match) + if (!parts || parts.length === 0) { + return newValue + } + + const prefix = parts[1] + let value, replacePart + + if (prefix === '\\') { + replacePart = parts[0] + value = replacePart.replace('\\$', '$') + } else { + const keyParts = parts[2].split(':-') + const key = keyParts[0] + const defautValue = keyParts[1] || '' + + replacePart = parts[0].substring(prefix.length) + value = Object.prototype.hasOwnProperty.call(process.env, key) + ? process.env[key] + : defautValue + + // If the value is found, remove nested expansions. + if (keyParts.length > 1 && value) { + const replaceNested = matches[index + 1] + matches[index + 1] = '' + + newValue = newValue.replace(replaceNested, '') + } + // Resolve recursive substitutesions + value = substituteEnv(value) + } + + return newValue.replace(replacePart, value) + }, valueIn) +} diff --git a/src/lib/loadYaml.js b/src/lib/loadYaml.js index 9e586c51..443743c7 100644 --- a/src/lib/loadYaml.js +++ b/src/lib/loadYaml.js @@ -1,7 +1,10 @@ import fs from 'fs' import yaml from 'js-yaml' -export default function (path) { +import { substituteEnvOnObject } from './interpolation' + +export default function loadYaml(path) { const fileContent = fs.readFileSync(path, 'utf8') - return yaml.load(fileContent) + const loadedYaml = yaml.load(fileContent) + return substituteEnvOnObject(loadedYaml) } diff --git a/src/spectaql/augmenters.js b/src/spectaql/augmenters.js index 773ff18a..6b4d32c5 100644 --- a/src/spectaql/augmenters.js +++ b/src/spectaql/augmenters.js @@ -6,6 +6,7 @@ import { typesAreSame, } from 'microfiber' +import { isUndef } from './utils' import { analyzeTypeIntrospection } from './type-helpers' import { addSpecialTags, addQuoteTags } from '../lib/common' import stripTrailing from '../themes/default/helpers/stripTrailing' @@ -305,6 +306,7 @@ export function addExamples(args = {}) { const queryType = introspectionManipulator.getQueryType() const mutationType = introspectionManipulator.getMutationType() + const subscriptionType = introspectionManipulator.getSubscriptionType() for (const type of types) { // Don't mess with reserved GraphQL types at all @@ -312,16 +314,19 @@ export function addExamples(args = {}) { continue } - const isQueryOrMutation = + const isQueryOrMutationOrSubscription = !!(queryType && typesAreSame(type, queryType)) || - !!(mutationType && typesAreSame(type, mutationType)) + !!(mutationType && typesAreSame(type, mutationType)) || + !!(subscriptionType && typesAreSame(type, subscriptionType)) - handleExamples({ type, isType: true }) + if (!isQueryOrMutationOrSubscription) { + handleExamples({ type }) + } for (const field of type.fields || []) { // Don't add examples to fields on the Query or Mutation types...because they are actually // queries or mutations, and we don't support that. - if (!isQueryOrMutation) { + if (!isQueryOrMutationOrSubscription) { handleExamples({ type, field }) } @@ -346,7 +351,6 @@ export function addExamples(args = {}) { function getExistingExample(thing) { let { example, examples } = _.get(thing, metadatasPath, {}) - if (examples && examples.length) { example = examples[Math.floor(Math.random() * examples.length)] } @@ -354,18 +358,18 @@ export function addExamples(args = {}) { return example } - function handleExamples({ type, field, inputField, arg, isType }) { + function handleExamples({ type, field, inputField, arg }) { const thing = arg || inputField || field || type const typeForAnalysis = thing === type ? type : thing.type const typeAnalysis = analyzeTypeIntrospection(typeForAnalysis) let example = getExistingExample(thing) - // Allow Scalars to have examples from the metadata...not 100% sure why not everything - if (!isUndef(example) && (!isType || type.kind === KINDS.SCALAR)) { + if (!isUndef(example)) { thing.example = example } example = processor({ ...typeAnalysis, type, field, arg, inputField }) + if (!isUndef(example)) { thing.example = example } @@ -438,7 +442,3 @@ export function removeTrailingPeriodsFromDescriptions(obj) { return obj } - -function isUndef(item) { - return typeof item === 'undefined' -} diff --git a/src/spectaql/directive.js b/src/spectaql/directive.js index ae12a0ab..ea74fe72 100644 --- a/src/spectaql/directive.js +++ b/src/spectaql/directive.js @@ -39,7 +39,7 @@ const OPTION_TO_CONVERTER_FN = { export function generateSpectaqlSdl({ directiveName = DEFAULT_DIRECTIVE_NAME, optionsTypeName = DEFAULT_DIRECTIVE_OPTION_NAME, -}) { +} = {}) { return ( generateDirectiveSdl({ directiveName, optionsTypeName }) + '\n' + @@ -51,7 +51,7 @@ export function generateSpectaqlSdl({ export function generateDirectiveSdl({ directiveName = DEFAULT_DIRECTIVE_NAME, optionsTypeName = DEFAULT_DIRECTIVE_OPTION_NAME, -}) { +} = {}) { // https://www.apollographql.com/docs/apollo-server/schema/creating-directives/#supported-locations // QUERY | MUTATION | SUBSCRIPTION | FIELD | FRAGMENT_DEFINITION | FRAGMENT_SPREAD | INLINE_FRAGMENT | // VARIABLE_DEFINITION | SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | @@ -63,7 +63,7 @@ export function generateDirectiveSdl({ export function generateOptionsSdl({ optionsTypeName = DEFAULT_DIRECTIVE_OPTION_NAME, -}) { +} = {}) { return `input ${optionsTypeName} { key: String!, value: String! }` } @@ -76,11 +76,34 @@ function processDirective(directive) { }, {}) } -export function generateSpectaqlDirectiveSupport( - spectaqlDirectiveOptions = {} -) { +export function generateSpectaqlDirectiveSupport({ + options: spectaqlDirectiveOptions = {}, + userSdl = '', +} = {}) { const directables = [] + const { + onlyAddIfMissing, + directiveName = DEFAULT_DIRECTIVE_NAME, + optionsTypeName = DEFAULT_DIRECTIVE_OPTION_NAME, + } = spectaqlDirectiveOptions + + let { directiveSdl, optionsSdl } = spectaqlDirectiveOptions + + if ( + !directiveSdl && + (!userSdl.includes(`directive @${directiveName}`) || !onlyAddIfMissing) + ) { + directiveSdl = generateSpectaqlSdl(spectaqlDirectiveOptions) + } + + if ( + !optionsSdl && + (!userSdl.includes(`input ${optionsTypeName}`) || !onlyAddIfMissing) + ) { + optionsSdl = generateOptionsSdl(spectaqlDirectiveOptions) + } + function typeHandler(type, schema, mapperKind) { const directive = getDirective(schema, type, 'spectaql')?.[0] if (!isEmpty(directive)) { @@ -155,13 +178,6 @@ export function generateSpectaqlDirectiveSupport( // [MapperKind.DIRECTIVE]: (...args) => configHandler(...args, MapperKind.DIRECTIVE), } - const { - directiveName = DEFAULT_DIRECTIVE_NAME, - directiveSdl = generateSpectaqlSdl(spectaqlDirectiveOptions), - optionsTypeName = DEFAULT_DIRECTIVE_OPTION_NAME, - optionsSdl = generateOptionsSdl(spectaqlDirectiveOptions), - } = spectaqlDirectiveOptions - return { directables, directiveName, diff --git a/src/spectaql/generate-graphql-example-data.js b/src/spectaql/generate-graphql-example-data.js index 1afb1133..40231abb 100644 --- a/src/spectaql/generate-graphql-example-data.js +++ b/src/spectaql/generate-graphql-example-data.js @@ -47,7 +47,6 @@ export function generateQuery({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) { let fieldExpansionDepth = @@ -76,26 +75,20 @@ export function generateQuery({ const cleanedQuery = queryResult.query.replace(/ : [\w![\]]+/g, '') - let consolidatedQueryNameStrategy = queryNameStrategy || queryNameStategy let queryName = field.name - if ( - !consolidatedQueryNameStrategy || - consolidatedQueryNameStrategy === QUERY_NAME_STATEGY_NONE - ) { + if (!queryNameStrategy || queryNameStrategy === QUERY_NAME_STATEGY_NONE) { // no op - } else if ( - consolidatedQueryNameStrategy === QUERY_NAME_STATEGY_CAPITALIZE_FIRST - ) { + } else if (queryNameStrategy === QUERY_NAME_STATEGY_CAPITALIZE_FIRST) { queryName = capitalizeFirstLetter(queryName) - } else if (consolidatedQueryNameStrategy === QUERY_NAME_STATEGY_CAPITALIZE) { + } else if (queryNameStrategy === QUERY_NAME_STATEGY_CAPITALIZE) { queryName = capitalize(queryName) - } else if (consolidatedQueryNameStrategy === QUERY_NAME_STATEGY_CAMELCASE) { + } else if (queryNameStrategy === QUERY_NAME_STATEGY_CAMELCASE) { queryName = camelCase(queryName) - } else if (consolidatedQueryNameStrategy === QUERY_NAME_STATEGY_SNAKECASE) { + } else if (queryNameStrategy === QUERY_NAME_STATEGY_SNAKECASE) { queryName = snakeCase(queryName) - } else if (consolidatedQueryNameStrategy === QUERY_NAME_STATEGY_UPPERCASE) { + } else if (queryNameStrategy === QUERY_NAME_STATEGY_UPPERCASE) { queryName = upperCase(queryName) - } else if (consolidatedQueryNameStrategy === QUERY_NAME_STATEGY_LOWERCASE) { + } else if (queryNameStrategy === QUERY_NAME_STATEGY_LOWERCASE) { queryName = lowerCase(queryName) } diff --git a/src/spectaql/graphql-loaders.js b/src/spectaql/graphql-loaders.js index befebe1c..cb0f3314 100644 --- a/src/spectaql/graphql-loaders.js +++ b/src/spectaql/graphql-loaders.js @@ -93,11 +93,21 @@ export const loadSchemaFromSDLFile = ({ optionsTypeName, transformer, directables, - } = generateSpectaqlDirectiveSupport(spectaqlDirectiveOptions)) + } = generateSpectaqlDirectiveSupport({ + options: spectaqlDirectiveOptions, + userSdl: printedTypeDefs, + })) } let schema = makeExecutableSchema({ - typeDefs: [directiveSdl, optionsSdl, printedTypeDefs], + typeDefs: [ + directiveSdl, + optionsSdl, + // I assume that these are processed in-order, so it's important to do the user-provided + // SDL *after* the spectaql generated directive-related SDL so that if they've defined + // or overridden things that will take precedence. + printedTypeDefs, + ], }) schema = transformer(schema) diff --git a/src/spectaql/index.js b/src/spectaql/index.js index b439d8d6..3f488f6e 100644 --- a/src/spectaql/index.js +++ b/src/spectaql/index.js @@ -3,21 +3,15 @@ import path from 'path' import buildSchemas from './build-schemas' import { augmentData } from './augmenters' import arrangeDataDefaultFn from '../themes/default/data' -import { fileExists } from './utils' +import { dynamicImport, fileExists, takeDefaultExport } from './utils' import preProcessData from './pre-process' -function run(opts) { +async function run(opts) { const { logo, favicon, specData: spec, themeDir } = opts const { introspection: introspectionOptions, - introspection: { - url: introspectionUrl, - queryNameStrategy, - // Ugh. Typo but gotta leave it now. - // TODO: remove in next Major / breaking release - queryNameStategy, - }, + introspection: { url: introspectionUrl, queryNameStrategy }, extensions = {}, servers = [], info = {}, @@ -55,17 +49,47 @@ function run(opts) { const { introspectionResponse, graphQLSchema } = buildSchemas(opts) // Figure out what data arranger to use...the default one, or the one from the theme - const customDataArrangerExists = ['data/index.js', 'data.js'].some( - (pathSuffix) => { - return fileExists(path.normalize(`${themeDir}/${pathSuffix}`)) + const customDataArrangerSuffixThatExists = [ + 'data/index.js', + 'data/index.mjs', + 'data.js', + 'data.mjs', + ].find((pathSuffix) => { + return fileExists(path.normalize(`${themeDir}/${pathSuffix}`)) + }) + + let arrangeDataModule = arrangeDataDefaultFn + if (customDataArrangerSuffixThatExists) { + try { + arrangeDataModule = await dynamicImport( + path.normalize(`${themeDir}/${customDataArrangerSuffixThatExists}`) + ) + } catch (err) { + console.error(err) + if ( + err instanceof SyntaxError && + err.message.includes('Cannot use import statement outside a module') + ) { + const messages = [ + '***', + 'It appears your theme code is written in ESM but not indicated as such.', + ] + if (!customDataArrangerSuffixThatExists.endsWith('.mjs')) { + messages.push( + 'You can try renaming your file with an "mjs" extension, or seting "type"="module" in your package.json' + ) + } else { + messages.push('Try setting "type"="module" in your package.json') + } + + messages.push('***') + messages.forEach((msg) => console.error(msg)) + } + throw err } - ) - const arrangeDataModule = customDataArrangerExists - ? require(path.normalize(`${themeDir}/data`)) - : arrangeDataDefaultFn - const arrangeData = arrangeDataModule.default - ? arrangeDataModule.default - : arrangeDataModule + } + + const arrangeData = takeDefaultExport(arrangeDataModule) const items = arrangeData({ introspectionResponse, @@ -81,7 +105,6 @@ function run(opts) { graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions: opts, }) diff --git a/src/spectaql/pre-process.js b/src/spectaql/pre-process.js index 1197f017..fbe50920 100644 --- a/src/spectaql/pre-process.js +++ b/src/spectaql/pre-process.js @@ -9,7 +9,6 @@ export default function preProcess({ graphQLSchema, extensions = {}, queryNameStrategy, - queryNameStategy, allOptions, }) { handleItems(items, { @@ -17,7 +16,6 @@ export default function preProcess({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } @@ -31,7 +29,6 @@ function handleItems( graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, } = {} ) { @@ -47,7 +44,6 @@ function handleItems( graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } @@ -62,7 +58,6 @@ function handleItem( graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, } ) { @@ -90,7 +85,6 @@ function handleItem( graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } @@ -106,7 +100,6 @@ function handleItem( graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } else if (item.isMutation) { @@ -117,7 +110,6 @@ function handleItem( graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } else if (item.isSubscription) { @@ -128,13 +120,12 @@ function handleItem( graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } else { // It's a definition anchorPrefix = 'definition' - addDefinitionToItem({ + addThingsToDefinitionItem({ item, introspectionResponse, graphQLSchema, @@ -152,7 +143,6 @@ function addQueryToItem({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) { return _addQueryToItem({ @@ -162,7 +152,6 @@ function addQueryToItem({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } @@ -173,7 +162,6 @@ function addMutationToItem({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) { return _addQueryToItem({ @@ -183,7 +171,6 @@ function addMutationToItem({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } @@ -194,7 +181,6 @@ function addSubscriptionToItem({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) { return _addQueryToItem({ @@ -204,7 +190,6 @@ function addSubscriptionToItem({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) } @@ -216,7 +201,6 @@ function _addQueryToItem({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) { const stuff = generateQueryExample({ @@ -226,7 +210,6 @@ function _addQueryToItem({ graphQLSchema, extensions, queryNameStrategy, - queryNameStategy, allOptions, }) const { query, variables, response } = stuff @@ -246,17 +229,20 @@ function _addQueryToItem({ } } -function addDefinitionToItem({ +function addThingsToDefinitionItem({ item, introspectionResponse, graphQLSchema, extensions, // allOptions, }) { - item.example = generateIntrospectionTypeExample({ - type: item, - introspectionResponse, - graphQLSchema, - extensions, - }) + // Only if not already present + if (typeof item.example === 'undefined') { + item.example = generateIntrospectionTypeExample({ + type: item, + introspectionResponse, + graphQLSchema, + extensions, + }) + } } diff --git a/src/spectaql/utils.js b/src/spectaql/utils.js index 6be1bd5c..a7e72375 100644 --- a/src/spectaql/utils.js +++ b/src/spectaql/utils.js @@ -1,6 +1,10 @@ import path from 'path' import fs from 'fs' import _ from 'lodash' +import tmp from 'tmp' + +// Ensures temporary files are cleaned up on program close, even if errors are encountered. +tmp.setGracefulCleanup() const cwd = process.cwd() @@ -9,6 +13,35 @@ const numDirsToRoot = 2 export const pathToRoot = path.resolve(__dirname, '../'.repeat(numDirsToRoot)) +export const TMP_PREFIX = 'spectaqltmp-' + +export function tmpFolder(options = {}) { + const { unsafeCleanup = true, prefix = TMP_PREFIX } = options + + return tmp.dirSync({ + unsafeCleanup, + prefix, + }).name +} + +export function takeDefaultExport(mojule) { + return mojule?.default ? mojule.default : mojule +} + +export async function dynamicImport(path) { + const mojule = await import(path) + // Some babelizing oddities result in a nested export structure sometimes, so let's + // normalize that + if ( + mojule.__esModule === true && + mojule.default?.default && + Object.keys(mojule).length === 2 + ) { + return mojule.default + } + return mojule +} + function normalizePathFn(pth, { start = cwd } = {}) { if (!path.isAbsolute(pth)) { pth = path.join(start, pth) @@ -167,3 +200,14 @@ export function upperCase(string) { export function lowerCase(string) { return string.toLowerCase() } + +export function isUndef(thing) { + return typeof thing === 'undefined' +} + +export function firstNonUndef(array) { + if (!Array.isArray(array)) { + return + } + return array.find((item) => !isUndef(item)) +} diff --git a/src/themes/default/data/index.js b/src/themes/default/data/index.js index 4660a04d..d77ffb6b 100644 --- a/src/themes/default/data/index.js +++ b/src/themes/default/data/index.js @@ -61,6 +61,20 @@ export default ({ ), } : null, + hasSubscriptions + ? { + name: 'Subscriptions', + makeNavSection: true, + makeContentSection: true, + items: sortBy( + subscriptionType.fields.map((type) => ({ + ...type, + isSubscription: true, + })), + 'name' + ), + } + : null, ], } : null, @@ -77,18 +91,5 @@ export default ({ ), } : null, - hasSubscriptions - ? { - name: 'Subscriptions', - makeContentSection: true, - items: sortBy( - subscriptionType.fields.map((type) => ({ - ...type, - isSubscription: true, - })), - 'name' - ), - } - : null, ].filter(Boolean) } diff --git a/src/themes/default/helpers/ariaLevel.js b/src/themes/default/helpers/ariaLevel.js new file mode 100644 index 00000000..03532ae5 --- /dev/null +++ b/src/themes/default/helpers/ariaLevel.js @@ -0,0 +1,9 @@ +const LARGEST_HEADING = 6 + +module.exports = function (headingNumber, _options) { + if (!headingNumber || headingNumber <= LARGEST_HEADING) { + return '' + } + + return 'aria-level="' + headingNumber + '"' +} diff --git a/src/themes/default/helpers/itemHeadingTag.js b/src/themes/default/helpers/itemHeadingTag.js new file mode 100644 index 00000000..440f4e04 --- /dev/null +++ b/src/themes/default/helpers/itemHeadingTag.js @@ -0,0 +1,5 @@ +const LARGEST_HEADING = 6 + +module.exports = function (headingNumber, _options) { + return 'h' + Math.min(headingNumber || LARGEST_HEADING, LARGEST_HEADING) +} diff --git a/src/themes/default/helpers/math.js b/src/themes/default/helpers/math.js new file mode 100644 index 00000000..80e84496 --- /dev/null +++ b/src/themes/default/helpers/math.js @@ -0,0 +1,11 @@ +module.exports = function (lvalue, operator, rvalue) { + lvalue = parseFloat(lvalue) + rvalue = parseFloat(rvalue) + return { + '+': lvalue + rvalue, + '-': lvalue - rvalue, + '*': lvalue * rvalue, + '/': lvalue / rvalue, + '%': lvalue % rvalue, + }[operator] +} diff --git a/src/themes/default/javascripts/scroll-spy.js b/src/themes/default/javascripts/scroll-spy.js index 9d0cd6bf..4b249241 100644 --- a/src/themes/default/javascripts/scroll-spy.js +++ b/src/themes/default/javascripts/scroll-spy.js @@ -71,11 +71,6 @@ function scrollSpy() { currentIndex = index var section = sections[index] - var getParentSection = function (el) { - if (!el || !el.closest) return null - return el.closest(EXPANDABLE_SELECTOR) - } - var activeEl = document.querySelector(`.${ACTIVE_CLASS}`) var nextEl = section ? document.querySelector('#nav a[href*=' + section.id + ']') @@ -86,10 +81,10 @@ function scrollSpy() { var isDifferentParent = parentActiveEl !== parentNextEl if (parentActiveEl && isDifferentParent) { - parentActiveEl.classList.remove(EXPAND_CLASS) + toggleSectionExpansion(parentActiveEl, false) } if (parentNextEl && isDifferentParent) { - parentNextEl.classList.add(EXPAND_CLASS) + toggleSectionExpansion(parentNextEl, true) } if (nextEl) { @@ -106,6 +101,19 @@ function scrollSpy() { } }, SCROLL_DEBOUNCE_MS) + function toggleSectionExpansion(element, shouldExpand) { + const classListFunc = shouldExpand ? 'add' : 'remove' + while (element) { + element.classList[classListFunc](EXPAND_CLASS) + element = getParentSection(element.parentNode) + } + } + + function getParentSection(el) { + if (!el || !el.closest) return null + return el.closest(EXPANDABLE_SELECTOR) + } + function getVisibleSectionIndex(scrollPosition) { var positionToCheck = scrollPosition + PADDING for (var i = 0; i < sections.length; i++) { diff --git a/src/themes/default/stylesheets/custom.scss b/src/themes/default/stylesheets/custom.scss index 9b9fe7ef..6b5ffbb3 100644 --- a/src/themes/default/stylesheets/custom.scss +++ b/src/themes/default/stylesheets/custom.scss @@ -47,7 +47,6 @@ // Style variables ////////////////// // -// $font-family: 'Barlow','Open Sans',serif; // $font-family-monospaced: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; // $text-color: #222222; // $text-color-subtle: #999999; diff --git a/src/themes/default/stylesheets/main.scss b/src/themes/default/stylesheets/main.scss index 6adb1adb..d8e1f655 100644 --- a/src/themes/default/stylesheets/main.scss +++ b/src/themes/default/stylesheets/main.scss @@ -20,7 +20,7 @@ $size-desktop-large: 64em; // Variables used in this theme ////////////////////// -$font-family: 'Barlow','Open Sans',serif; // See Barlow import below... +$font-family: -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; $font-family-monospaced: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; $text-color: #222222; $text-color-subtle: #999999; @@ -71,8 +71,6 @@ $background-arguments: #fafbfc; @import 'syntax-highlighting'; // Custom font -@import url('https://fonts.googleapis.com/css?family=Barlow:ital,wght@0,400;0,700;1,400;1,700&display=swap'); - // Custom styles ////////////////////// diff --git a/src/themes/default/views/partials/graphql/_description-with-defaults.hbs b/src/themes/default/views/partials/graphql/_description-with-defaults.hbs new file mode 100644 index 00000000..d8f31333 --- /dev/null +++ b/src/themes/default/views/partials/graphql/_description-with-defaults.hbs @@ -0,0 +1,10 @@ +{{ md ( + concat + (interpolateReferences description) + (ternary defaultValue + (concat "Default = " (codify defaultValue)) + "" + ) + joiner=". " filterFalsy=true + ) stripParagraph=true +}} \ No newline at end of file diff --git a/src/themes/default/views/partials/graphql/_query-or-mutation-arguments.hbs b/src/themes/default/views/partials/graphql/_query-or-mutation-arguments.hbs index 1324df11..219a9437 100644 --- a/src/themes/default/views/partials/graphql/_query-or-mutation-arguments.hbs +++ b/src/themes/default/views/partials/graphql/_query-or-mutation-arguments.hbs @@ -18,16 +18,7 @@ {{! The Description column }} - {{ md ( - concat - (interpolateReferences description) - (ternary defaultValue - (concat "Default = " (codify defaultValue)) - "" - ) - joiner=". " filterFalsy=true - ) stripParagraph=true - }} + {{>graphql/_description-with-defaults}} {{/each}} diff --git a/src/themes/default/views/partials/graphql/kinds/_fields.hbs b/src/themes/default/views/partials/graphql/kinds/_fields.hbs index 48cbab45..4494212d 100644 --- a/src/themes/default/views/partials/graphql/kinds/_fields.hbs +++ b/src/themes/default/views/partials/graphql/kinds/_fields.hbs @@ -19,7 +19,7 @@ {{! The Description column }} {{#if description}} - {{md (interpolateReferences description) stripParagraph=true}} + {{>graphql/_description-with-defaults}} {{/if}} {{#if (and deprecationReason (not @root.info.x-hideDeprecationReason))}} {{ md deprecationReason stripParagraph=true }} diff --git a/src/themes/default/views/partials/graphql/kinds/input-object.hbs b/src/themes/default/views/partials/graphql/kinds/input-object.hbs index 62d5e725..94544a61 100644 --- a/src/themes/default/views/partials/graphql/kinds/input-object.hbs +++ b/src/themes/default/views/partials/graphql/kinds/input-object.hbs @@ -25,7 +25,7 @@ {{! The Description column }} {{#if description}} - {{md (interpolateReferences description)}} + {{>graphql/_description-with-defaults}} {{/if}} diff --git a/src/themes/default/views/partials/layout/nav/item.hbs b/src/themes/default/views/partials/layout/nav/item.hbs index 93e64c02..c4928c5d 100644 --- a/src/themes/default/views/partials/layout/nav/item.hbs +++ b/src/themes/default/views/partials/layout/nav/item.hbs @@ -1,13 +1,13 @@ {{#unless hideInNav}} {{#if items}}