MarkuplintでHTMLの品質を守る

この記事は、CYBOZU SUMMER BLOG FES '26の記事です。

こんにちは!サイボウズでフロントエンドエンジニアをしている たくぼう です!

はじめに

フロントエンドの開発では、JavaScript には ESLint、CSS には Stylelint と、Lint ツールを入れるのが当たり前になっています。

一方で HTML はどうかというと、意外とノーチェックのままになっていないでしょうか。

HTML は多少間違っていてもブラウザがよしなに表示してくれるので、問題に気づきにくい言語です。

ただ、崩れた入れ子、抜けた alt、ひも付いていない label といった小さな乱れは、アクセシビリティや SEO、コードの保守性にじわじわ効いてきます。

この記事では、そんな HTML のための Lint ツールMarkuplintを紹介します。 あわせて記事の後半では、実際にモノレポ構成の kintone へ導入してみた事例も紹介します。

Markuplintとは

Markuplint は、その名のとおりマークアップのための Lint ツールです。

markuplint.dev

HTML の文法ミスやアクセシビリティ上の問題、非推奨の書き方を静的解析で検出してくれます。

素の HTML だけでなく、JSX(React)や Vue、Svelteといったテンプレートに書かれたマークアップも、後述する仕組みでチェックできます。

もう一つの特徴は、ルールを柔軟に設定できることです。

HTML 仕様への準拠を確認するだけでなく、チームやプロダクト独自のマークアップ規約を Lint として表現する使い方にも向いています。

何をチェックしてくれるのか

具体例を挙げてみます。レビューで指摘した覚えのあるものが混じっているのではないでしょうか。

  • 要素の入れ子の妥当性(span の中に div は置けない → permitted-contents
  • 必須属性の欠落(imgaltrequired-attr
  • アクセシビリティまわり(label との関連付け、tablecaption、WAI-ARIA の誤用、id の重複)
  • 非推奨の要素・属性

たとえば次のように span の中に div を書いたとします。

<span>
  <div>テキスト</div>
</span>

すると、次のようなエラーが出ます。

error: このコンテキストでは、要素spanに要素`div`を含めることはできません (permitted-contents)

エラーメッセージが日本語で出てくれるのも強みの1つです。

参考

ESLintのプラグインとの違い

required-attrwai-aria のようなチェックは、React であれば eslint-plugin-jsx-a11y でも一部カバーできます。

「それなら ESLint だけでいいのでは?」と思うかもしれませんが、公式の FAQ でも触れられているとおり、Markuplint ならではの強みがあります。

  • 要素の親子関係(構造)の適合性チェック(permitted-contents)ができる
  • 強力なセレクタ機能で、ルールを細かく制御できる
  • HTML や JSX 以外の構文も幅広くサポートしている

一方でonClick を付けた要素にキーボード操作の考慮を求めるようなイベントハンドラ起点のチェックなど、eslintにしかできないこともあるので併用も可能です。 すでに eslint-plugin-jsx-a11y を入れているプロジェクトでも、置き換えではなく足す形で導入できます。

参考

導入と設定

まずは --init から

導入は簡単で、対話形式で設定ファイルを作ってくれる --init を実行するだけです。

npx markuplint --init

使っているテンプレートエンジン(React や Vue など)や、推奨設定を取り込むかどうかを聞かれるので、答えていくと設定ファイル.markuplintrcが生成されます。

markuplint.config.js のような形式でも書けます。

設定の土台になるのが、公式の推奨プリセット markuplint:recommended です。

extends に指定すると、入れ子の妥当性(permitted-contents)、必須属性(required-attr)、WAI-ARIA(wai-aria)といった主要なルールがまとめて有効になります。

まずはこれを土台にして、あとから必要に応じて足し引きしていくのがおすすめです。React の場合は、後述する React 版のプリセットもあります。

Parser と Spec の設定

Markuplint でコンポーネント内のマークアップをチェックするには、ParserSpec という2つの設定が必要です。

それぞれの役割は次のとおりです。

  • parser: ファイル拡張子(正規表現)に対して、その構文を解釈する Parser を紐づける
  • specs: 同じく拡張子に対して、フレームワーク固有の属性を認識させる

この2つを設定に足すだけで、JSX やテンプレートの中のマークアップも正しくチェックできるようになります。

対応しているフレームワーク

Parser と Spec は特定のフレームワーク専用ではなく、主要なフレームワーク向けにパッケージが用意されています。

  • Vue: @markuplint/vue-parser + @markuplint/vue-spec
  • React: @markuplint/jsx-parser + @markuplint/react-spec
  • Svelte: @markuplint/svelte-parser
  • Astro: @markuplint/astro-parser

Vue・React・Svelte には推奨プリセットも用意されています。

Reactで使う

Reactでは、@markuplint/jsx-parser@markuplint/react-spec を導入します。

npm install -D @markuplint/jsx-parser @markuplint/react-spec

設定ファイルでは、parser.jsx / .tsx に JSX Parser を、specs に React Spec を紐づけます。

React 専用の推奨プリセット markuplint:recommended-react が用意されているので、extends にはこれを指定するのが手軽です。

{
  "extends": ["markuplint:recommended-react"],
  "parser": {
    "\\.[jt]sx?$": "@markuplint/jsx-parser"
  },
  "specs": {
    "\\.[jt]sx?$": "@markuplint/react-spec"
  }
}

React Spec を入れないと、key のような React 固有の属性が「HTML 仕様に存在しない属性」として誤検知(invalid-attr)されてしまいます。

Spec を入れることで誤検知が消え、本当に直すべき問題だけが残ります。

なお、先ほどの --initReactを選ぶと、このあたりの設定はほぼ自動で書き出されます。

まずは --init に任せて、生成された設定を見ながら理解していく進め方でも十分です。

コンポーネントをHTML要素として評価する(pretenders)

Parser と Spec を入れても、まだチェックしきれないケースがあります。カスタムコンポーネントです。

たとえば li をコンポーネント化した次のようなコードを考えます。

<ul>
  <ListItem />
  <ListItem />
</ul>

Markuplint からは ul の直下に ListItem という未知の要素が置かれているように見えるため、本来チェックしたい ul > li の構造を検証できません。

これを解決するのが pretenders(プリテンダー機能)です。 「このコンポーネントはレンダリングされるとこの要素になる」という対応を設定で教えると、各ルールがコンポーネントをその HTML 要素とみなして評価してくれます。

{
  "pretenders": [
    { "selector": "ListItem", "as": "li" }
  ]
}

as はオブジェクト形式でも書けて、レンダリング後の要素が持つ属性(attrs)や、コンポーネントの属性を引き継ぐか(inheritAttrs)まで指定できます。

コンポーネントの数だけ設定が増えていくのが難点ですが、まずはエラーが出た箇所から少しずつ足していく運用で十分機能します。

参考

ルールのカスタマイズ

推奨プリセットのままでも十分使えますが、運用していくとプロジェクトに合わせて調整したくなる場面が出てきます。

いちばん簡単なのは、rules で個別ルールのオン・オフを切り替えることです。false で無効、それ以外の値で有効になります。

{
  "rules": {
    "required-attr": true,
    "indentation": false
  }
}

特定の要素にだけルールを効かせたい場合は、nodeRules(対象要素そのもの)や childNodeRules(対象要素の子・子孫)にセレクタで指定します。

たとえば imgalt を必須にする場合は次のとおりです。

{
  "rules": {
    "required-attr": true
  },
  "nodeRules": [
    {
      "selector": "img",
      "rules": {
        "required-attr": "alt"
      }
    }
  ]
}

required-attr のように要素の種類ごとに設定したいルールは、こうして nodeRules に書くのが公式でも推奨されている使い方です。

カスタムルールの自作もできますが、組み込みルールのオン・オフと nodeRules での絞り込みだけでも、かなり実用的な運用ができると思います。

参考

既存プロジェクトへの組み込み

実際のプロジェクトで動かす方法は大きく二つあります。CI で機械的に回すか、エディタでリアルタイムに指摘してもらうかです。

CI に組み込むなら、package.jsonscripts にコマンドを足しておくのが素直です。

{
  "scripts": {
    "lint:markup": "markuplint \"./src/**/*.tsx\""
  }
}

これを CI で走らせれば、規約から外れたマークアップがそのままマージされるのを防げます。

あわせて VS Code 拡張(Markuplint)を入れておくと、書いているそばから警告が出るので、CI で弾かれる前に気づけます。

MarkuplintのVScode拡張でrequired-attrのエラーが出ている様子

参考

カスタムルールで独自の規約をチェックする

Markuplint はカスタムルールも書けます。

たとえば「.js-toggle クラスを付けた要素には aria-expanded を必須にする」というチェック。こうしたクラス名と属性のマッピングはチーム固有の知識なので、既存のルールでは表現できません。

カスタムルールは createRule で定義し、プラグイン(name + rules)として包んでエクスポートします。

// my-plugin.js
import { createRule } from '@markuplint/ml-core';

const requireAriaExpanded = createRule({
  async verify({ document, report }) {
    await document.walkOn('Element', (el) => {
      if (
        el.classList.contains('js-toggle') &&
        !el.hasAttribute('aria-expanded')
      ) {
        report({
          scope: el,
          message: '.js-toggle 要素には aria-expanded が必要です',
        });
      }
    });
  },
});

// プラグイン化: name + rules で包む
export default {
  name: 'my-rules',
  rules: { 'require-aria-expanded': requireAriaExpanded },
};

あとは設定ファイルの plugins に登録して、プラグイン名/ルール名 で有効化します。

{
  "plugins": ["./my-plugin.js"],
  "rules": { "my-rules/require-aria-expanded": true }
}

これで、チームの規約が組み込みルールと同じようにチェックされます。

規約をコードにしておけば、レビューの目視に頼らず機械的に担保できます。単なる HTML チェッカーを超えて、プロジェクトのマークアップ規約を支える基盤としても使える、というわけです。

参考

kintoneでの活用

kintone でも、アクセシビリティの確保やコード品質の保証を目的として、Markuplint の導入を試みました。

kintone は複数のパッケージを抱えるモノレポ構成です。

開発フローに乗せるなら、CI で回すだけでなく、VS Code 拡張でエディタ上にリアルタイムに警告が出る状態が理想です。

モノレポでハマったところ

リポジトリのルートを VS Code で開くと、拡張が次のような警告を出してうまく動きませんでした。

Since markuplint could not be found in the node_modules of the workspace,
this use the version (v4.12.0) installed in VS Code Extension.

kintone のディレクトリ構造は、ざっくり次のようになっています。

kintone/              # リポジトリのルート
└─ frontend/
   ├─ pudding/        # チームのディレクトリ
   │  ├─ app/         # 領域やパッケージごとのディレクトリ
   │  └─ mobile-app/

Markuplint が各パッケージの下にインストールされている構成だと、ルートを開いただけでは拡張がそれを見つけられず、プロジェクト固有の設定やプラグインを反映しないままチェックが走ってしまいます。

当面の回避策は、ルートではなくパッケージのディレクトリを直接開くことです。

ただ、これは v5 系で解消できます。

v5.0.0-alpha.2 で VS Code 拡張に workingDirectories オプションが追加され、モノレポ内の複数のパッケージをワーキングディレクトリとして指定できるようになりました。これを設定しておけば、ルートを開いたままでも、各パッケージの設定と Markuplint が正しく解決されます。

たとえば appmobile-app を対象にするなら、次のように指定します。

{
  "markuplint.workingDirectories": ["app", "mobile-app"]
}

プレリリースの段階なので今後変わる可能性はありますが、モノレポでエディタ連携を諦めていた場合は、v5 系を試す価値があります。

参考

Release v5.0.0-rc.4 · markuplint/markuplint

まとめ

ESLint や Stylelint と比べると、Markuplint はまだ知名度が高くありません。

カスタムルールまで書けば、チーム固有の規約を組み込みルールと同じ土俵でチェックすることもできます。

HTML のチェックが手つかずになっているプロジェクトには、十分導入する価値があると思います。

ぜひ、まずは推奨プリセットから手軽に始めてみてください。