Skip to content

Commit 21024fe

Browse files
authored
chore: check rule examples for syntax errors (#17718)
* check rule examples for syntax errors * fix a test on Windows * more than three backticks allowed * add comments, minimal tweaks * fix Makefile task * fix for multiple trailing spaces after 'correct' * rename npm script
1 parent 4a88a54 commit 21024fe

9 files changed

Lines changed: 436 additions & 43 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ jobs:
3333
run: npm run lint:scss
3434
- name: Lint Docs JS Files
3535
run: node Makefile lintDocsJS
36+
- name: Check Rule Examples
37+
run: node Makefile checkRuleExamples
3638
- name: Build Docs Website
3739
working-directory: docs
3840
run: npm run build

Makefile.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,17 @@ target.checkRuleFiles = function() {
867867

868868
};
869869

870+
target.checkRuleExamples = function() {
871+
const { execFileSync } = require("child_process");
872+
873+
// We don't need the stack trace of execFileSync if the command fails.
874+
try {
875+
execFileSync(process.execPath, ["tools/check-rule-examples.js", "docs/src/rules/*.md"], { stdio: "inherit" });
876+
} catch {
877+
exit(1);
878+
}
879+
};
880+
870881
target.checkLicenses = function() {
871882

872883
/**

docs/.eleventy.js

Lines changed: 27 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const { highlighter, lineNumberPlugin } = require("./src/_plugins/md-syntax-high
1414
const {
1515
DateTime
1616
} = require("luxon");
17+
const markdownIt = require("markdown-it");
18+
const markdownItRuleExample = require("./tools/markdown-it-rule-example");
1719

1820
module.exports = function(eleventyConfig) {
1921

@@ -113,7 +115,7 @@ module.exports = function(eleventyConfig) {
113115
* Source: https://github.com/11ty/eleventy/issues/658
114116
*/
115117
eleventyConfig.addFilter("markdown", value => {
116-
const markdown = require("markdown-it")({
118+
const markdown = markdownIt({
117119
html: true
118120
});
119121

@@ -191,57 +193,39 @@ module.exports = function(eleventyConfig) {
191193
return btoa(unescape(encodeURIComponent(text)));
192194
}
193195

194-
/**
195-
* Creates markdownItContainer settings for a playground-linked codeblock.
196-
* @param {string} name Plugin name and class name to add to the code block.
197-
* @returns {[string, object]} Plugin name and options for markdown-it.
198-
*/
199-
function withPlaygroundRender(name) {
200-
return [
201-
name,
202-
{
203-
render(tokens, index) {
204-
if (tokens[index].nesting !== 1) {
205-
return "</div>";
206-
}
207-
208-
// See https://github.com/eslint/eslint.org/blob/ac38ab41f99b89a8798d374f74e2cce01171be8b/src/playground/App.js#L44
209-
const parserOptionsJSON = tokens[index].info?.split("correct ")[1]?.trim();
210-
const parserOptions = { sourceType: "module", ...(parserOptionsJSON && JSON.parse(parserOptionsJSON)) };
211-
212-
// Remove trailing newline and presentational `⏎` characters (https://github.com/eslint/eslint/issues/17627):
213-
const content = tokens[index + 1].content
214-
.replace(/\n$/u, "")
215-
.replace(/(?=\n)/gu, "");
216-
const state = encodeToBase64(
217-
JSON.stringify({
218-
options: { parserOptions },
219-
text: content
220-
})
221-
);
222-
const prefix = process.env.CONTEXT && process.env.CONTEXT !== "deploy-preview"
223-
? ""
224-
: "https://eslint.org";
225-
226-
return `
227-
<div class="${name}">
196+
// markdown-it plugin options for playground-linked code blocks in rule examples.
197+
const ruleExampleOptions = markdownItRuleExample({
198+
open(type, code, parserOptions) {
199+
200+
// See https://github.com/eslint/eslint.org/blob/ac38ab41f99b89a8798d374f74e2cce01171be8b/src/playground/App.js#L44
201+
const state = encodeToBase64(
202+
JSON.stringify({
203+
options: { parserOptions },
204+
text: code
205+
})
206+
);
207+
const prefix = process.env.CONTEXT && process.env.CONTEXT !== "deploy-preview"
208+
? ""
209+
: "https://eslint.org";
210+
211+
return `
212+
<div class="${type}">
228213
<a class="c-btn c-btn--secondary c-btn--playground" href="${prefix}/play#${state}" target="_blank">
229214
Open in Playground
230215
</a>
231-
`.trim();
232-
}
233-
}
234-
];
235-
}
216+
`.trim();
217+
},
218+
close() {
219+
return "</div>";
220+
}
221+
});
236222

237-
const markdownIt = require("markdown-it");
238223
const md = markdownIt({ html: true, linkify: true, typographer: true, highlight: (str, lang) => highlighter(md, str, lang) })
239224
.use(markdownItAnchor, {
240225
slugify: s => slug(s)
241226
})
242227
.use(markdownItContainer, "img-container", {})
243-
.use(markdownItContainer, ...withPlaygroundRender("correct"))
244-
.use(markdownItContainer, ...withPlaygroundRender("incorrect"))
228+
.use(markdownItContainer, "rule-example", ruleExampleOptions)
245229
.use(markdownItContainer, "warning", {
246230
render(tokens, idx) {
247231
return generateAlertMarkup("warning", tokens, idx);
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"use strict";
2+
3+
/** @typedef {import("../../lib/shared/types").ParserOptions} ParserOptions */
4+
5+
/**
6+
* A callback function to handle the opening of container blocks.
7+
* @callback OpenHandler
8+
* @param {"correct" | "incorrect"} type The type of the example.
9+
* @param {string} code The example code.
10+
* @param {ParserOptions} parserOptions The parser options to be passed to the Playground.
11+
* @param {Object} codeBlockToken The `markdown-it` token for the code block inside the container.
12+
* @returns {string | undefined} If a text is returned, it will be appended to the rendered output
13+
* of `markdown-it`.
14+
*/
15+
16+
/**
17+
* A callback function to handle the closing of container blocks.
18+
* @callback CloseHandler
19+
* @returns {string | undefined} If a text is returned, it will be appended to the rendered output
20+
* of `markdown-it`.
21+
*/
22+
23+
/**
24+
* This is a utility to simplify the creation of `markdown-it-container` options to handle rule
25+
* examples in the documentation.
26+
* It is designed to automate the following common tasks:
27+
*
28+
* - Ensure that the plugin instance only matches container blocks tagged with 'correct' or
29+
* 'incorrect'.
30+
* - Parse the optional `parserOptions` after the correct/incorrect tag.
31+
* - Apply common transformations to the code inside the code block, like stripping '⏎' at the end
32+
* of a line or the last newline character.
33+
*
34+
* Additionally, the opening and closing of the container blocks are handled by two distinct
35+
* callbacks, of which only the `open` callback is required.
36+
* @param {Object} options The options object.
37+
* @param {OpenHandler} options.open The open callback.
38+
* @param {CloseHandler} [options.close] The close callback.
39+
* @returns {Object} The `markdown-it-container` options.
40+
* @example
41+
* const markdownIt = require("markdown-it");
42+
* const markdownItContainer = require("markdown-it-container");
43+
*
44+
* markdownIt()
45+
* .use(markdownItContainer, "rule-example", markdownItRuleExample({
46+
* open(type, code, parserOptions, codeBlockToken) {
47+
* // do something
48+
* }
49+
* close() {
50+
* // do something
51+
* }
52+
* }))
53+
* .render(text);
54+
*
55+
*/
56+
function markdownItRuleExample({ open, close }) {
57+
return {
58+
validate(info) {
59+
return /^\s*(?:in)?correct(?!\S)/u.test(info);
60+
},
61+
render(tokens, index) {
62+
const tagToken = tokens[index];
63+
64+
if (tagToken.nesting < 0) {
65+
const text = close ? close() : void 0;
66+
67+
// Return an empty string to avoid appending unexpected text to the output.
68+
return typeof text === "string" ? text : "";
69+
}
70+
71+
const { type, parserOptionsJSON } = /^\s*(?<type>\S+)(\s+(?<parserOptionsJSON>\S.*?))?\s*$/u.exec(tagToken.info).groups;
72+
const parserOptions = { sourceType: "module", ...(parserOptionsJSON && JSON.parse(parserOptionsJSON)) };
73+
const codeBlockToken = tokens[index + 1];
74+
75+
// Remove trailing newline and presentational `⏎` characters (https://github.com/eslint/eslint/issues/17627):
76+
const code = codeBlockToken.content
77+
.replace(/\n$/u, "")
78+
.replace(/(?=\n)/gu, "");
79+
80+
const text = open(type, code, parserOptions, codeBlockToken);
81+
82+
// Return an empty string to avoid appending unexpected text to the output.
83+
return typeof text === "string" ? text : "";
84+
}
85+
};
86+
}
87+
88+
module.exports = markdownItRuleExample;

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"build:readme": "node tools/update-readme.js",
2020
"lint": "node Makefile.js lint",
2121
"lint:docs:js": "node Makefile.js lintDocsJS",
22+
"lint:docs:rule-examples": "node Makefile.js checkRuleExamples",
2223
"lint:fix": "node Makefile.js lint -- fix",
2324
"lint:fix:docs:js": "node Makefile.js lintDocsJS -- fix",
2425
"release:generate:alpha": "node Makefile.js generatePrerelease -- alpha",
@@ -42,6 +43,7 @@
4243
"git add packages/js/src/configs/eslint-all.js"
4344
],
4445
"docs/src/rules/*.md": [
46+
"node tools/check-rule-examples.js",
4547
"node tools/fetch-docs-links.js",
4648
"git add docs/src/_data/further_reading_links.json"
4749
],
@@ -132,6 +134,8 @@
132134
"gray-matter": "^4.0.3",
133135
"lint-staged": "^11.0.0",
134136
"load-perf": "^0.2.0",
137+
"markdown-it": "^12.2.0",
138+
"markdown-it-container": "^3.0.0",
135139
"markdownlint": "^0.31.1",
136140
"markdownlint-cli": "^0.37.0",
137141
"marked": "^4.0.8",

tests/fixtures/bad-examples.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
title: Lorem Ipsum
3+
---
4+
5+
This file contains rule example code with syntax errors.
6+
7+
<!-- markdownlint-capture -->
8+
<!-- markdownlint-disable MD040 -->
9+
::: incorrect { "sourceType": "script" }
10+
11+
```
12+
export default "foo";
13+
```
14+
15+
:::
16+
<!-- markdownlint-restore -->
17+
18+
:::correct
19+
20+
````ts
21+
const foo = "bar";
22+
23+
const foo = "baz";
24+
````
25+
26+
:::

tests/fixtures/good-examples.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
This file contains rule example code without syntax errors.
2+
3+
::: incorrect
4+
5+
```js
6+
export default
7+
"foo";
8+
```
9+
10+
:::
11+
12+
::: correct { "ecmaFeatures": { "jsx": true } }
13+
14+
```jsx
15+
const foo = <bar></bar>;
16+
```
17+
18+
:::
19+
20+
A test with multiple spaces after 'correct':
21+
<!-- markdownlint-disable-next-line no-trailing-spaces -->
22+
:::correct
23+
24+
```js
25+
```
26+
27+
:::
28+
29+
The following code block is not a rule example, so it won't be checked:
30+
31+
```js
32+
!@#$%^&*()
33+
```

tests/tools/check-rule-examples.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use strict";
2+
3+
//------------------------------------------------------------------------------
4+
// Requirements
5+
//------------------------------------------------------------------------------
6+
7+
const assert = require("assert");
8+
const { execFile } = require("child_process");
9+
const { promisify } = require("util");
10+
11+
//------------------------------------------------------------------------------
12+
// Helpers
13+
//------------------------------------------------------------------------------
14+
15+
/**
16+
* Runs check-rule-examples on the specified files.
17+
* @param {...string} filenames Files to be passed to check-rule-examples.
18+
* @returns {Promise<ChildProcess>} An object with properties `stdout` and `stderr` on success.
19+
* @throws An object with properties `code`, `stdout` and `stderr` on success.
20+
*/
21+
async function runCheckRuleExamples(...filenames) {
22+
return await promisify(execFile)(
23+
process.execPath,
24+
["--no-deprecation", "tools/check-rule-examples.js", ...filenames],
25+
{ env: { FORCE_COLOR: "3" } } // 24-bit color mode
26+
);
27+
}
28+
29+
//------------------------------------------------------------------------------
30+
// Tests
31+
//------------------------------------------------------------------------------
32+
33+
describe("check-rule-examples", () => {
34+
35+
it("succeeds when not passed any files", async () => {
36+
const childProcess = await runCheckRuleExamples();
37+
38+
assert.strictEqual(childProcess.stdout, "");
39+
assert.strictEqual(childProcess.stderr, "");
40+
});
41+
42+
it("succeeds when passed a syntax error free file", async () => {
43+
const childProcess = await runCheckRuleExamples("tests/fixtures/good-examples.md");
44+
45+
assert.strictEqual(childProcess.stdout, "");
46+
assert.strictEqual(childProcess.stderr, "");
47+
});
48+
49+
it("fails when passed a file with a syntax error", async () => {
50+
const promise = runCheckRuleExamples("tests/fixtures/good-examples.md", "tests/fixtures/bad-examples.md");
51+
52+
await assert.rejects(
53+
promise,
54+
{
55+
code: 1,
56+
stdout: "",
57+
stderr:
58+
"\x1B[0m\x1B[0m\n" +
59+
"\x1B[0m\x1B[4mtests/fixtures/bad-examples.md\x1B[24m\x1B[0m\n" +
60+
"\x1B[0m \x1B[2m11:4\x1B[22m \x1B[31merror\x1B[39m Missing language tag: use one of 'javascript', 'js' or 'jsx'\x1B[0m\n" +
61+
"\x1B[0m \x1B[2m12:1\x1B[22m \x1B[31merror\x1B[39m Syntax error: 'import' and 'export' may appear only with 'sourceType: module'\x1B[0m\n" +
62+
"\x1B[0m \x1B[2m20:5\x1B[22m \x1B[31merror\x1B[39m Nonstandard language tag 'ts': use one of 'javascript', 'js' or 'jsx'\x1B[0m\n" +
63+
"\x1B[0m \x1B[2m23:7\x1B[22m \x1B[31merror\x1B[39m Syntax error: Identifier 'foo' has already been declared\x1B[0m\n" +
64+
"\x1B[0m\x1B[0m\n" +
65+
"\x1B[0m\x1B[31m\x1B[1m✖ 4 problems (4 errors, 0 warnings)\x1B[22m\x1B[39m\x1B[0m\n" +
66+
"\x1B[0m\x1B[31m\x1B[1m\x1B[22m\x1B[39m\x1B[0m\n"
67+
}
68+
);
69+
});
70+
71+
it("fails when a file cannot be processed", async () => {
72+
const promise = runCheckRuleExamples("tests/fixtures/non-existing-examples.md");
73+
74+
await assert.rejects(
75+
promise,
76+
({ code, stdout, stderr }) => {
77+
assert.strictEqual(code, 1);
78+
assert.strictEqual(stdout, "");
79+
const expectedStderr =
80+
"\x1B[0m\x1B[0m\n" +
81+
"\x1B[0m\x1B[4mtests/fixtures/non-existing-examples.md\x1B[24m\x1B[0m\n" +
82+
"\x1B[0m \x1B[2m0:0\x1B[22m \x1B[31merror\x1B[39m Error checking file: ENOENT: no such file or directory, open <FILE>\x1B[0m\n" +
83+
"\x1B[0m\x1B[0m\n" +
84+
"\x1B[0m\x1B[31m\x1B[1m✖ 1 problem (1 error, 0 warnings)\x1B[22m\x1B[39m\x1B[0m\n" +
85+
"\x1B[0m\x1B[31m\x1B[1m\x1B[22m\x1B[39m\x1B[0m\n";
86+
87+
// Replace filename as it's OS-dependent.
88+
const normalizedStderr = stderr.replace(/'.+'/u, "<FILE>");
89+
90+
assert.strictEqual(normalizedStderr, expectedStderr);
91+
return true;
92+
}
93+
);
94+
});
95+
});

0 commit comments

Comments
 (0)