CLI tool
zhlint-project/zhlint avatar
zhlint-project/zhlint

zhlint: a linter that knows where Chinese text meets Latin text

A linting tool for Chinese language.

1,015 stars28 forksTypeScriptMIT

At a glance

What is it?
zhlint is a linting tool for Chinese text content that inserts the spaces a mixed Chinese and English sentence needs, and reports each problem with a character offset and a caret. It runs as a CLI, as a Node package, and as a standalone browser script, and it understands Markdown and Hexo tag syntax well enough to skip over them.
Who is it for?
zhlint fits a documentation team or a content pipeline that mixes Chinese with English identifiers and needs the spacing fixed consistently, and that wants the check to run in CI rather than in a reviewer's head. It is the wrong choice for a prose-only Chinese document with no Latin in it, and the wrong choice if you need a general-purpose Markdown linter.
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 124 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 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

One rule, applied with an offset and a caret

zhlint is a linting tool for Chinese text content, and the problem it solves is narrow enough to describe in a sentence: in a sentence that mixes Chinese characters with English words, there should be a space between them.

The output is a report that looks like this:

bash
1:6 - 此处中英文内容之间需要一个空格

自动在中文和English之间加入空格
      ^

1:13 - 此处中英文内容之间需要一个空格

自动在中文和English之间加入空格
             ^
Invalid files:
- foo.md

Found 2 errors.

Each finding gives a line and column, a message in natural language, the offending line, and a caret under the exact position. The column matters, because the second error is at a different offset in the same line, and a linter that only reported the line would be far less useful in a document with several problems close together.

Underneath, the data model is small and explicit. A `Result` holds the optional file name, a `disabled` flag, the original content in `origin`, the finally fixed content in `result`, and an array of validations. A `Validation` has a `message` in natural language, an `index` that is the index of the target token in the input string, and a `length` that is the length of that token.

That index-and-length shape is what makes the tool composable, because it means a caller can act on the exact span rather than re-searching the text. The `disabled` flag on the result is a useful detail too, since it lets a caller know a file was skipped by the ignore configuration rather than simply found clean.

Markdown and Hexo tags are skipped, not linted

The interesting engineering in zhlint is not the spacing rule, it is knowing which characters are actually prose.

Markdown syntax is supported by default. The documentation says it will analyse the Markdown syntax first and extract the pure text content and do the lint job, and that after that the fixed pure text content could be replaced back to the raw Markdown string and returned as the output value in the result. So the tool does not operate on a flattened string, it operates on the prose and then writes the fix back into the original document around the unchanged markup.

An example shows the shape of the problem:

js
run('自动在_中文_和**English**之间加入空格', options)

The underscores and the double asterisks are Markdown emphasis, and a naive scan would see the character before 中 and the character after 势 and conclude that Latin text is butted against Chinese, when in fact the asterisks are what separated them.

Hexo tags are handled for a more specific reason. The documentation says Hexo tags syntax is supported just because when using Hexo to build a Vue.js website, the markdown source files more or less include special tags like that, so the result was unpredictable. The tool therefore skips Hexo-style tags by default:

js
run(
  '现在过滤器只能用在插入文本中 (`{% raw %}{{ }}{% endraw %}` tags)。',
  options
)

That example is the hard case. The text contains a `{% raw %}` tag, and a naive parser would have to decide whether the braces start a template expression, and the answer here is that the whole thing is literal text the author wanted, which is why the raw tag exists in the first place.

The parsers are pluggable, which is how the tool handles a file format it does not know. The `hyperParse` option customises the hyper parser by name, and the defaults are the ignored cases parser, the Markdown parser, and the Hexo tags parser. A site with a different templating syntax can add its own parser rather than losing all the prose detection, and a file type with no parser at all falls back to whatever the default does.

So the tool is really a text segmentation problem with a Chinese-aware rule, and the segmentation is the part that took the work.

The CLI, the Node API, and the browser build

There are three ways to use zhlint, and the documentation describes all three properly.

The CLI is the everyday interface. Install it globally through npm, yarn, or pnpm:

bash
# install through npm
npm install zhlint -g

# or through yarn
yarn global add zhlint

# or through pnpm
pnpm add zhlint -g

Then the commands. Globbing files, linting them, printing the validation report, and exiting with code `1` if there is any error found. Adding `--fix` to glob files and fix all possibly found errors. Passing an input file with `--output=` to lint the file and output fixed content into another file. And `--help` for usage info.

The exit code detail is the one that matters for CI. A linter that always exits zero is documentation, and a linter that exits 1 on any error is a gate. Here the report is printed and the process signals failure, so `zhlint` in a pipeline can fail the build.

The Node package is the programmatic route, and the API is four functions. `run(str, options)` lints a string and returns the result, `report(results, logger?)` prints the validation reports for each file, `readRc(dir, config, fileIgnore, caseIgnore, logger?)` reads the configuration, and `runWithConfig(str, config)` lints a string with that configuration.

The usage example is short:

js
const { run, report } = require('zhlint')

const value = '自动在中文和English之间加入空格'
const options = { rules: { preset: 'default' } }
const output = run(value, options)

// print '自动在中文和 English 之间加入空格''
console.log(output.result)

// print validation report
report([output])

And the standalone build, a JavaScript file `dist/zhlint.js` that you can add into your browser as a `<script>` tag, after which there is a global variable `zhlint` for you. The package metadata confirms the distribution shape, with `main` pointing at the Node build, `unpkg` and `jsdelivr` both pointing at a UMD build in `dist/`, and `types` at the declaration file.

So the same engine is usable from a shell, from a Node script, and from a page, and the browser build is a real part of the design rather than an afterthought.

Three config files, and why each exists

The configuration story is three files with three different jobs, and the separation is the point.

The rc file is the rules file, and it is JSON shaped:

json
{
  "preset": "default",
  "rules": {
    "adjustedFullwidthPunctuation": ""
  }
}

The `preset` picks a named set of rules and the `rules` object overrides individual ones. The rule named in the example, `adjustedFullwidthPunctuation`, is about fullwidth punctuation, and the empty string in the example is the override value.

The file-ignore file uses .gitignore syntax, so you can exclude paths with the patterns you already know rather than learning a second matching language. The same file serves for the case-ignore file, which lists ignored cases in a much simpler format:

txt
( , )

Those are the pairs of characters that are allowed to sit adjacent without a space, which is the escape hatch for text where the rule does not apply.

The programmatic path shows how the same three inputs are combined. `readRc` takes the directory, the config file path, the file-ignore file path, and the case-ignore file path, and returns a `Config`. `runWithConfig` then lints a string with that config. So the file-based and programmatic routes use the same machinery, which is what stops the CLI and the library from drifting apart.

The options type shows the full surface at once:

ts
type Options = {
  rules?: RuleOptions
  hyperParse?: string[]
  ignoredCases?: IgnoredCase[]
  logger?: Console
}

`rules` may be undefined, which means linting nothing, or `{ preset: 'default' }` for the default configuration. `ignoredCases` takes an `IgnoredCase` of the shape `{ prefix?, textStart, textEnd?, suffix? }`, and the format follows a certain pattern with an optional prefix, then the start text, then an optional end text, then an optional suffix, which the documentation says is inspired by the W3C Scroll To Text Fragment proposal.

That inspiration is a good clue about the design. The Scroll To Text Fragment proposal is about saying, in a URL, that a piece of text should be highlighted. zhlint borrows its notation for saying, in a document, that a piece of text should be left alone, which is a neat reuse of a syntax that was designed for a different purpose but with the same shape of problem.

The build produces a Node library, a browser bundle, and a docs site

The package metadata shows a project with a real release pipeline rather than a single build script.

The build is two steps. `build:node` runs `tsc` with a separate build configuration, producing the `lib/` directory with the JavaScript and the type declarations, and `build:browser` runs a Vite build producing the `dist/` bundles. The published file list includes the `dist/` directory, the `bin/` directory, the `lib/` directory, and a couple of documentation assets, and the `bin` entry points `zhlint` at `./bin/index.js`.

The tests are two layers. `test:tsc` runs the compiler with `--noEmit` to check the types, and `test:vitest` runs the test suite, and the `test` script chains them. There is a separate `coverage` script and a `debug` script that runs a single test file in the debugger, which is the kind of convenience that appears when a suite has a test that is hard to reproduce otherwise.

The linting is split three ways, which is worth noting because it means the formatting check and the code quality check are independent. `lint` is Prettier in check mode, `lint:eslint` is eslint over the source and test directories, and `lint:fix` variants exist for both. The eslint invocation covers `{src,test}/**.ts`, so both directories are linted with the same rules.

The documentation is VitePress, with a dev server, a build, a serve, and a deploy script. Two of the scripts are unusual and telling. `docs:runtime` copies the built browser bundle into the docs directory, so the documentation site runs the real built artefact rather than a reimplementation of it. And `docs:readme` runs a script that generates the readme, which means the readme's rule table and the documentation come from one source rather than being maintained by hand in two places.

The `prepublish` script chains the build, the docs runtime copy, and the readme generation, so a published package always ships with a documentation set that matches the code that was built.

Editorial conclusion

zhlint fits a documentation team or a content pipeline that mixes Chinese with English identifiers and needs the spacing fixed consistently, and that wants the check to run in CI rather than in a reviewer's head. It is the wrong choice for a prose-only Chinese document with no Latin in it, and the wrong choice if you need a general-purpose Markdown linter. Check three things before you adopt it: that your content is really the kind of mixed text it is aimed at, that you understand the ignored-cases mechanism, since a project with deliberate exceptions needs them, and that the Markdown and Hexo parsers cover your file types, because a parser it does not recognise means the lint runs over markup it should have skipped.

Frequently asked questions

What does zhlint actually check in Chinese text?

It is a linter for Chinese text content whose main job is inserting and reporting the space needed between Chinese characters and adjacent English text. Each finding gives a line and column, a natural language message, the offending line with a caret under the position, and internally an index and a length for the target token in the input string.

How do I install and run zhlint from the command line?

Install it globally with `npm install zhlint -g`, `yarn global add zhlint`, or `pnpm add zhlint -g`. Then run `zhlint <file-pattern>` to glob files, lint them, print the report, and exit with code 1 if any error was found, or add `--fix` to fix the errors in place, or pass `--output=` to write fixed content to another file.

Does zhlint understand Markdown and Hexo tags?

Yes. Markdown syntax is supported by default, with the tool analysing the syntax first, extracting the pure text content, linting that, and replacing the fixed text back into the raw Markdown. Hexo style tags are skipped by default, because a Vue site built with Hexo tends to have template tags in its Markdown that would otherwise produce unpredictable results, and the parsers are customisable through the `hyperParse` option.

How do I exclude specific text from zhlint's rules?

There is a case-ignore file, `.zhlintcaseignore` by default, listing ignored cases in a simple line format, and an `ignoredCases` option taking an `IgnoredCase` of the shape `{ prefix?, textStart, textEnd?, suffix? }`, a pattern inspired by the W3C Scroll To Text Fragment proposal. Files can be excluded separately through the file-ignore file, which uses .gitignore syntax, and the rules themselves are set in the rc file with a `preset` and per-rule overrides.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. zhlint-project/zhlint on GitHub
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/zhlint-project-zhlint.svg)](https://hysenlabs.com/projects/zhlint-project-zhlint)

Was this useful?