name: markdownlint-custom-rules user-invocable: false description: 创建自定义的markdownlint规则,包括规则结构、解析器集成、错误报告和自动修复。 allowed-tools: [Bash, Read]
Markdownlint 自定义规则
掌握创建自定义markdownlint规则,包括规则结构、markdown-it和micromark解析器集成、带fixInfo的错误报告以及异步规则开发。
概述
Markdownlint允许您创建自定义规则,以适应项目的特定文档要求。自定义规则可以强制执行项目特定的约定、验证内容模式并确保超出内置规则提供的一致性。
规则对象结构
基本规则定义
每个自定义规则必须是一个具有特定属性的JavaScript对象:
module.exports = {
names: ["rule-name", "RULE001"],
description: "此规则检查的描述",
tags: ["custom", "style"],
parser: "markdownit",
function: function(params, onError) {
// 规则实现
}
};
必需属性
{
names: Array<String>, // 规则标识符(必需)
description: String, // 规则检查内容(必需)
tags: Array<String>, // 分类标签(必需)
parser: String, // "markdownit"、"micromark"或"none"(必需)
function: Function // 规则逻辑(必需)
}
可选属性
{
information: URL, // 规则文档链接
asynchronous: Boolean // 如果为true,函数返回Promise
}
解析器选择
markdown-it 解析器
最适合基于令牌的解析,具有丰富的元数据:
module.exports = {
names: ["any-blockquote-markdown-it"],
description: "报告任何块引用的错误的规则",
information: new URL("https://example.com/rules/any-blockquote"),
tags: ["test"],
parser: "markdownit",
function: (params, onError) => {
const blockquotes = params.parsers.markdownit.tokens
.filter((token) => token.type === "blockquote_open");
for (const blockquote of blockquotes) {
const [startIndex, endIndex] = blockquote.map;
const lines = endIndex - startIndex;
onError({
lineNumber: blockquote.lineNumber,
detail: `块引用跨越${lines}行。`,
context: blockquote.line
});
}
}
};
micromark 解析器
最适合详细的令牌分析和精确定位:
module.exports = {
names: ["any-blockquote-micromark"],
description: "报告任何块引用的错误的规则",
information: new URL("https://example.com/rules/any-blockquote"),
tags: ["test"],
parser: "micromark",
function: (params, onError) => {
const blockquotes = params.parsers.micromark.tokens
.filter((token) => token.type === "blockQuote");
for (const blockquote of blockquotes) {
const lines = blockquote.endLine - blockquote.startLine + 1;
onError({
lineNumber: blockquote.startLine,
detail: `块引用跨越${lines}行。`,
context: params.lines[blockquote.startLine - 1]
});
}
}
};
无解析器
适用于简单的基于行的规则:
module.exports = {
names: ["no-todo-comments"],
description: "禁止在markdown中使用TODO注释",
tags: ["custom"],
parser: "none",
function: (params, onError) => {
params.lines.forEach((line, index) => {
if (line.includes("TODO:") || line.includes("FIXME:")) {
onError({
lineNumber: index + 1,
detail: "应解决TODO/FIXME注释",
context: line.trim()
});
}
});
}
};
函数参数
params 对象
params 对象包含关于markdown内容的所有信息:
function rule(params, onError) {
// params.name - 输入文件/字符串名称
// params.lines - 行数组(string[])
// params.frontMatterLines - 前导符行
// params.config - 来自 .markdownlint.json 的规则配置
// params.version - markdownlint库版本
// params.parsers - 解析器输出
}
访问行
function: (params, onError) => {
params.lines.forEach((line, index) => {
const lineNumber = index + 1; // 行基于1
if (someCondition(line)) {
onError({
lineNumber,
detail: "问题描述",
context: line.trim()
});
}
});
}
使用配置
// 在 .markdownlint.json 中
{
"custom-rule": {
"max_length": 50,
"pattern": "^[A-Z]"
}
}
// 在规则中
function: (params, onError) => {
const config = params.config || {};
const maxLength = config.max_length || 40;
const pattern = config.pattern ? new RegExp(config.pattern) : null;
// 使用配置值
}
处理前导符
function: (params, onError) => {
const frontMatterLines = params.frontMatterLines;
if (frontMatterLines.length > 0) {
// 处理YAML前导符
const frontMatter = frontMatterLines.join('
');
// 验证前导符
}
}
错误报告与 onError
基本错误报告
onError({
lineNumber: 5, // 必需:基于1的行号
detail: "行超过最大长度", // 可选:附加信息
context: "这是有问题的..." // 可选:相关文本
});
带范围的错误
高亮行的特定部分:
onError({
lineNumber: 10,
detail: "无效的标题格式",
context: "### 标题",
range: [1, 3] // 列1,长度3(高亮"###")
});
带修复信息的错误
启用自动修复:
onError({
lineNumber: 15,
detail: "额外空白",
context: " 文本 ",
fixInfo: {
editColumn: 1,
deleteCount: 2,
insertText: ""
}
});
自动修复与 fixInfo
删除字符
// 从列10开始删除5个字符
fixInfo: {
lineNumber: 5,
editColumn: 10,
deleteCount: 5
}
插入文本
// 在列1插入文本
fixInfo: {
lineNumber: 3,
editColumn: 1,
insertText: "# "
}
替换文本
// 用新文本替换3个字符
fixInfo: {
lineNumber: 7,
editColumn: 5,
deleteCount: 3,
insertText: "new"
}
删除整行
// 删除整行
fixInfo: {
lineNumber: 10,
deleteCount: -1
}
插入新行
// 插入空行
fixInfo: {
lineNumber: 8,
insertText: "
"
}
多行修复
为同一违规报告多个修复:
function: (params, onError) => {
// 修复需要在多行进行更改
onError({
lineNumber: 5,
detail: "不一致的列表标记",
fixInfo: {
lineNumber: 5,
editColumn: 1,
deleteCount: 1,
insertText: "-"
}
});
onError({
lineNumber: 6,
detail: "不一致的列表标记",
fixInfo: {
lineNumber: 6,
editColumn: 1,
deleteCount: 1,
insertText: "-"
}
});
}
完整规则示例
强制标题大写
module.exports = {
names: ["heading-capitalization", "HC001"],
description: "标题必须以大写字母开头",
tags: ["headings", "custom"],
parser: "markdownit",
function: (params, onError) => {
const headings = params.parsers.markdownit.tokens
.filter(token => token.type === "heading_open");
for (const heading of headings) {
const headingLine = params.lines[heading.lineNumber - 1];
const match = headingLine.match(/^#+\s+(.+)$/);
if (match) {
const text = match[1];
const firstChar = text.charAt(0);
if (firstChar !== firstChar.toUpperCase()) {
const hashCount = headingLine.indexOf(' ');
onError({
lineNumber: heading.lineNumber,
detail: "标题必须以大写字母开头",
context: headingLine,
range: [hashCount + 2, 1],
fixInfo: {
editColumn: hashCount + 2,
deleteCount: 1,
insertText: firstChar.toUpperCase()
}
});
}
}
}
}
};
要求标题前有空行
module.exports = {
names: ["blank-line-before-heading", "BLH001"],
description: "要求标题前有空行(首行除外)",
tags: ["headings", "custom", "whitespace"],
parser: "markdownit",
function: (params, onError) => {
const headings = params.parsers.markdownit.tokens
.filter(token => token.type === "heading_open");
for (const heading of headings) {
const lineNumber = heading.lineNumber;
// 如果是首行或在前导符后跳过
if (lineNumber <= params.frontMatterLines.length + 1) {
continue;
}
const previousLine = params.lines[lineNumber - 2];
if (previousLine.trim() !== "") {
onError({
lineNumber: lineNumber - 1,
detail: "期望标题前有空行",
context: previousLine,
fixInfo: {
lineNumber: lineNumber - 1,
editColumn: previousLine.length + 1,
insertText: "
"
}
});
}
}
}
};
验证代码块语言
module.exports = {
names: ["code-block-language", "CBL001"],
description: "代码块必须指定语言",
tags: ["code", "custom"],
parser: "markdownit",
function: (params, onError) => {
const config = params.config || {};
const allowedLanguages = config.allowed_languages || [];
const fences = params.parsers.markdownit.tokens
.filter(token => token.type === "fence");
for (const fence of fences) {
const language = fence.info.trim();
if (!language) {
onError({
lineNumber: fence.lineNumber,
detail: "代码块必须指定语言",
context: fence.line
});
} else if (allowedLanguages.length > 0 && !allowedLanguages.includes(language)) {
onError({
lineNumber: fence.lineNumber,
detail: `语言'${language}'不在允许列表中:${allowedLanguages.join(', ')}`,
context: fence.line
});
}
}
}
};
检测损坏的相对链接
const fs = require('fs');
const path = require('path');
module.exports = {
names: ["no-broken-links", "NBL001"],
description: "检测损坏的相对链接",
tags: ["links", "custom"],
parser: "markdownit",
asynchronous: true,
function: async (params, onError) => {
const links = params.parsers.markdownit.tokens
.filter(token => token.type === "link_open");
for (const link of links) {
const hrefToken = link.attrs.find(attr => attr[0] === "href");
if (hrefToken) {
const href = hrefToken[1];
// 仅检查相对链接
if (!href.startsWith('http://') && !href.startsWith('https://')) {
const filePath = path.join(path.dirname(params.name), href);
try {
await fs.promises.access(filePath);
} catch (err) {
onError({
lineNumber: link.lineNumber,
detail: `损坏的链接:${href}`,
context: link.line
});
}
}
}
}
}
};
强制一致的列表标记
module.exports = {
names: ["consistent-list-markers", "CLM001"],
description: "列表必须在同一级别使用一致的标记",
tags: ["lists", "custom"],
parser: "micromark",
function: (params, onError) => {
const lists = params.parsers.micromark.tokens
.filter(token => token.type === "listUnordered");
for (const list of lists) {
const items = params.parsers.micromark.tokens.filter(
token => token.type === "listItemMarker" &&
token.startLine >= list.startLine &&
token.endLine <= list.endLine
);
if (items.length > 0) {
const firstMarker = params.lines[items[0].startLine - 1]
.charAt(items[0].startColumn - 1);
for (const item of items.slice(1)) {
const marker = params.lines[item.startLine - 1]
.charAt(item.startColumn - 1);
if (marker !== firstMarker) {
onError({
lineNumber: item.startLine,
detail: `不一致的列表标记:期望'${firstMarker}',找到'${marker}'`,
context: params.lines[item.startLine - 1],
range: [item.startColumn, 1],
fixInfo: {
editColumn: item.startColumn,
deleteCount: 1,
insertText: firstMarker
}
});
}
}
}
}
}
};
异步规则
基本异步规则
module.exports = {
names: ["async-rule-example"],
description: "示例异步规则",
tags: ["async", "custom"],
parser: "none",
asynchronous: true,
function: async (params, onError) => {
// 可以使用await
const result = await someAsyncOperation();
if (!result.valid) {
onError({
lineNumber: 1,
detail: "异步验证失败"
});
}
// 必须返回Promise(由async函数隐式返回)
}
};
网络验证
const https = require('https');
module.exports = {
names: ["validate-external-links"],
description: "验证外部HTTP链接返回200",
tags: ["links", "async"],
parser: "markdownit",
asynchronous: true,
function: async (params, onError) => {
const links = params.parsers.markdownit.tokens
.filter(token => token.type === "link_open");
const checkLink = (url) => {
return new Promise((resolve) => {
https.get(url, (res) => {
resolve(res.statusCode === 200);
}).on('error', () => {
resolve(false);
});
});
};
for (const link of links) {
const hrefToken = link.attrs.find(attr => attr[0] === "href");
if (hrefToken) {
const href = hrefToken[1];
if (href.startsWith('http://') || href.startsWith('https://')) {
const valid = await checkLink(href);
if (!valid) {
onError({
lineNumber: link.lineNumber,
detail: `外部链接可能损坏:${href}`,
context: link.line
});
}
}
}
}
}
};
使用自定义规则
在配置文件中
// .markdownlint.js
const customRules = require('./custom-rules');
module.exports = {
default: true,
customRules: [
customRules.headingCapitalization,
customRules.blankLineBeforeHeading,
customRules.codeBlockLanguage
],
"heading-capitalization": true,
"blank-line-before-heading": true,
"code-block-language": {
"allowed_languages": ["javascript", "typescript", "bash", "json"]
}
};
在Node.js脚本中
const markdownlint = require('markdownlint');
const customRules = require('./custom-rules');
const options = {
files: ['README.md'],
customRules: [
customRules.headingCapitalization,
customRules.blankLineBeforeHeading
],
config: {
default: true,
"heading-capitalization": true,
"blank-line-before-heading": true
}
};
markdownlint(options, (err, result) => {
if (!err) {
console.log(result.toString());
}
});
使用 markdownlint-cli
# 使用CLI自定义规则
markdownlint -c .markdownlint.js -r ./custom-rules/*.js *.md
TypeScript 支持
类型安全规则定义
import { Rule } from 'markdownlint';
const rule: Rule = {
names: ['typescript-rule', 'TS001'],
description: '示例TypeScript自定义规则',
tags: ['custom'],
parser: 'markdownit',
function: (params, onError) => {
// 类型安全实现
params.parsers.markdownit.tokens.forEach(token => {
if (token.type === 'heading_open') {
onError({
lineNumber: token.lineNumber,
detail: '示例错误'
});
}
});
}
};
export default rule;
何时使用此技能
- 强制执行项目特定的文档标准
- 验证自定义markdown模式
- 检查领域特定要求
- 扩展markdownlint超越内置规则
- 创建可重用的规则包
- 自动化文档质量检查
- 实施团队编码标准
- 构建自定义linting工具链
最佳实践
- 清晰的规则名称 - 使用描述性名称指示目的
- 全面的描述 - 记录规则检查内容
- 适当的标签 - 分类规则以便轻松过滤
- 选择正确的解析器 - 大多数情况使用markdownit,精确定位使用micromark
- 提供信息URL - 链接到详细规则文档
- 支持配置 - 允许通过params.config自定义规则
- 有帮助的错误消息 - 提供清晰的细节和上下文
- 尽可能使用范围 - 高亮确切问题位置
- 实现fixInfo - 尽可能启用自动修复
- 处理边缘情况 - 考虑前导符、空文件等
- 性能考虑 - 避免在规则中执行昂贵操作
- 全面测试 - 使用各种markdown文件测试
- 版本文档 - 记录所需的markdownlint版本
- 正确导出 - 一致使用module.exports或ES6导出
- 需要时使用异步 - 仅对I/O操作使用异步
常见陷阱
- 错误行号 - 忘记行基于1,不是基于0
- 缺少解析器 - 未指定解析器属性
- 不正确的令牌类型 - 使用错误的令牌类型名称
- 无错误上下文 - 未在错误中提供有帮助的上下文
- 同步I/O - 使用同步函数而不是异步
- 忽略前导符 - 未正确处理前导符
- 硬编码值 - 未使用配置参数
- 性能差 - 在大文件上使用低效算法
- 缺少可修复性 - 可能时未实现fixInfo
- 不完全测试 - 未测试边缘情况和错误条件
- 解析器不匹配 - 访问错误的解析器输出
- 列偏移1 - 列基于1,如行号
- 内存泄漏 - 在异步规则中未清理
- 阻塞操作 - 长时间运行的同步操作
- 类型混淆 - 在解析器之间混淆令牌属性