Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit b1fb896

Browse files
sarahxsanderssaihaj
andauthoredApr 27, 2025··
docs: add linting and code snippet validation (#1983)
* Add linting and code snippet validation for docs * Remove unused link checkers * Add link checking to CONTRIBUTING.md and remove package-lock.json * update pnpm lockfile to sync with package.json * feedback from Benjie * build docs locally to test broken links * fix failing build * fix another snippets ignore --------- Co-authored-by: Saihajpreet Singh <saihajpreet.singh@gmail.com>
1 parent 7361ab3 commit b1fb896

File tree

8 files changed

+282
-27
lines changed

8 files changed

+282
-27
lines changed
 

‎.eslintrc.cjs

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
/* eslint-env node */
22

33
const CODE_EXT = "js,jsx,cjs,mjs,ts,tsx,cts,mts"
4-
54
const MARKDOWN_EXT = "md,mdx"
65

76
module.exports = {
87
root: true,
8+
plugins: ["@graphql-eslint", "mdx", "@typescript-eslint", "tailwindcss"],
99
overrides: [
1010
{
1111
files: [`**/*.{${CODE_EXT}}`],
12-
// TODO: extract graphql documents from code files
13-
// to lint graphql documents marked with /* GraphQL */ comments inside js/ts codeblocks in markdown
14-
// processor: '@graphql-eslint/graphql',
15-
// plugins: ['@graphql-eslint'],
1612
extends: [
1713
"eslint:recommended",
1814
"plugin:@typescript-eslint/recommended",
@@ -34,7 +30,6 @@ module.exports = {
3430
},
3531
],
3632
"prefer-const": ["error", { destructuring: "all" }],
37-
// TODO: fix below
3833
"prefer-rest-params": "off",
3934
"@typescript-eslint/no-explicit-any": "off",
4035
"@typescript-eslint/no-unused-vars": "off",
@@ -51,27 +46,45 @@ module.exports = {
5146
{
5247
files: [`**/*.{${MARKDOWN_EXT}}`],
5348
parser: "eslint-mdx",
49+
extends: ["plugin:mdx/recommended"],
5450
processor: "mdx/remark",
55-
plugins: ["mdx"],
5651
parserOptions: {
5752
ecmaVersion: 13,
5853
sourceType: "module",
5954
},
6055
settings: {
6156
"mdx/code-blocks": true,
57+
"mdx/language-mapper": {
58+
js: "espree",
59+
graphql: "@graphql-eslint/parser",
60+
ts: "@typescript-eslint/parser",
61+
typescript: "@typescript-eslint/parser",
62+
},
6263
},
6364
rules: {
6465
"mdx/remark": "error",
6566
},
6667
},
6768
{
68-
files: [`**/*.{${MARKDOWN_EXT}}/*.{${CODE_EXT}}`],
69+
files: ["**/*.graphql"],
70+
parser: "@graphql-eslint/parser",
6971
rules: {
70-
"no-unused-labels": "off",
71-
"no-undef": "off",
72-
"no-redeclare": "off",
73-
"no-import-assign": "off",
74-
"no-prototype-builtins": "off",
72+
"@graphql-eslint/no-syntax-errors": "error",
73+
"@graphql-eslint/unique-operation-name": "error",
74+
"@graphql-eslint/unique-fragment-name": "error",
75+
"@graphql-eslint/no-anonymous-operations": "warn",
76+
"@graphql-eslint/lone-anonymous-operation": "error",
77+
"@graphql-eslint/no-duplicate-fields": "error",
78+
"@graphql-eslint/no-unused-fragments": "warn",
79+
"@graphql-eslint/no-duplicate-fragment-names": "error",
80+
"@graphql-eslint/no-undefined-variables": "error",
81+
"@graphql-eslint/unique-variable-names": "error",
82+
},
83+
},
84+
{
85+
files: [`**/*.{${CODE_EXT}}`, `**/*.{${MARKDOWN_EXT}}`],
86+
parserOptions: {
87+
plugins: ["graphql"],
7588
},
7689
},
7790
{
@@ -84,9 +97,5 @@ module.exports = {
8497
"mdx/remark": "off",
8598
},
8699
},
87-
{
88-
files: ["**/*.graphql"],
89-
parser: "@graphql-eslint/eslint-plugin",
90-
},
91100
],
92101
}

‎.github/workflows/docs-validation.yml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Docs validation workflow runs on each PR on main w/ broken link checker
2+
# and code snippet validation
3+
4+
name: Docs validation
5+
6+
on:
7+
pull_request:
8+
branches:
9+
- main
10+
push:
11+
branches:
12+
- main
13+
14+
jobs:
15+
link-check:
16+
name: Broken link checker
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@v4
22+
23+
- name: Set up Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: "20"
27+
cache: "pnpm"
28+
29+
- name: Install dependencies
30+
run: pnpm install --frozen-lockfile
31+
32+
- name: Build static site
33+
run: pnpm build
34+
35+
- name: Set up Rust
36+
uses: actions-rs/toolchain@v1
37+
with:
38+
toolchain: stable
39+
40+
- name: Install lychee
41+
run: cargo install lychee
42+
43+
- name: Check links
44+
run: lychee --verbose --no-progress './out/**/*.html'
45+
46+
code-validate:
47+
name: Code snippet and GraphQL validation
48+
runs-on: ubuntu-latest
49+
steps:
50+
- uses: actions/checkout@v4
51+
52+
- uses: actions/setup-node@v4
53+
with:
54+
node-version: "20"
55+
cache: "pnpm"
56+
57+
- name: Install dependencies
58+
run: pnpm install --frozen-lockfile
59+
60+
- name: Run validation w/ annotations
61+
run: pnpm lint:docs:ci
62+
63+
- name: Validate code snippets
64+
run: pnpm validate:snippets

‎CONTRIBUTING.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ There are many ways to get involved. Follow this guide and feel free to [reach o
1010

1111
- [Development guide](#development-guide)
1212
- [Running the site locally](#running-the-site-locally)
13+
- [Checking for broken links](#checking-for-broken-links)
1314
- [Branching](#branching)
1415
- [Project structure](#project-structure)
1516
- [Publishing the updated site](#publishing-the-updated-site)
@@ -53,6 +54,21 @@ Finally, open http://localhost:3000 to view it in the browser.
5354

5455
The GraphQL website is built with [Nextra](https://nextra.site). This means that a hot-reloading development environment will be accessible by default.
5556

57+
### Checking for broken links
58+
59+
We use [Lychee](https://github.com/lycheeverse/lychee), a Rust-based CLI tool, to check for broken links in our documentation.
60+
61+
To install Lychee locally:
62+
63+
1. Install Rust: https://www.rust-lang.org/tools/install
64+
2. After installing Rust, run:
65+
66+
```bash
67+
cargo install lychee
68+
```
69+
70+
With Rust and Lychee installed, run the link checker: `pnpm run check:links`.
71+
5672
### Branching
5773

5874
Active development for graphql.org happens on the `source` branch. Be sure to create any new branches or direct any pull requests back to `source`.

‎package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@
66
"packageManager": "pnpm@9.15.2",
77
"scripts": {
88
"build": "next build && next-image-export-optimizer",
9+
"check:links": "lychee --verbose --no-progress './src/pages/**/*.mdx' --base https://graphql.org",
910
"dev": "next",
1011
"format": "pnpm format:check --write",
1112
"format:check": "prettier --cache --check .",
1213
"lint": "eslint --ignore-path .gitignore .",
14+
"lint:docs": "eslint --ignore-path .gitignore src/pages/learn --format stylish",
15+
"lint:docs:ci": "eslint --ignore-path .gitignore src/pages/learn --format eslint-formatter-github",
1316
"postbuild": "next-sitemap",
1417
"prebuild": "tsx src/get-github-info.ts",
1518
"start": "next start",
16-
"test": "echo \"no tests\" && exit 1"
19+
"test": "echo \"no tests\" && exit 1",
20+
"validate:snippets": "node scripts/validate-snippets.js"
1721
},
1822
"dependencies": {
1923
"@graphql-tools/schema": "10.0.15",
@@ -23,7 +27,7 @@
2327
"@tailwindcss/typography": "^0.5.10",
2428
"autoprefixer": "^10.4.17",
2529
"clsx": "^2.1.0",
26-
"codemirror": "5.65.1",
30+
"codemirror": "^5.65.19",
2731
"codemirror-graphql": "1.3.2",
2832
"date-fns": "^2.30.0",
2933
"fast-glob": "^3.3.2",

‎pnpm-lock.yaml

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎scripts/validate-snippets.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#!/usr/bin/env node
2+
3+
import fs from "node:fs"
4+
import path from "node:path"
5+
import glob from "glob"
6+
import { parse } from "graphql"
7+
import chalk from "chalk"
8+
import { fileURLToPath } from "node:url"
9+
10+
const __filename = fileURLToPath(import.meta.url)
11+
const __dirname = path.dirname(__filename)
12+
const projectRoot = path.resolve(__dirname, "../")
13+
14+
const MDX_GLOB = "./src/pages/learn/**/*.mdx"
15+
const CODE_BLOCK_REGEX = /^(`{3,})(\w+)\s*\n([\s\S]*?)\r?\n\1$/gm
16+
const IGNORE_COMMENT = "snippet-ignore"
17+
18+
let totalFiles = 0
19+
let totalSnippets = 0
20+
let totalErrors = 0
21+
22+
// TODO: Add JS linting after JS code snippet modernization
23+
// async function lintJavaScript(code, filePath) {
24+
// const eslint = new ESLint({
25+
// useEslintrc: true,
26+
// baseConfig: {
27+
// parserOptions: {
28+
// ecmaVersion: "latest",
29+
// sourceType: "module",
30+
// },
31+
// },
32+
// })
33+
34+
// let preparedCode = code.trim()
35+
36+
// if (preparedCode.startsWith("function")) {
37+
// preparedCode = "/* eslint-disable no-unused-vars */\n" + preparedCode
38+
// }
39+
40+
// const results = await eslint.lintText(preparedCode, { filePath })
41+
// return results.flatMap(result => result.messages)
42+
// }
43+
44+
function validateGraphQL(code) {
45+
try {
46+
parse(code)
47+
return []
48+
} catch (error) {
49+
return [{ message: error.message }]
50+
}
51+
}
52+
53+
function extractSnippets(content, filePath) {
54+
const snippets = []
55+
let match
56+
57+
while ((match = CODE_BLOCK_REGEX.exec(content)) !== null) {
58+
const [fullMatch, openingBackticks, lang, code] = match
59+
const beforeBlock = content.slice(0, match.index)
60+
const lineNumber = beforeBlock.split(/\r?\n/).length
61+
62+
if (beforeBlock.includes(IGNORE_COMMENT)) {
63+
continue
64+
}
65+
66+
snippets.push({ lang, code, lineNumber, filePath })
67+
}
68+
69+
return snippets
70+
}
71+
72+
async function validateSnippet(snippet) {
73+
const { lang, code, lineNumber, filePath } = snippet
74+
75+
if (!code.trim()) return []
76+
77+
// TODO: Add section after JS code snippet modernization
78+
// if (["js", "javascript", "ts", "typescript"].includes(lang)) {
79+
// const messages = await lintJavaScript(code, filePath)
80+
// return messages.map(msg => ({
81+
// type: "JS/TS",
82+
// file: filePath,
83+
// line: lineNumber + (msg.line || 1),
84+
// message: msg.message,
85+
// }))
86+
// }
87+
88+
if (lang === "graphql") {
89+
const messages = validateGraphQL(code)
90+
return messages.map(msg => ({
91+
type: "GraphQL",
92+
file: filePath,
93+
line: lineNumber + (msg.line || 1),
94+
message: msg.message,
95+
}))
96+
}
97+
98+
return []
99+
}
100+
101+
async function main() {
102+
console.log(`Validating code snippets in: ${projectRoot}/${MDX_GLOB}`)
103+
104+
const files = glob.sync(MDX_GLOB, { cwd: projectRoot })
105+
totalFiles = files.length
106+
107+
if (totalFiles === 0) {
108+
console.log(chalk.green("No MDX files found to validate."))
109+
return
110+
}
111+
112+
const errors = []
113+
114+
for (const file of files) {
115+
const content = fs.readFileSync(file, "utf8")
116+
const snippets = extractSnippets(content, file)
117+
totalSnippets += snippets.length
118+
119+
for (const snippet of snippets) {
120+
const snippetErrors = await validateSnippet(snippet)
121+
errors.push(...snippetErrors)
122+
}
123+
}
124+
125+
totalErrors = errors.length
126+
127+
if (totalErrors > 0) {
128+
errors.forEach(err => {
129+
const errorMessage = `${err.type} Error in ${err.file} at line ${err.line}: ${err.message}`
130+
console.error(chalk.red(errorMessage))
131+
132+
if (process.env.GITHUB_ACTIONS) {
133+
console.log(`::error file=${err.file},line=${err.line}::${err.message}`)
134+
}
135+
})
136+
137+
console.error(
138+
chalk.red("\nCode snippet validation failed. Check error logs."),
139+
)
140+
console.error(`Files checked: ${totalFiles}`)
141+
console.error(`Snippets checked: ${totalSnippets}`)
142+
console.error(`Errors found: ${totalErrors}`)
143+
process.exit(1)
144+
} else {
145+
console.log(
146+
chalk.green(
147+
"\nCode snippet validation passed. All code snippets are valid.",
148+
),
149+
)
150+
console.log(`Files checked: ${totalFiles}`)
151+
console.log(`Snippets checked: ${totalSnippets}`)
152+
}
153+
}
154+
155+
main().catch(err => {
156+
console.error(err)
157+
process.exit(1)
158+
})

‎src/pages/learn/response.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ In addition to the `data` key, the GraphQL specification outlines how [errors](h
4141

4242
Request errors typically occur because the client made a mistake. For example, there may be a _syntax error_ in the document, such as a missing bracket or the use of an unknown root operation type keyword:
4343

44+
{/* <!-- snippet-ignore --> */}
4445
```graphql
4546
# { "graphiql": true }
4647
operation {

‎src/pages/learn/schema.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ type Character {
210210

211211
As we see above, the Non-Null and List modifiers can be combined. For example, you can have a List of Non-Null `String` types:
212212

213+
{/* <!-- snippet-ignore --> */}
213214
```graphql
214215
myField: [String!]
215216
```
@@ -225,6 +226,7 @@ myField: ["a", null, "b"] // error
225226

226227
Now, let's say we defined a Non-Null List of `String` types:
227228

229+
{/* <!-- snippet-ignore --> */}
228230
```graphql
229231
myField: [String]!
230232
```
@@ -240,6 +242,7 @@ myField: ["a", null, "b"] // valid
240242

241243
Lastly, you can also have a Non-Null List of Non-Null `String` types:
242244

245+
{/* <!-- snippet-ignore --> */}
243246
```graphql
244247
myField: [String!]!
245248
```

0 commit comments

Comments
 (0)
Please sign in to comment.