Skip to content

Commit efcf914

Browse files
committed
feat(remark-lint): add strict rules
1 parent 11fa0e5 commit efcf914

File tree

8 files changed

+1013
-56
lines changed

8 files changed

+1013
-56
lines changed

packages/remark-lint/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ Prevents redundant stability markers in nested sections.
6565
> Stability: 2 - Stable <!-- Redundant! -->
6666
```
6767

68+
### `node-core:invalid-type-reference`
69+
70+
Ensures that all `{type}` references are valid types and formatted correctly.
71+
72+
**Allowed:**
73+
74+
```markdown
75+
This is usually a {boolean}, but it could also be a {string|number}.
76+
```
77+
78+
**Not allowed:**
79+
80+
```markdown
81+
This is an {invalid} type, and so is {string | number} because there should **not** be whitespace around the `|`.
82+
```
83+
6884
### `node-core:hashed-self-reference`
6985

7086
Ensures self-references use fragment-only links.

packages/remark-lint/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@node-core/remark-lint",
33
"type": "module",
4-
"version": "1.0.0",
4+
"version": "1.1.0",
55
"exports": {
66
".": "./src/index.mjs",
77
"./api": "./src/api.mjs"
@@ -20,6 +20,7 @@
2020
"test:unit": "cross-env NODE_NO_WARNINGS=1 node --experimental-test-coverage --test \"**/*.test.mjs\""
2121
},
2222
"dependencies": {
23+
"@nodejs/doc-kit": "github:nodejs/doc-kit",
2324
"remark-gfm": "^4.0.1",
2425
"remark-lint-blockquote-indentation": "^4.0.1",
2526
"remark-lint-checkbox-character-style": "^5.0.1",

packages/remark-lint/src/api.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import remarkLintUnorderedListMarkerStyle from 'remark-lint-unordered-list-marke
77
import basePreset from './index.mjs';
88
import duplicateStabilityNodes from './rules/duplicate-stability-nodes.mjs';
99
import hashedSelfReference from './rules/hashed-self-reference.mjs';
10+
import invalidTypeReference from './rules/invalid-type-reference.mjs';
1011
import orderedReferences from './rules/ordered-references.mjs';
1112
import requiredMetadata from './rules/required-metadata.mjs';
1213
import yamlComments from './rules/yaml/index.mjs';
@@ -34,6 +35,7 @@ export default (options = {}) => ({
3435
hashedSelfReference,
3536
orderedReferences,
3637
requiredMetadata,
38+
invalidTypeReference,
3739
].map(plugin => [plugin, options]),
3840

3941
// External Rules
@@ -61,6 +63,7 @@ export default (options = {}) => ({
6163
{ yes: 'Unix' },
6264
{ yes: 'Valgrind' },
6365
{ yes: 'V8' },
66+
{ yes: 'npm' },
6467
],
6568
],
6669
],
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it } from 'node:test';
2+
3+
import { testRule } from './utils.mjs';
4+
import invalidTypeReference from '../invalid-type-reference.mjs';
5+
6+
const testCases = [
7+
{
8+
name: 'no references',
9+
input: 'Just some text.',
10+
expected: [],
11+
},
12+
{
13+
name: 'single reference',
14+
input: 'Just a {number}.',
15+
expected: [],
16+
},
17+
{
18+
name: 'miswrapped reference',
19+
input: 'First a {string}, then a \\<number>.',
20+
expected: ['Type reference must be wrapped in "{}"; saw "<number>"'],
21+
},
22+
{
23+
name: 'multiple references',
24+
input: 'Psst, are you a {string | boolean}',
25+
expected: [
26+
'Type reference should be separated by "|", without spaces; saw "{string | boolean}"',
27+
],
28+
},
29+
{
30+
name: 'invalid references',
31+
input: 'This is {invalid}.',
32+
expected: ['Invalid type reference: {invalid}'],
33+
},
34+
];
35+
36+
describe('invalid-type-reference', () => {
37+
for (const { name, input, expected } of testCases) {
38+
it(name, () => testRule(invalidTypeReference, input, expected));
39+
}
40+
});

packages/remark-lint/src/rules/duplicate-stability-nodes.mjs

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,49 @@
1+
import createQueries from '@nodejs/doc-kit/src/utils/queries/index.mjs';
12
import { lintRule } from 'unified-lint-rule';
23
import { visit } from 'unist-util-visit';
34

4-
// TODO(@avivkeller): This is re-used from doc-kit
5-
// Regex to match "Stability: <number>" in blockquotes
6-
const STABILITY = /Stability: ([0-5](?:\.[0-3])?)/;
7-
85
/**
96
* Finds and reports duplicate stability nodes
107
* @type {import('unified-lint-rule').Rule}
118
*/
129
const duplicateStabilityNodes = (tree, vfile) => {
13-
let currentDepth = 0;
14-
let currentStability = -1;
15-
let currentHeaderDepth = 0;
10+
// Map depth → stability string recorded at that depth
11+
const stabilityByDepth = new Map();
12+
let currentHeadingDepth = 0; // Current heading depth (0 for "no heading")
1613

17-
visit(tree, node => {
18-
// Update the current heading depth whenever a heading node is encountered
14+
visit(tree, ['heading', 'blockquote'], node => {
1915
if (node.type === 'heading') {
20-
currentHeaderDepth = node.depth;
16+
// Update heading depth and clear deeper recorded stabilities
17+
currentHeadingDepth = node.depth;
18+
for (const depth of stabilityByDepth.keys()) {
19+
if (depth >= currentHeadingDepth) {
20+
stabilityByDepth.delete(depth);
21+
}
22+
}
23+
return;
24+
}
25+
26+
// Handle blockquotes: extract text from paragraph > text structure
27+
const text = node.children?.[0]?.children?.[0]?.value;
28+
if (!text) {
29+
return;
2130
}
2231

23-
// Look for blockquotes which may contain stability indicators
24-
if (node.type === 'blockquote') {
25-
// Assume the first child is a paragraph
26-
const paragraph = node.children?.[0];
27-
// And the first child of that paragraph is text
28-
const text = paragraph?.children?.[0];
32+
const match = createQueries.QUERIES.stabilityIndexPrefix.exec(text); // Match "Stability: X"
33+
if (!match) {
34+
return;
35+
}
2936

30-
// Ensure structure is paragraph > text
31-
if (paragraph?.type === 'paragraph' && text?.type === 'text') {
32-
// Try to match "Stability: X"
33-
const match = text.value.match(STABILITY);
34-
if (match) {
35-
const stability = parseFloat(match[1]);
36-
// If the heading got deeper, and stability is valid and matches previous, report a duplicate
37-
if (
38-
currentHeaderDepth > currentDepth &&
39-
stability >= 0 &&
40-
stability === currentStability
41-
) {
42-
vfile.message('Duplicate stability node', node);
43-
} else {
44-
// Otherwise, record this stability and heading depth
45-
currentDepth = currentHeaderDepth;
46-
currentStability = stability;
47-
}
48-
}
37+
const stability = match[1];
38+
// Report if a duplicate stability exists in a parent heading depth
39+
for (const [depth, prevStability] of stabilityByDepth) {
40+
if (depth < currentHeadingDepth && prevStability === stability) {
41+
vfile.message('Duplicate stability node', node);
42+
break;
4943
}
5044
}
45+
46+
stabilityByDepth.set(currentHeadingDepth, stability);
5147
});
5248
};
5349

packages/remark-lint/src/rules/hashed-self-reference.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const hashedSelfReference = (tree, vfile) => {
3535

3636
if (targetURL.pathname === currentFileURL.pathname) {
3737
const expected = url.includes('#') ? url.slice(url.indexOf('#')) : '#';
38+
node.url = expected;
3839

3940
vfile.message(
4041
`Self-reference must start with hash (expected "${expected}", got "${url}")`,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { transformTypeToReferenceLink } from '@nodejs/doc-kit/src/utils/parser/index.mjs';
2+
import createQueries from '@nodejs/doc-kit/src/utils/queries/index.mjs';
3+
import { lintRule } from 'unified-lint-rule';
4+
import { visit } from 'unist-util-visit';
5+
6+
const MATCH_RE = /\s\||\|\s/g;
7+
const REPLACE_RE = /\s*\|\s*/g;
8+
9+
/**
10+
* Ensures that all type references are valid
11+
* @type {import('unified-lint-rule').Rule}
12+
*/
13+
const invalidTypeReference = (tree, vfile) => {
14+
visit(tree, createQueries.UNIST.isTextWithType, node => {
15+
const types = node.value.match(createQueries.QUERIES.normalizeTypes);
16+
17+
types.forEach(type => {
18+
// Ensure wrapped in {}
19+
if (type[0] !== '{' || type[type.length - 1] !== '}') {
20+
vfile.message(
21+
`Type reference must be wrapped in "{}"; saw "${type}"`,
22+
node
23+
);
24+
25+
node.value = node.value.replace(type, `{${type.slice(1, -1)}}`);
26+
}
27+
28+
// Fix spaces around |
29+
if (MATCH_RE.test(type)) {
30+
vfile.message(
31+
`Type reference should be separated by "|", without spaces; saw "${type}"`,
32+
node
33+
);
34+
35+
const normalized = type.replace(REPLACE_RE, '|');
36+
node.value = node.value.replace(type, normalized);
37+
}
38+
39+
if (transformTypeToReferenceLink(type) === type) {
40+
vfile.message(`Invalid type reference: ${type}`, node);
41+
}
42+
});
43+
});
44+
};
45+
46+
export default lintRule(
47+
'node-core:invalid-type-reference',
48+
invalidTypeReference
49+
);

0 commit comments

Comments
 (0)