CLI tool
lint-md/lint-md avatar
lint-md/lint-md

lint-md core: an AST rule engine for Chinese Markdown

:books: 检查中文 markdown 编写格式规范的命令行工具,基于 AST,方便集成 CI,写博客 / 文档必备。支持 API 调用!

956 stars56 forksTypeScriptMIT

At a glance

What is it?
The core package of lint-md is a pure rule engine that checks and repairs Chinese Markdown by parsing it into an AST rather than matching text patterns. It performs no file I/O of its own, so CLI, editor and CI integrations are thin wrappers around the same library.
Who is it for?
lint-md core fits teams writing Chinese Markdown at volume, where hand-maintained style rules drift and a diff of changed lines is too little to keep a blog or knowledge base consistent. It does not fit projects that need more than spacing, punctuation and empty-element checks, since the built-in set is 20 rules and only no-long-code refuses to fix itself.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 23 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A rule engine built around a parsed tree, not a regex

lint-md was built to check Chinese Markdown, and the rules reflect that focus rather than general Markdown style. The spacing rules demand a space between Chinese characters and Latin letters, and between Chinese characters and digits. The punctuation rule pushes Chinese contexts toward full-width punctuation and rejects full-width digits. Other rules reject empty code blocks, empty inline code, empty links, empty list items, empty blockquotes, and text containing special characters. There is also a rule requiring two spaces before a soft line break, one forbidding spaces inside link text or inline code, one limiting the length of a single code line, and one allowing at most one consecutive blank line.

That design comes from the architecture. Every lint run creates a SourceCode instance, and rules read the original text and the AST through context.sourceCode. Because the input is a parsed tree, a rule can ask for the text range of a specific node rather than scanning the whole document with a pattern. The ranges use JavaScript UTF-16 indexes and half-open interval semantics, with a TextRange treated as a read-only pair.

The practical consequence is that fixes can be applied as precise text replacements instead of whole-file rewrites, which is what makes it safe to run over a large documentation set without disturbing unrelated formatting.

The package is published to npm as @lint-md/core and written in TypeScript, with jest tests, eslint and prettier configuration in the repository, and an api-extractor setup that produces a report of the public surface.

Zero I/O in the core, all I/O in the adapters

The architecture principle stated for the project is a pure engine with thin adapters. The core accepts strings and returns structured data. It does not read files, does not write files and does not print to a terminal.

Everything that touches the outside world sits in an adapter: the command line tool, editor plugins, and other wrappers. Their job is limited to input and output plus format conversion. None of them carry rule logic, so a rule behaves identically whether it was triggered by a CLI run, an editor save, or a CI job.

Diagnostics are normalized through a LintDiagnostic type, and the core ships a converter, toALEOutput, that maps them into the format ALE style linters expect. An adapter therefore does not have to implement its own mapping. The documentation shows the intended editor integration shape:

ts
// 使用标准诊断格式接入编辑器(如 Vim/Neovim ALE)
import { lintMarkdown, toALEOutput } from '@lint-md/core';

const result = lintMarkdown('中文English 123', {}, false);
console.log(toALEOutput(result.diagnostics, 'test.md'));
// test.md:1:3: E space-around-alphabet: 中英文之间需要添加空格
// test.md:1:12: W space-around-number: 中文与数字之间需要添加空格

Running that produces two diagnostics for the same mixed string, one error for the missing space before the Latin word and one warning for the missing space before the digits. The severity letters differ by rule, and the file position is carried through the conversion, so an editor can place the marker without knowing anything about the rules themselves.

SourceCode ranges and the errors they raise

Rules do not edit text directly. They ask the SourceCode instance to translate a node into a text range, then hand that range to a report call:

ts
const range = context.sourceCode.getTextRange(node, start, end);

context.report({
  range,
  message: '发现不规范文本'
});

The failure modes around ranges are explicit in the documentation, and this is where the engine earns its keep. An invalid range throws InvalidRuleRangeError. A missing mapping throws SourceMapUnavailableError. If a node has been modified in a way that breaks the mapping, SourceMapConsistencyError is thrown. Range errors do not land in the executionErrors list, so a rule that fails on a bad range is a hard stop for that rule rather than a soft warning.

There is a further subtlety for anyone writing custom rules. The mapping is tied to the state of the tree at the time of the request, so a rule that mutates nodes before asking for a range can trigger the consistency error. Rules are expected to read first and report later.

For teams extending the rule set, that contract is the specification. It also means a custom rule has to be written against the same half-open, UTF-16 index model as the built-in ones, which is stricter than a typical text replacement script.

fixMarkdown returns the original findings and the repaired text

The fix API is explicit. fixMarkdown takes a markdown string and an optional options object:

ts
fixMarkdown(
  markdown: string,
  options?: {
    rules?: LintMdRulesConfig,
    ruleErrorPolicy?: 'collect' | 'strict'
  }
)

The rules option selects which rules run for that call. The ruleErrorPolicy option decides what happens when a rule fails, either collect or strict. The return value is a LintMdFixResult whose lintResult describes the original input and whose fixedResult.result holds the final repaired text.

The older lintMarkdown signature stays compatible, taking the markdown string, an optional rules config, an isFixMode flag that defaults to true, and an options object:

ts
lintMarkdown(
  markdown: string,
  rules?: LintMdRulesConfig,
  isFixMode?: boolean,
  options?: LintExecutionOptions
)

The returned structure has four parts worth knowing. lintResult holds the diagnostics with rule name, position, message and severity. diagnostics holds the same findings in the standard LintDiagnostic form for editor consumption. fixedResult is null unless fix mode is on, and when present it carries the repaired result plus notAppliedFixes. executionErrors lists structured rule failures, and when it is not empty the other two result lists may be partial, so an adapter is expected to mark the run as incomplete.

One warning about coordinates. In fix mode, lintResult, diagnostics and the fixable counts all describe the original markdown, while fixedResult.result describes the final text. Applying original diagnostic positions to the fixed output will point at the wrong place.

Severity levels live in .lintmdrc for CLI users

Each rule can be switched off, set to warn, or set to error, and the level is a number: 0 disables the rule, 1 produces a warning, and 2 produces an error. The rule configuration map uses the rule name as the key and the level as the value.

The CLI reads a configuration file from ./.lintmdrc in the project root by default, and a different path can be passed with the config flag. The documented example enables three rules that are off by default:

json
{
  "rules": {
    "require-trailing-spaces": 2,
    "space-around-link": 2,
    "no-multiple-blank-lines": 2
  }
}

Those three defaults matter. require-trailing-spaces, space-around-link and no-multiple-blank-lines are disabled unless you ask for them, presumably because each one changes the meaning of a document in ways that some writers do not want. space-around-link in particular inserts a space between a link and the surrounding text, and require-trailing-spaces inserts the two spaces that make a soft line break render as a break.

When you call the core API directly, the same configuration goes into the rules argument of fixMarkdown instead of a file on disk. The no-long-code rule is the only configurable one in the built-in set, taking a length limit and an exclude list of languages such as dot and mermaid.

Fixes that collide are reported instead of silently dropped

Auto-fixing a document means many rules may want to edit the same region. lint-md does not resolve that quietly. Conflicting fixes are surfaced through the notAppliedFixes list on the fixed result, and each entry carries a range, the replacement text, the targetRule which is the source rule meta name, and a reason.

The reason is a stable conflict code with two values. overlap means the fix starts inside a range that was already applied. same-offset means an earlier insertion owns the same offset. Only conflicts from the final fix round appear in the result, and the range attached to an entry is relative to the input of that final round rather than to the returned text.

This is a design choice worth naming. A tool that silently drops half of its repairs leaves the writer guessing why the file still looks wrong. Here the set of skipped fixes is inspectable, so a wrapper can decide to report it, retry it, or tell the user which rule lost the race. The data field on each entry stays rule-defined, and the documentation is clear that the API does not promise that data will serialize to JSON.

The fixed result also carries convergence, rounds and metrics, which describe the complete fix process rather than a single pass, and executionErrors aggregates failures from all rounds.

Twenty rules, one that cannot fix itself, and a fast loop

The built-in rule set is 20 rules, and the table marks almost all of them as fixable. The exception is no-long-code, which checks that a code line does not exceed a length limit and is marked as not fixable, which is sensible since truncating a line of code changes its meaning. A no-long-code entry can also exclude specific languages from the check.

Where does the engine stop? There is no rule for table formatting and no rule about heading hierarchy. If your team's Markdown conventions go beyond spacing, punctuation and empty elements, this package does not cover them out of the box.

The performance claim is specific and narrow: on a 4 vCPU environment, processing 1000 documents takes under 4 seconds. That figure comes from the project's own description, not from an independent measurement, and it says nothing about individual file latency on small documents.

The maintenance picture is active. The last push was on 2026-09-30, and releases v2.5.3, v2.5.2 and v2.5.1 all landed in September 2026. The licence is MIT. For a dependency you will pin in a build, that cadence means upgrades are cheap, but the API surface is checked by api-extractor through the api:check script, so a change that alters the public contract fails the pipeline rather than reaching your codebase quietly.

Editorial conclusion

lint-md core fits teams writing Chinese Markdown at volume, where hand-maintained style rules drift and a diff of changed lines is too little to keep a blog or knowledge base consistent. It does not fit projects that need more than spacing, punctuation and empty-element checks, since the built-in set is 20 rules and only no-long-code refuses to fix itself. Before adopting it, read the rule table and confirm the severity mapping for 0, 1 and 2 matches what you want CI to fail on, then pin the version you test against because releases and commits move quickly.

Frequently asked questions

What does @lint-md/core do exactly?

It is the rule engine core of lint-md, parsing Chinese Markdown into an AST and applying rules for spacing, punctuation and empty elements. It accepts strings and returns structured data without reading or writing files itself.

How do I turn on rules that are disabled by default in lint-md?

The CLI reads ./.lintmdrc by default, and you can point it at another file with lint-md --config. Set a rule to 1 for a warning or 2 for an error, for example require-trailing-spaces, space-around-link and no-multiple-blank-lines.

What happens when two lint-md fixes overlap?

The conflicting fix is not applied and appears in fixedResult.notAppliedFixes with its range, text, targetRule and a reason of either overlap or same-offset. Only conflicts from the final fix round are listed.

Can lint-md core read files or write them back?

No. The core does zero I/O by design, and the project documents that the CLI, editor plugins and other adapters handle input, output and format conversion while carrying no rule logic.

How many rules does lint-md core ship with, and can they all be fixed automatically?

The built-in set is 20 rules. All of them are fixable except no-long-code, which only checks that a code line stays within a length limit and can exclude specific languages.

Official sources

  1. Issues
  2. License: MIT
  3. lint-md/lint-md on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/lint-md-lint-md.svg)](https://hysenlabs.com/projects/lint-md-lint-md)

Was this useful?