Skip to content

Commit 11a3349

Browse files
authored
feat: Migrate linting to remark-lint (#37)
1 parent 6e7eb5f commit 11a3349

File tree

13 files changed

+868
-290
lines changed

13 files changed

+868
-290
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ dist
1313
!.yarn/plugins
1414
!.yarn/releases
1515
!.yarn/sdks
16-
!.yarn/versions
16+
!.yarn/versions
17+
18+
eval

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,10 @@ Skip external link validation for offline or faster checks:
111111
toolkit-md check ./docs --skip-external-links
112112
```
113113

114-
Ignore specific markdownlint rules:
114+
Ignore specific remark-lint rules:
115115

116116
```bash
117-
toolkit-md check ./docs --ignore-rule MD013 --ignore-rule MD033
117+
toolkit-md check ./docs --ignore-rule maximum-line-length --ignore-rule no-html
118118
```
119119

120120
Only report errors (skip warnings):
@@ -169,7 +169,7 @@ Toolkit for Markdown supports configuration through:
169169
| `check.links.timeout` | `--link-timeout` | `TKMD_CHECK_LINK_TIMEOUT` | Timeout in milliseconds for HTTP link and image checks | `5000` |
170170
| `check.links.skipExternal` | `--skip-external-links` | `TKMD_CHECK_SKIP_EXTERNAL_LINKS` | Skip validation of external HTTP/HTTPS links and images | `false` |
171171
| `check.links.ignorePatterns` | `--ignore-link-pattern` | `TKMD_CHECK_LINK_IGNORE_PATTERN_*` | Regex patterns for URLs to ignore during link checking, can be specified multiple times | `[]` |
172-
| `check.lint.ignoreRules` | `--ignore-rule` | `TKMD_CHECK_LINT_IGNORE_RULE_*` | Markdownlint rule names or aliases to ignore, can be specified multiple times | `[]` |
172+
| `check.lint.ignoreRules` | `--ignore-rule` | `TKMD_CHECK_LINT_IGNORE_RULE_*` | remark-lint rule names to ignore (without the remark-lint- prefix), can be specified multiple times | `[]` |
173173
| `staticPrefix` | `--static-prefix` | `TKMD_STATIC_PREFIX` | URL prefix indicating a link points to a file in the static directory | `undefined` |
174174
| `staticDir` | `--static-dir` | `TKMD_STATIC_DIR` | Directory relative to the cwd where static assets are stored, used with staticPrefix | `undefined` |
175175

@@ -215,7 +215,7 @@ Create a `.toolkit-mdrc` file in JSON format:
215215
"ignorePatterns": ["^https://example\\.com/.*"]
216216
},
217217
"lint": {
218-
"ignoreRules": ["MD013"]
218+
"ignoreRules": ["maximum-line-length"]
219219
}
220220
},
221221
"staticPrefix": "/static/",
@@ -526,7 +526,7 @@ toolkit-md map ./docs --images
526526

527527
### `check`
528528

529-
Validates Markdown content without AI by running linting checks (via markdownlint), verifying that local link targets exist, and confirming that referenced images are present. Remote links and images are validated with HTTP HEAD requests. This command requires no AWS credentials and is suitable for CI pipelines. Exits with code 1 if any errors are found.
529+
Validates Markdown content without AI by running linting checks (via remark-lint), verifying that local link targets exist, and confirming that referenced images are present. Remote links and images are validated with HTTP HEAD requests. This command requires no AWS credentials and is suitable for CI pipelines. Exits with code 1 if any errors are found.
530530

531531
**Example:**
532532

@@ -540,10 +540,10 @@ toolkit-md check ./docs
540540
toolkit-md check ./docs --skip-external-links
541541
```
542542

543-
**Ignore specific markdownlint rules:**
543+
**Ignore specific remark-lint rules:**
544544

545545
```bash
546-
toolkit-md check ./docs --ignore-rule MD013 --ignore-rule MD033
546+
toolkit-md check ./docs --ignore-rule maximum-line-length --ignore-rule no-html
547547
```
548548

549549
**Options:**

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,15 @@
4040
"globby": "^14.0.2",
4141
"gray-matter": "^4.0.3",
4242
"handlebars": "^4.7.8",
43-
"markdownlint": "^0.37.4",
4443
"ora": "^8.0.1",
4544
"remark": "^15.0.1",
4645
"remark-directive": "^4.0.0",
46+
"remark-frontmatter": "^5.0.0",
4747
"remark-parse": "^11.0.0",
48+
"remark-preset-lint-consistent": "^6.0.1",
49+
"remark-preset-lint-recommended": "^7.0.1",
4850
"unified": "^11.0.5",
51+
"unist-util-visit": "^5.1.0",
4952
"yaml": "^2.8.0",
5053
"zod": "^3.25.75"
5154
},

src/ai/mcp/runChecksTool.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,14 @@ export function registerRunChecksTool(
110110
}
111111

112112
if (result.issues.length === 0) {
113-
results.push(`${result.filePath}: no issues`);
114-
} else {
115-
results.push(result.filePath);
116-
for (const issue of result.issues) {
117-
results.push(
118-
` ${issue.line}:${issue.column} ${issue.severity} ${issue.rule} ${issue.message} (${issue.category})`,
119-
);
120-
}
113+
continue;
114+
}
115+
116+
results.push(result.filePath);
117+
for (const issue of result.issues) {
118+
results.push(
119+
` ${issue.line}:${issue.column} ${issue.severity} ${issue.rule} ${issue.message} (${issue.category})`,
120+
);
121121
}
122122
}
123123

src/check/lintChecker.ts

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,54 +15,69 @@
1515
*/
1616

1717
/**
18-
* @fileoverview Markdown linting checker using markdownlint.
18+
* @fileoverview Markdown linting checker using remark-lint.
1919
*
20-
* Validates markdown content against markdownlint rules, with support
21-
* for ignoring specific rules by name or alias.
20+
* Validates markdown content against remark-lint rules from the
21+
* recommended and consistent presets, with support for ignoring
22+
* specific rules by name.
2223
*/
2324

24-
import { lint } from "markdownlint/promise";
25+
import { remark } from "remark";
26+
import remarkDirective from "remark-directive";
27+
import remarkFrontmatter from "remark-frontmatter";
28+
import remarkLintCodeBlockStyle from "remark-lint-code-block-style";
29+
import remarkLintHeadingStyle from "remark-lint-heading-style";
30+
import remarkLintNoUndefinedReferences from "remark-lint-no-undefined-references";
31+
import remarkPresetLintConsistent from "remark-preset-lint-consistent";
32+
import remarkPresetLintRecommended from "remark-preset-lint-recommended";
33+
import type { VFile } from "vfile";
34+
import remarkCodeDirective from "../content/utils/remarkCodeDirective.js";
2535
import type { CheckIssue } from "./types.js";
2636

2737
/**
28-
* Runs markdownlint on the given content and returns any issues found.
38+
* Runs remark-lint on the given content and returns any issues found.
2939
*
3040
* @param filePath - Path to the file being checked (used for reporting)
3141
* @param content - Raw markdown content to lint
32-
* @param ignoreRules - Rule names or aliases to disable
33-
* @returns Array of check issues found by markdownlint
42+
* @param ignoreRules - Rule names to suppress (without the `remark-lint-` prefix)
43+
* @returns Array of check issues found by remark-lint
3444
*/
3545
export async function checkLint(
3646
filePath: string,
3747
content: string,
3848
ignoreRules: string[],
3949
): Promise<CheckIssue[]> {
40-
const config: Record<string, boolean> = {
41-
default: true,
42-
MD033: false,
43-
MD013: false,
44-
};
45-
for (const rule of ignoreRules) {
46-
config[rule] = false;
47-
}
50+
const processor = remark()
51+
.use(remarkFrontmatter)
52+
.use(remarkDirective)
53+
.use(remarkCodeDirective)
54+
.use(remarkPresetLintConsistent)
55+
.use(remarkPresetLintRecommended)
56+
.use(remarkLintNoUndefinedReferences, false)
57+
.use(remarkLintCodeBlockStyle, false)
58+
.use(remarkLintHeadingStyle, "atx");
4859

49-
const results = await lint({
50-
strings: { [filePath]: content },
51-
config,
60+
const file: VFile = await processor.process({
61+
path: filePath,
62+
value: content,
5263
});
5364

54-
const fileResults = results[filePath];
55-
if (!fileResults) {
56-
return [];
57-
}
65+
const ignoreSet = new Set(
66+
ignoreRules.map((r) => r.replace(/^remark-lint-/, "")),
67+
);
5868

59-
return fileResults.map((result) => ({
60-
file: filePath,
61-
line: result.lineNumber,
62-
column: 1,
63-
severity: "warning" as const,
64-
category: "lint" as const,
65-
rule: result.ruleNames.join("/"),
66-
message: result.ruleDescription,
67-
}));
69+
return file.messages
70+
.filter((msg) => {
71+
const ruleId = msg.ruleId ?? "";
72+
return !ignoreSet.has(ruleId);
73+
})
74+
.map((msg) => ({
75+
file: filePath,
76+
line: msg.line ?? 1,
77+
column: msg.column ?? 1,
78+
severity: "warning" as const,
79+
category: "lint" as const,
80+
rule: msg.ruleId ?? "unknown",
81+
message: msg.reason,
82+
}));
6883
}

src/commands/check.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ function printResults(result: CheckResult, baseDir: string): void {
9797
: file.filePath;
9898

9999
if (file.issues.length === 0) {
100-
console.log(chalk.green(`${relativePath}: no issues`));
100+
continue;
101101
} else {
102102
console.log(chalk.underline(relativePath));
103103
for (const issue of file.issues) {

src/config/schema.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,9 @@ export const CONFIG_CHECK_LINK_IGNORE_PATTERNS = withConfig(
309309
export const CONFIG_CHECK_LINT_IGNORE_RULES = withConfig(
310310
z
311311
.array(z.string())
312-
.describe("Markdownlint rule names or aliases to ignore")
312+
.describe(
313+
"remark-lint rule names to ignore (without the remark-lint- prefix)",
314+
)
313315
.default([]),
314316
"ignoreRule",
315317
undefined,

src/content/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@
2323
export * from "./diffUtils.js";
2424
export * from "./languageUtils.js";
2525
export * from "./markdownUtils.js";
26+
export { default as remarkCodeDirective } from "./remarkCodeDirective.js";
2627
export * from "./staticPathUtils.js";
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Copyright 2025 Amazon.com, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* @fileoverview Remark plugin that converts :::code container directives
19+
* into standard fenced code blocks.
20+
*
21+
* When used with remark-directive, a block like:
22+
*
23+
* :::code{lang=python}
24+
* print("hello")
25+
* :::
26+
*
27+
* is parsed as a containerDirective node with name "code". This plugin
28+
* replaces those nodes with standard mdast `code` nodes so downstream
29+
* processors (serializers, linters) see regular fenced code blocks.
30+
*/
31+
32+
import type { Code, Root, RootContent } from "mdast";
33+
import type { ContainerDirective } from "mdast-util-directive";
34+
import { visit } from "unist-util-visit";
35+
36+
/**
37+
* Remark plugin that transforms :::code directives into fenced code blocks.
38+
*/
39+
export default function remarkCodeDirective() {
40+
return (tree: Root) => {
41+
visit(tree, "containerDirective", (node, index, parent) => {
42+
const directive = node as ContainerDirective;
43+
if (directive.name !== "code" || index == null || !parent) {
44+
return;
45+
}
46+
47+
const lang =
48+
directive.attributes?.lang ?? directive.attributes?.language ?? null;
49+
50+
const textParts: string[] = [];
51+
for (const child of directive.children) {
52+
if (child.type === "paragraph") {
53+
const paragraphText = child.children
54+
.map((c) => ("value" in c ? (c.value ?? "") : ""))
55+
.join("");
56+
textParts.push(paragraphText);
57+
} else if ("value" in child && child.value != null) {
58+
textParts.push(child.value);
59+
}
60+
}
61+
62+
const codeNode: Code = {
63+
type: "code",
64+
lang,
65+
meta: null,
66+
value: textParts.join("\n"),
67+
position: node.position,
68+
};
69+
70+
parent.children[index] = codeNode as RootContent;
71+
});
72+
};
73+
}

tests/check/checker.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ describe("checkAll", () => {
106106
const nodes = [
107107
createTestNode({
108108
filePath: join(tempDir, "docs", "bad.md"),
109-
content: "#Bad Heading\n",
109+
content: "1) Hello, _Jupiter_ and *Neptune*!\n",
110110
}),
111111
];
112112

@@ -175,7 +175,7 @@ describe("checkAll", () => {
175175
});
176176

177177
test("should respect ignoreRules option", async () => {
178-
const content = "#Bad Heading\n";
178+
const content = "1) Hello, _Jupiter_ and *Neptune*!\n";
179179
const nodes = [
180180
createTestNode({
181181
filePath: join(tempDir, "docs", "test.md"),
@@ -187,7 +187,7 @@ describe("checkAll", () => {
187187

188188
const resultWith = await checkAll(nodes, {
189189
...defaultOptions,
190-
lint: { ignoreRules: ["MD018", "MD041"] },
190+
lint: { ignoreRules: ["ordered-list-marker-style", "emphasis-marker"] },
191191
});
192192

193193
expect(resultWithout.totalWarnings).toBeGreaterThan(
@@ -199,7 +199,7 @@ describe("checkAll", () => {
199199
const nodes = [
200200
createTestNode({
201201
filePath: join(tempDir, "docs", "test.md"),
202-
content: "#Bad Heading\n\n[Missing](./nope.md)\n",
202+
content: "1) Hello, _Jupiter_ and *Neptune*!\n\n[Missing](./nope.md)\n",
203203
links: [{ url: "./nope.md", text: "Missing", line: 3, remote: false }],
204204
}),
205205
];
@@ -234,7 +234,7 @@ describe("checkAll", () => {
234234
const nodes = [
235235
createTestNode({
236236
filePath: join(tempDir, "docs", "test.md"),
237-
content: "#Bad Heading\n\n[Missing](./nope.md)\n",
237+
content: "1) Hello, _Jupiter_ and *Neptune*!\n\n[Missing](./nope.md)\n",
238238
links: [{ url: "./nope.md", text: "Missing", line: 3, remote: false }],
239239
}),
240240
];
@@ -255,7 +255,7 @@ describe("checkAll", () => {
255255
const nodes = [
256256
createTestNode({
257257
filePath: join(tempDir, "docs", "test.md"),
258-
content: "#Bad Heading\n\n[Missing](./nope.md)\n",
258+
content: "1) Hello, _Jupiter_ and *Neptune*!\n\n[Missing](./nope.md)\n",
259259
links: [{ url: "./nope.md", text: "Missing", line: 3, remote: false }],
260260
}),
261261
];
@@ -281,7 +281,7 @@ describe("checkAll", () => {
281281
const nodes = [
282282
createTestNode({
283283
filePath: join(tempDir, "docs", "test.md"),
284-
content: "#Bad Heading\n\n[Missing](./nope.md)\n",
284+
content: "1) Hello, _Jupiter_ and *Neptune*!\n\n[Missing](./nope.md)\n",
285285
links: [{ url: "./nope.md", text: "Missing", line: 3, remote: false }],
286286
}),
287287
];

0 commit comments

Comments
 (0)