iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐟

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.

https://zenn.dev/trifolium/articles/007bff63247432

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 treefmt for batch execution
  • Specify articles/*.md as the target
  • Consolidate linter settings in linter/

You can perform linter checks as shown below.

Sentence with warnings
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

Linter warning screen in VSCode

  • Run linters via CLI
Bash
treefmt --config-file ./linter/treefmt.toml
It's long, so collapsed

I ran the linter on the text above.

Bash
$ 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
  • treefmt configuration
  • VSCode extension settings

Folder Structure

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

https://github.com/DavidAnson/markdownlint-cli2

  • VSCode Extension

https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint

1.2 textlint

Checks Japanese notation and writing style rules.
Many extensions are available, allowing customization according to your needs.

  • CLI

https://github.com/textlint/textlint

  • textlint Extensions

Allows you to specify areas not to be checked by the linter.

https://github.com/textlint/textlint-filter-rule-comments

Rule preset for determining the presence or absence of spaces around Japanese characters.

https://github.com/textlint-ja/textlint-rule-preset-ja-spacing

Rule preset for technical documentation.

https://github.com/textlint-ja/textlint-rule-preset-ja-technical-writing

Rules for checking and correcting the spelling of terms, brands, and technologies in English technical documents.

https://github.com/sapegin/textlint-rule-terminology

  • VSCode Extension

https://marketplace.visualstudio.com/items?itemName=3w36zj6.textlint

1.3 cspell

Performs spell checking for English words.

  • CLI

https://github.com/streetsidesoftware/cspell-cli

  • VSCode

https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker

1.4 lychee

Checks for broken URL links.

  • CLI

https://github.com/lycheeverse/lychee

1.5 treefmt

Allows batch execution of multiple linters.

  • CLI

https://github.com/numtide/treefmt

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.

https://zenn.dev/trifolium/articles/007bff63247432

We will use importNpmLock to make Node packages available in the Nix devShell.

Details are explained in this article.

https://zenn.dev/trifolium/articles/6678b0c0fb0d27

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.

Folder structure
  zenn_contents/
+ ├─ node-pkgs/
+ │  └─ package.json
  └─ flake.nix
package.json
{
  "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

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

https://www.npmjs.com/package/npm-check-updates

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.

Bash
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.

Packages
markdownlint-cli2
textlint
textlint-filter-rule-comments
textlint-rule-preset-ja-spacing
textlint-rule-preset-ja-technical-writing
textlint-rule-terminology
cspell-cli
Bash
snap install lychee

treefmt binaries can be downloaded from GitHub.

https://github.com/numtide/treefmt/releases

3. Configuration for Each Linter

3.1 markdownlint-cli2

Create the linter folder and .markdownlint-cli2.jsonc file.

Folder structure
  zenn_contents/
+ └─ linter/
+     └─ .markdownlint-cli2.jsonc
.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.

https://github.com/DavidAnson/markdownlint

Disabled rules and reasons

MD001: Heading levels should only increment by one level at a time

NG
# Heading 1

### Heading 3
Reason for disabling
# 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

NG
text


text
Reason for disabling
# 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

NG
# Heading 1
Some text

Some more text
## Heading 2
Reason for disabling
# Heading
It's easier to read if it's written like this.
Personal preference.

MD024: Multiple headings with the same content

NG
# Some text

## Some text
Reason for disabling
# Introduction
## textlint

# Usage
## textlint

Because I want to use this kind of writing style.

MD025: Multiple top-level headings in the same document

NG
# Top level heading

# Another top-level heading
Reason for disabling
# Introduction

# Overview

# Conclusion

Because I want to use this kind of writing style.

MD029: Ordered list item prefix

NG
- 1. First, do this
- 2. Next, do that
Reason for disabling
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

NG
<h1>Inline HTML heading</h1>
Reason for disabling
|||
|--|--|
|No line break|With<br>line break|

Because I use HTML tags when breaking lines within a table.

MD034: Bare URLs are used

NG
For more info, visit https://www.example.com/ or email user@example.com.
Reason for disabling
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.

Folder structure
  zenn_contents/
  └─ linter/
      ├─ .markdownlint-cli2.jsonc
+     └─ .textlintrc.json
.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.

markdown
<!-- 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.

.textlintrc.json
{
  "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.

.textlintrc.json
{
  "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:

markdown
:::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.
.textlintrc.json
{
  "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.

NG examples
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").

textlint error

Assuming you want to suppress this error, I will explain how.

First, check the dictionary in the official GitHub repository.

https://github.com/sapegin/textlint-rule-terminology/blob/master/terms.jsonc

If you search for Visual Studio Code, you will find the following:

terms.jsonc
  ["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.

.textlintrc.json
{
  "rules": {
    "terminology": {
      "exclude": [
        "V[ -]?S[ -]?Code"
      ]
    }
  }
}

3.3 cspell

Create cspell.json.

Folder structure
  zenn_contents/
  └─ linter/
      ├─ .markdownlint-cli2.jsonc
      ├─ .textlintrc.json
+     └─ cspell.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.

cspell warning example

Describe the words you want to register in the words field of cspell.json.

linter/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.

Warning screen

Quick fix screen

Disabling specific ranges

You can specify areas not to be checked by cspell.

markdown
<!-- cspell:disable -->

This is ignored text by rule.
Disables all rules between comments

<!-- cspell:enable -->

3.4 lychee

Create lychee.toml.

Folder structure
  zenn_contents/
  └─ linter/
      ├─ .markdownlint-cli2.jsonc
      ├─ .textlintrc.json
      ├─ cspell.json
+     └─ lychee.toml
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.

Folder structure
  zenn_contents/
  └─ linter/
      ├─ .markdownlint-cli2.jsonc
      ├─ .textlintrc.json
      ├─ cspell.json
+     └─ treefmt.toml
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:

treefmt.toml
[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:

Bash
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.

treefmt.toml
[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.

treefmt.toml
[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.

Bash
treefmt --config-file ./linter/treefmt.toml

5.1 Verifying Operation

It's long, so collapsed

I ran the linter on the following text.

Sentence with warnings
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.
Bash
 $ 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.

Folder structure
  zenn_contents/
  └─ .vscode/
+     └─ extensions.json
extensions.json
{
    "recommendations": [
        "streetsidesoftware.code-spell-checker",
        "davidanson.vscode-markdownlint",
        "3w36zj6.textlint"
    ]
}

6.2 Configuration

Create settings.json.

Folder structure
  zenn_contents/
  └─ .vscode/
      ├─ extensions.json
+     └─ settings.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).

Bash
ln -s ../linter/cspell.json .vscode/cspell.json
Folder structure
  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.

Linter warning screen in VSCode

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