iTranslated by AI
Setting Up Linters for Zenn Writing: Batch Execution with treefmt
Introduction
In the previous article, I introduced a local writing environment using VSCode and Zenn CLI.
In this article, I will introduce the configuration and setup of linters.
Specifically, the following elements will be checked by linters:
- Markdown styling (markdownlint-cli2)
- Japanese expressions (textlint)
- English word spelling (cspell)
- Broken URL links (lychee)
Additionally, I will introduce linter settings tailored for Zenn.
Furthermore, since we will be using multiple linters, I will also explain how to execute them collectively using treefmt.
Intended Audience
- Those writing with Zenn CLI / VSCode
- Those using (or wanting to use) markdownlint or textlint
- Those who want to run multiple linters at once
Overview
- Register each linter in
treefmtfor batch execution - Specify
articles/*.mdas the target - Consolidate linter settings in
linter/
You can perform linter checks as shown below.
Missing period at the end and has spaces
Broken URL. https://zenn.dev/trifolium/articles/007bff6324743
[Jump to a non-existent section](#a)
[Zenn-spec image path (non-existent file)](/images/5b01a68b80808b/hoge.webp)
javascript -> x, JavaScript -> o
JavaScrit typo.
- Real-time linter checks in VSCode
Only the URL check is set for CLI use

- Run linters via CLI
treefmt --config-file ./linter/treefmt.toml
It's long, so collapsed
I ran the linter on the text above.
$ task
ERRO formatter | md[1]: failed to apply with options '[--config linter/.markdownlint-cli2.jsonc]': exit status 1
markdownlint-cli2 v0.18.1 (markdownlint v0.38.0)
Finding: articles/5b01a68b80808b.md
Linting: 1 file(s)
Summary: 2 error(s)
articles/5b01a68b80808b.md:857:14 MD009/no-trailing-spaces Trailing spaces [Expected: 0 or 2; Actual: 1]
articles/5b01a68b80808b.md:860:1 MD051/link-fragments Link fragments should be valid [Context: "[Jump to a non-existent section](#a)"]
ERRO formatter | text[2]: failed to apply with options '[--config linter/.textlintrc.json]': exit status 1
/home/ryu/dev/zenn_contents/articles/5b01a68b80808b.md
857:13 error The sentence does not end with "。". ja-technical-writing/ja-no-mixed-period
863:1 ✓ error Incorrect term: “javascript”, use “JavaScript” instead terminology
✖ 2 problems (2 errors, 0 warnings, 0 infos)
✓ 1 fixable problem.
Try to run: $ textlint --fix [file]
ERRO formatter | url[3]: failed to apply with options '[--root-dir /home/ryu/dev/zenn_contents --timeout 10 --max-retries 2]': exit status 2
Issues found in 1 input. Find details below.
[articles/5b01a68b80808b.md]:
[ERROR] file:///home/ryu/dev/zenn_contents/images/5b01a68b80808b/hoge.webp | Cannot find file
[404] https://zenn.dev/trifolium/articles/007bff6324743 | Rejected status code (this depends on your "accept" configuration): Not Found
🔍 25 Total (in 0s) ✅ 23 OK 🚫 2 Errors
ERRO formatter | spell[4]: failed to apply with options '[]': exit status 1
1/1 articles/5b01a68b80808b.md 635.59ms X
articles/5b01a68b80808b.md:864:5 - Unknown word (Scrit) fix: (Scrip, Script)
CSpell: Files checked: 1, Issues found: 1 in 1 file.
traversed 164 files
emitted 1 files for processing
formatted 0 files (0 changed) in 4.32s
Error: failed to finalise formatting: formatting failures detected
task: Failed to run task "default": exit status 1
Article Flow
- Introduction of each linter
- Installation of linters and
treefmt - Configuration for each linter
-
treefmtconfiguration - VSCode extension settings
Folder Structure
zenn_contents/
├─ .vscode/
│ ├─ cspell.json // Symbolic link
│ ├─ extensions.json
│ └─ settings.json
├─ articles/
├─ node-pkgs/
│ └─ package.json
├─ linter/
│ ├─ treefmt.toml
│ ├─ .markdownlint-cli2.jsonc
│ ├─ .textlintrc.json
│ └─ cspell.json
└─ flake.nix
1. Introduction of Tools Used
1.1 markdownlint-cli2
Performs syntax checking for Markdown.
- CLI
- VSCode Extension
1.2 textlint
Checks Japanese notation and writing style rules.
Many extensions are available, allowing customization according to your needs.
- CLI
-
textlintExtensions
Allows you to specify areas not to be checked by the linter.
Rule preset for determining the presence or absence of spaces around Japanese characters.
Rule preset for technical documentation.
Rules for checking and correcting the spelling of terms, brands, and technologies in English technical documents.
- VSCode Extension
1.3 cspell
Performs spell checking for English words.
- CLI
- VSCode
1.4 lychee
Checks for broken URL links.
- CLI
1.5 treefmt
Allows batch execution of multiple linters.
- CLI
2. Installation of Each Tool
I will introduce the "Method using Nix (recommended as it can isolate the Zenn CLI environment and keep the user environment clean)" and the "Standard installation method."
2.1 Method Using Nix
2.1.1 Preface
This assumes the environment described in this article.
We will use importNpmLock to make Node packages available in the Nix devShell.
Details are explained in this article.
2.1.2 Creating Files for Node Package Management
Create a node-pkgs folder directly under the project root and create a package.json file.
zenn_contents/
+ ├─ node-pkgs/
+ │ └─ package.json
└─ flake.nix
{
"name": "zenn-cli-env",
"version": "1.0.0",
"private": "true",
"devDependencies": {
"cspell": "9.2.1",
"markdownlint-cli2": "0.18.1",
"textlint": "15.2.2",
"textlint-filter-rule-comments": "1.2.2",
"textlint-rule-preset-ja-spacing": "2.4.3",
"textlint-rule-preset-ja-technical-writing": "12.0.2",
"textlint-rule-terminology": "5.2.15",
"zenn-cli": "0.2.3"
}
}
2.1.3 Creating flake.nix
{
description = "Zenn CLI environment";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{ nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) importNpmLock;
nodejs = pkgs.nodejs_24;
npmRoot = ./node-pkgs;
in
{
devShells.default = pkgs.mkShell {
packages = [
pkgs.treefmt
pkgs.lychee
importNpmLock.hooks.linkNodeModulesHook
];
npmDeps = importNpmLock.buildNodeModules {
inherit npmRoot nodejs;
};
};
# for updating package.json and package-lock.json
devShells.node = pkgs.mkShell {
packages = [
nodejs
pkgs.npm-check-updates
];
};
}
);
}
2.1.4 Updating Packages
Since the versions of the packages described in package.json earlier might be old, we will update them to the latest versions.
Move to the node-pkgs directory.
cd your_path/zenn_contents/node-pkgs
Execute the ncu command using the devShells.node environment.
nix develop .#node -c ncu -u
2.1.5 Creating package-lock.json
Use npm to record the dependencies of the Node packages listed in package.json into package-lock.json.
nix develop .#node -c npm install --package-lock-only
2.1.6 Starting devShell
Return to the project root and start the environment with nix develop.
Packages will be built based on package-lock.json, and node_modules will be linked directly under the project root.
cd ..
nix develop
Now the preparation is complete.
2.2 Standard Method
Run the following commands to install the tools.
npm install markdownlint-cli2 textlint textlint-filter-rule-comments textlint-rule-preset-ja-spacing textlint-rule-preset-ja-technical-writing textlint-rule-terminology cspell-cli
Package List
I'll leave these here for easy copy-pasting.
markdownlint-cli2
textlint
textlint-filter-rule-comments
textlint-rule-preset-ja-spacing
textlint-rule-preset-ja-technical-writing
textlint-rule-terminology
cspell-cli
snap install lychee
treefmt binaries can be downloaded from GitHub.
3. Configuration for Each Linter
3.1 markdownlint-cli2
Create the linter folder and .markdownlint-cli2.jsonc file.
zenn_contents/
+ └─ linter/
+ └─ .markdownlint-cli2.jsonc
{
"config": {
"MD001": false,
"MD012": false,
"MD013": false,
"MD022": false,
"MD024": false,
"MD025": false,
"MD029": false,
"MD033": false,
"MD034": false
}
}
The list of rules (MDxxx) is available on this page.
Disabled rules and reasons
MD001: Heading levels should only increment by one level at a time
# Heading 1
### Heading 3
# Heading
#### Headings you don't want to show in the table of contents
Sometimes I want to write it like this.
MD012: Multiple consecutive blank lines
text
text
# Heading
Explanation
# Next Heading
It's easier to read during editing if it's written like this.
Personal preference.
MD013: Line length
A warning is generated when it exceeds 80 characters.
Disabled because the role overlaps with textlint.
MD022: Headings should be surrounded by blank lines
# Heading 1
Some text
Some more text
## Heading 2
# Heading
It's easier to read if it's written like this.
Personal preference.
MD024: Multiple headings with the same content
# Some text
## Some text
# Introduction
## textlint
# Usage
## textlint
Because I want to use this kind of writing style.
MD025: Multiple top-level headings in the same document
# Top level heading
# Another top-level heading
# Introduction
# Overview
# Conclusion
Because I want to use this kind of writing style.
MD029: Ordered list item prefix
- 1. First, do this
- 2. Next, do that
This rule forces the following writing style:
1. Do this.
2. Do that.
3. Done.
While this in itself is harmless, warnings also appeared for the writing style in the NG example.
Since I use the writing style in the NG example, I've disabled it.
MD033: Inline HTML
<h1>Inline HTML heading</h1>
|||
|--|--|
|No line break|With<br>line break|
Because I use HTML tags when breaking lines within a table.
MD034: Bare URLs are used
For more info, visit https://www.example.com/ or email user@example.com.
https://zenn.dev/zenn/articles/markdown-guide
Because Zenn has link cards as a unique syntax and I want to use them.
3.2 textlint
Create .textlintrc.json.
zenn_contents/
└─ linter/
├─ .markdownlint-cli2.jsonc
+ └─ .textlintrc.json
{
"plugins": {},
"filters": {
"comments": true
},
"rules": {
"preset-ja-spacing": {
"ja-space-between-half-and-full-width": {
"space": "always"
}
},
"preset-ja-technical-writing": {
"ja-no-weak-phrase": false,
"no-mix-dearu-desumasu": {
"preferInList": "ですます"
},
"ja-no-mixed-period": {
"allowPeriodMarks": [":"]
},
"no-exclamation-question-mark": false
},
"terminology": {
"exclude": [
"V[ -]?S[ -]?Code"
]
}
}
}
Added Extensions
textlint-filter-rule-comments
Allows you to specify areas not to be checked by textlint.
<!-- textlint-disable -->
This is ignored text by rule.
Disables all rules between comments
<!-- textlint-enable -->
In .textlintrc.json, I am just enabling this extension without any customization.
{
"filters": {
"comments": true
}
}
textlint-rule-preset-ja-spacing
A rule preset for determining the presence of spaces around Japanese characters.
By default, it is set not to insert spaces between half-width and full-width characters.
Since I personally prefer to have spaces between "English words or half-width numbers" and Japanese, I have changed the setting to always insert them.
{
"rules": {
"preset-ja-spacing": {
"ja-space-between-half-and-full-width": {
"space": "always"
}
}
}
}
textlint-rule-preset-ja-technical-writing
A rule preset for technical documentation.
I have customized the following:
-
ja-no-weak-phrase
Prohibits weak expressions like〜と思います(I think...). Since I want to use such expressions, I have changed the setting to allow them. -
no-mix-dearu-desumasu
A rule to unify headings as automatic, the main text as "desu-masu" (polite) style, and bullet points as "dearu" (plain) style. I have changed it to unify both the main text and bullet points to "desu-masu" style. -
ja-no-mixed-period
A rule that checks for missing periods ("。"). I have added ":" as a character to be allowed as an exception (recognized as a period). If not added, warnings will appear in the following syntax:
:::message
Message here.
:::
- no-exclamation-question-mark
A rule that prohibits exclamation marks (!!) and question marks (??). I have changed it to allow them because it's more convenient.
{
"rules": {
"preset-ja-technical-writing": {
"ja-no-weak-phrase": false,
"no-mix-dearu-desumasu": {
"preferInList": "ですます"
},
"ja-no-mixed-period": {
"allowPeriodMarks": [":"]
},
"no-exclamation-question-mark": false
}
}
}
textlint-rule-terminology
A rule for checking and correcting the spelling of terms, brands, and technologies in English technical documents.
Javascript -> JavaScript
NPM -> npm
front-end -> frontend
website -> site
Internet -> internet
I believe this extension requires continuous review of settings.
For example, by default, "VSCode" will result in an error (it should be "Visual Studio Code").

Assuming you want to suppress this error, I will explain how.
First, check the dictionary in the official GitHub repository.
If you search for Visual Studio Code, you will find the following:
["Visual ?Studio ?Code", "Visual Studio Code"],
["V[ -]?S[ -]?Code", "Visual Studio Code"],
Add V[ -]?S[ -]?Code to rules.terminology.exclude in .textlintrc.json.
Now "VSCode" is allowed.
Note that since Visual ?Studio ?Code is not specified for exclusion, visual studio code will still trigger an error.
{
"rules": {
"terminology": {
"exclude": [
"V[ -]?S[ -]?Code"
]
}
}
}
3.3 cspell
Create cspell.json.
zenn_contents/
└─ linter/
├─ .markdownlint-cli2.jsonc
├─ .textlintrc.json
+ └─ cspell.json
{
"version": "0.2",
"files": [
"articles/*.md"
],
"words": [
"Zenn"
]
}
Dictionary Registration
Proper nouns are not registered in the cspell dictionary, so warnings will appear.

Describe the words you want to register in the words field of cspell.json.
{
"words": [
"Zenn"
]
}
For VSCode
You can easily register words in the dictionary using Quick Fix.
Just hover over "Zenn" and click Add "Zenn" to config: .vscode/cspell.json.


Disabling specific ranges
You can specify areas not to be checked by cspell.
<!-- cspell:disable -->
This is ignored text by rule.
Disables all rules between comments
<!-- cspell:enable -->
3.4 lychee
Create lychee.toml.
zenn_contents/
└─ linter/
├─ .markdownlint-cli2.jsonc
├─ .textlintrc.json
├─ cspell.json
+ └─ lychee.toml
# Maximum number of allowed retries before a link is declared dead.
max_retries = 2
# Website timeout from connect to response finished.
timeout = 10
# Root path to use when checking absolute local links, must be an absolute path
root_dir = "<your_path>/zenn_contents"
By specifying root_dir, Zenn's unique image path syntax can be correctly recognized. Please read the comments for other settings.
4. treefmt Settings
Create treefmt.toml.
zenn_contents/
└─ linter/
├─ .markdownlint-cli2.jsonc
├─ .textlintrc.json
├─ cspell.json
+ └─ treefmt.toml
[formatter.md]
command = "markdownlint-cli2"
options = ["--config", "linter/.markdownlint-cli2.jsonc"]
includes = ["articles/*.md"]
priority = 1
[formatter.text]
command = "textlint"
options = ["--config", "linter/.textlintrc.json"]
includes = ["articles/*.md"]
priority = 2
[formatter.url]
command = "lychee"
options = ["--config", "linter/lychee.toml"]
includes = ["articles/*.md"]
priority = 3
[formatter.spell]
command = "cspell"
includes = ["articles/*.md"]
priority = 4
[global]
excludes = [
"articles/00341ed49b7935.md"
]
Explanation of Settings
Basic Settings for Each Linter
I think it will be easier to understand if you see the actual behavior.
Consider a case with the following settings:
[formatter.md]
command = "markdownlint-cli2"
options = ["--config", "linter/.markdownlint-cli2.jsonc"]
includes = ["articles/*.md"]
Running treefmt gives the same result as executing the following commands:
markdownlint-cli2 --config linter/.markdownlint-cli2.jsonc articles/5b01a68b80808b.md
markdownlint-cli2 --config linter/.markdownlint-cli2.jsonc articles/0a5f92301e3b6f.md
# ...and so on
# Evaluates all .md files in the articles directory
Using Multiple Linters
While the previous example only used markdownlint-cli, treefmt allows you to register multiple linters.
Additionally, the execution order of the linters can be controlled using priority.
[formatter.md]
priority = 1
[formatter.text]
priority = 2
[formatter.YourLinterName]
priority = 3
In this example, linters will be executed on the target file in the order: md -> text -> YourLinterName.
Exclusion Settings
treefmt allows you to specify files to be excluded from checks.
Exclusion can be set per linter or globally for all linters.
[formatter.md]
excludes = ["articles/00341ed49b7935.md"]
[global]
excludes = ["articles/00341ed49b7935.md"]
In the configuration introduced this time, since warnings in articles written before the introduction of the linters are annoying, I've set the exclusions in global.
5. How to Use
Run the following command to perform linter checks.
treefmt --config-file ./linter/treefmt.toml
5.1 Verifying Operation
It's long, so collapsed
I ran the linter on the following text.
Missing period at the end and has spaces
Broken URL. https://zenn.dev/trifolium/articles/007bff6324743
[Jump to a non-existent section](#a)
[Zenn-spec image path (non-existent file)](/images/5b01a68b80808b/hoge.webp)
javascript -> x, JavaScript -> o
JavaScrit typo.
$ task
ERRO formatter | md[1]: failed to apply with options '[--config linter/.markdownlint-cli2.jsonc]': exit status 1
markdownlint-cli2 v0.18.1 (markdownlint v0.38.0)
Finding: articles/5b01a68b80808b.md
Linting: 1 file(s)
Summary: 2 error(s)
articles/5b01a68b80808b.md:857:14 MD009/no-trailing-spaces Trailing spaces [Expected: 0 or 2; Actual: 1]
articles/5b01a68b80808b.md:860:1 MD051/link-fragments Link fragments should be valid [Context: "[Jump to a non-existent section](#a)"]
ERRO formatter | text[2]: failed to apply with options '[--config linter/.textlintrc.json]': exit status 1
/home/ryu/dev/zenn_contents/articles/5b01a68b80808b.md
857:13 error The sentence does not end with "。". ja-technical-writing/ja-no-mixed-period
863:1 ✓ error Incorrect term: “javascript”, use “JavaScript” instead terminology
✖ 2 problems (2 errors, 0 warnings, 0 infos)
✓ 1 fixable problem.
Try to run: $ textlint --fix [file]
ERRO formatter | url[3]: failed to apply with options '[--root-dir /home/ryu/dev/zenn_contents --timeout 10 --max-retries 2]': exit status 2
Issues found in 1 input. Find details below.
[articles/5b01a68b80808b.md]:
[ERROR] file:///home/ryu/dev/zenn_contents/images/5b01a68b80808b/hoge.webp | Cannot find file
[404] https://zenn.dev/trifolium/articles/007bff6324743 | Rejected status code (this depends on your "accept" configuration): Not Found
🔍 25 Total (in 0s) ✅ 23 OK 🚫 2 Errors
ERRO formatter | spell[4]: failed to apply with options '[]': exit status 1
1/1 articles/5b01a68b80808b.md 635.59ms X
articles/5b01a68b80808b.md:864:5 - Unknown word (Scrit) fix: (Scrip, Script)
CSpell: Files checked: 1, Issues found: 1 in 1 file.
traversed 164 files
emitted 1 files for processing
formatted 0 files (0 changed) in 4.32s
Error: failed to finalise formatting: formatting failures detected
task: Failed to run task "default": exit status 1
6. VSCode Extension Settings
6.1 Installing Extensions
VSCode extensions for markdownlint-cli2, textlint, and cspell are available.
Refer to the following extensions.json to install the extensions.
zenn_contents/
└─ .vscode/
+ └─ extensions.json
{
"recommendations": [
"streetsidesoftware.code-spell-checker",
"davidanson.vscode-markdownlint",
"3w36zj6.textlint"
]
}
6.2 Configuration
Create settings.json.
zenn_contents/
└─ .vscode/
├─ extensions.json
+ └─ settings.json
{
"textlint.configPath": "<your_path>/zenn_contents/linter/.textlintrc.json",
"markdownlint.configFile": "<your_path>/zenn_contents/linter/.markdownlint-cli2.jsonc",
}
The configuration is simple, just specifying the path to the config files.
cspell
Code Spell Checker does not have a setting to specify the config path.
It is designed to automatically read cspell.json located in the project root or in .vscode/.
Therefore, we will create a symbolic link from linter/cspell.json to .vscode/cspell.json.
Run the following at the project root (zenn_contents).
ln -s ../linter/cspell.json .vscode/cspell.json
zenn_contents/
├─ .vscode/
+ │ ├─ cspell.json // Symbolic link
│ ├─ extensions.json
│ └─ settings.json
└─ linter
└─ cspell.json
6.3 Verifying Operation
As shown in the image below, linter warnings will appear in real-time in the Problems tab.

Conclusion
By using treefmt as a hub, I was able to unify checks for Markdown, Japanese, links, and spelling.
In the next article, I plan to introduce how to simplify command execution using Taskfile.
Discussion