Loading...
Loading...
Create custom linting rules for markdownlint including rule structure, parser integration, error reporting, and automatic fixing.
npx skill4agent add thebushidocollective/han markdownlint-custom-rulesmodule.exports = {
names: ["rule-name", "RULE001"],
description: "Description of what this rule checks",
tags: ["custom", "style"],
parser: "markdownit",
function: function(params, onError) {
// Rule implementation
}
};{
names: Array<String>, // Rule identifiers (required)
description: String, // What the rule checks (required)
tags: Array<String>, // Categorization tags (required)
parser: String, // "markdownit", "micromark", or "none" (required)
function: Function // Rule logic (required)
}{
information: URL, // Link to rule documentation
asynchronous: Boolean // If true, function returns Promise
}module.exports = {
names: ["any-blockquote-markdown-it"],
description: "Rule that reports an error for any blockquote",
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: `Blockquote spans ${lines} line(s).`,
context: blockquote.line
});
}
}
};module.exports = {
names: ["any-blockquote-micromark"],
description: "Rule that reports an error for any blockquote",
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: `Blockquote spans ${lines} line(s).`,
context: params.lines[blockquote.startLine - 1]
});
}
}
};module.exports = {
names: ["no-todo-comments"],
description: "Disallow TODO comments in markdown",
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 comments should be resolved",
context: line.trim()
});
}
});
}
};paramsfunction rule(params, onError) {
// params.name - Input file/string name
// params.lines - Array of lines (string[])
// params.frontMatterLines - Lines of front matter
// params.config - Rule's configuration from .markdownlint.json
// params.version - markdownlint library version
// params.parsers - Parser outputs
}function: (params, onError) => {
params.lines.forEach((line, index) => {
const lineNumber = index + 1; // Lines are 1-based
if (someCondition(line)) {
onError({
lineNumber,
detail: "Issue description",
context: line.trim()
});
}
});
}// In .markdownlint.json
{
"custom-rule": {
"max_length": 50,
"pattern": "^[A-Z]"
}
}
// In rule
function: (params, onError) => {
const config = params.config || {};
const maxLength = config.max_length || 40;
const pattern = config.pattern ? new RegExp(config.pattern) : null;
// Use configuration values
}function: (params, onError) => {
const frontMatterLines = params.frontMatterLines;
if (frontMatterLines.length > 0) {
// Process YAML front matter
const frontMatter = frontMatterLines.join('\n');
// Validate front matter
}
}onError({
lineNumber: 5, // Required: 1-based line number
detail: "Line exceeds maximum length", // Optional: Additional info
context: "This is the problematic..." // Optional: Relevant text
});onError({
lineNumber: 10,
detail: "Invalid heading format",
context: "### Heading",
range: [1, 3] // Column 1, length 3 (highlights "###")
});onError({
lineNumber: 15,
detail: "Extra whitespace",
context: " text ",
fixInfo: {
editColumn: 1,
deleteCount: 2,
insertText: ""
}
});// Remove 5 characters starting at column 10
fixInfo: {
lineNumber: 5,
editColumn: 10,
deleteCount: 5
}// Insert text at column 1
fixInfo: {
lineNumber: 3,
editColumn: 1,
insertText: "# "
}// Replace 3 characters with new text
fixInfo: {
lineNumber: 7,
editColumn: 5,
deleteCount: 3,
insertText: "new"
}// Delete the entire line
fixInfo: {
lineNumber: 10,
deleteCount: -1
}// Insert a blank line
fixInfo: {
lineNumber: 8,
insertText: "\n"
}function: (params, onError) => {
// Fix requires changes on multiple lines
onError({
lineNumber: 5,
detail: "Inconsistent list markers",
fixInfo: {
lineNumber: 5,
editColumn: 1,
deleteCount: 1,
insertText: "-"
}
});
onError({
lineNumber: 6,
detail: "Inconsistent list markers",
fixInfo: {
lineNumber: 6,
editColumn: 1,
deleteCount: 1,
insertText: "-"
}
});
}module.exports = {
names: ["heading-capitalization", "HC001"],
description: "Headings must start with a capital letter",
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: "Heading must start with capital letter",
context: headingLine,
range: [hashCount + 2, 1],
fixInfo: {
editColumn: hashCount + 2,
deleteCount: 1,
insertText: firstChar.toUpperCase()
}
});
}
}
}
}
};module.exports = {
names: ["blank-line-before-heading", "BLH001"],
description: "Require blank line before headings (except first line)",
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;
// Skip if first line or after front matter
if (lineNumber <= params.frontMatterLines.length + 1) {
continue;
}
const previousLine = params.lines[lineNumber - 2];
if (previousLine.trim() !== "") {
onError({
lineNumber: lineNumber - 1,
detail: "Expected blank line before heading",
context: previousLine,
fixInfo: {
lineNumber: lineNumber - 1,
editColumn: previousLine.length + 1,
insertText: "\n"
}
});
}
}
}
};module.exports = {
names: ["code-block-language", "CBL001"],
description: "Code blocks must specify a language",
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: "Code block must specify a language",
context: fence.line
});
} else if (allowedLanguages.length > 0 && !allowedLanguages.includes(language)) {
onError({
lineNumber: fence.lineNumber,
detail: `Language '${language}' not in allowed list: ${allowedLanguages.join(', ')}`,
context: fence.line
});
}
}
}
};const fs = require('fs');
const path = require('path');
module.exports = {
names: ["no-broken-links", "NBL001"],
description: "Detect broken relative links",
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];
// Only check relative links
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: `Broken link: ${href}`,
context: link.line
});
}
}
}
}
}
};module.exports = {
names: ["consistent-list-markers", "CLM001"],
description: "Lists must use consistent markers within the same level",
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: `Inconsistent list marker: expected '${firstMarker}', found '${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: "Example asynchronous rule",
tags: ["async", "custom"],
parser: "none",
asynchronous: true,
function: async (params, onError) => {
// Can use await
const result = await someAsyncOperation();
if (!result.valid) {
onError({
lineNumber: 1,
detail: "Async validation failed"
});
}
// Must return Promise (implicitly returned by async function)
}
};const https = require('https');
module.exports = {
names: ["validate-external-links"],
description: "Validate external HTTP links return 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: `External link may be broken: ${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"]
}
};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());
}
});# Using custom rules with CLI
markdownlint -c .markdownlint.js -r ./custom-rules/*.js *.mdimport { Rule } from 'markdownlint';
const rule: Rule = {
names: ['typescript-rule', 'TS001'],
description: 'Example TypeScript custom rule',
tags: ['custom'],
parser: 'markdownit',
function: (params, onError) => {
// Type-safe implementation
params.parsers.markdownit.tokens.forEach(token => {
if (token.type === 'heading_open') {
onError({
lineNumber: token.lineNumber,
detail: 'Example error'
});
}
});
}
};
export default rule;