esa.ioのエディタにリンター機能を付加する実験の記録

この投稿は、esa.ioのエディタをハックして、リンター機能を付与できないかを検証した記録です。

esa.ioはチーム向けドキュメント共有サービスで、Markdownでドキュメントを書けることからQiitaと使い勝手が似ているウェブサービスです。

## リンター機能とは

リンターはプログラムコードの良くない書き方を指摘してくれるツールです。たとえば、JavaScriptならeslintが有名です。

Markdownを対象としたリンターもあります。markdownlintやremark-lintがそれです。これらを使うと、Markdownの書き方で良くないところを指摘してもらえます。

日本語の校正を目的とした、textlintというのもあります。これは日本語の表現で直すべきところを指摘してくれるツールです。

## なぜリンター機能を付与したいか

複数人でドキュメントを書いていると、書き方で気になるところが出てきます。たとえば、ドキュメント同士のリンクの書き方です。Markdownでサイト内リンクのURLを指定する方法として、`https://`から書き始める方法と、ドメイン部分を省略して絶対パスで書く方法があります。esaでは絶対パスで書くほうが望ましいです。ドメイン名はいつでも変更できるので、もしドメイン名が変わるとリンク切れになります。

```markdown
○ [リンク](/posts/123)
✕ [リンク](https://example.esa.io/posts/123)
```

他にも、リスト記法を`-`と`*`どちらで統一するとか、インデントは4スペースにするとか、細かい統一感が出したかったです。そのために以前、[esa Webhookを使ってMarkdownを整形するツール](https://github.com/suin/esa-hazukuroi)を作ってみたりもしました。これはこれで便利なのですが、書いているときにフィードバックが無いのが課題でした。

そこで、esaエディタにリンター機能をつけられたらいいなと思いました。

## esaエディタの仕様

esaのエディタはCode Mirrorというオープンソースのエディタをベースに作られています。リンター機能をつけるには、Code Mirrorをいじる必要があります。

## esaエディタインスタンスのありか

Code Mirrorにふれるには、Code Mirrorのオブジェクトを取り出さないとなりません。これをするには、次のようなコードで`.CodeMirror`要素から`CodeMirror`プロパティを参照するだけです。

```js
const editor = document.querySelector(".CodeMirror").CodeMirror
```

## Code Mirrorのリンター機能

Code Mirrorには[lint.js](https://codemirror.net/addon/lint/lint.js)というリンター機能のフレームワークになるアドオンがあります。これを利用するのが、esaエディタにリンターをつける近道です。

## esaエディタにリンターをつける方法

lint.jsをesaの投稿ページにロードしてやれば、基本的にリンター機能のインストールができるのですが、lint.jsはグローバルオブジェクトに`CodeMirror`プロパティを必要としているので、esaエディタの`CodeMirror`オブジェクトをグローバル変数化してやる必要があります。注意点なのですが、lint.jsが依存する`CodeMirror`オブジェクトはエディタのインスタンスではなく、コンストラクタです。なので、上で取り出した`editor`をグローバル変数化しても意味がありません。esaの`CodeMirror`コンストラクタはスコープが閉じたところにあるので直接アクセスできません。そこで、`editor`変数からコンストラクタを取り出す方法で対処します。

```js
window.CodeMirror = editor.constructor;
```

これでlint.jsをロードするための条件がととのいます。あとは、次のファイルを動的にロードしてやればOKです。

- https://codemirror.net/addon/lint/lint.js
- https://codemirror.net/addon/lint/lint.css

最後に、エディタインスタンス`editor`にリンターを追加するとリンターが動くようになります。

```js
function lint(text) {
  return [
    {
      severity: "error",
      from: { line: 0, ch: 11 },
      to: { line: 0, ch: 40 },
      message: "リンクにはドメインを含めないようにしましょう。例: /posts/123",
    },
  ];
}

// setup gutter
editor.setOption("gutters", ["CodeMirror-lint-markers"]);
document.querySelector(".CodeMirror-lint-markers").style.width = 0;

// load addon
await loadJavaScript("https://codemirror.net/addon/lint/lint.js");
await loadStylesheet("https://codemirror.net/addon/lint/lint.css");

// enable lint feature
editor.setOption("lint", lint);
```

esa.ioのエディタにリンター機能を付加する技術的な解説は以上です。

## 完成版のコード

ここまでの手順をコードにしたものが次になります。これをGoogle ChromeのDevToolのコンソールで実行すると、1行目の11文字目〜40文字目に警告が表示されます。

![20211110_121652.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/889/8232364a-39bd-0130-43ac-d1066a43c78c.png)


```js
main();

async function main() {
  const editor = discoverCodeMirrorInstance();
  globalifyCodeMirrorClass(editor);
  await enableLinter(editor, lint);
}

function lint(text) {
  return [
    {
      severity: "error",
      from: { line: 0, ch: 11 },
      to: { line: 0, ch: 40 },
      message: "リンクにはドメインを含めないようにしましょう。例: /posts/123",
    },
  ];
}

function discoverCodeMirrorInstance() {
  return document.querySelector(".CodeMirror").CodeMirror;
}

function globalifyCodeMirrorClass(editorInstance) {
  window.CodeMirror = editorInstance.constructor;
}

async function enableLinter(editor, lint) {
  // setup gutter
  editor.setOption("gutters", ["CodeMirror-lint-markers"]);
  document.querySelector(".CodeMirror-lint-markers").style.width = 0;

  // load addon
  await loadJavaScript("https://codemirror.net/addon/lint/lint.js");
  await loadStylesheet("https://codemirror.net/addon/lint/lint.css");

  // enable lint feature
  editor.setOption("lint", lint);
}

function loadJavaScript(url) {
  return new Promise((resolve) => {
    const script = document.createElement("script");
    script.src = url;
    script.addEventListener("load", resolve);
    document.head.appendChild(script);
  });
}

function loadStylesheet(url) {
  return new Promise((resolve) => {
    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.type = "text/css";
    link.href = url;
    link.addEventListener("load", resolve);
    document.head.appendChild(link);
  });
}
```

このコードをベースにGoogle Chromeの拡張を作ったりしたら、もっと便利になるかと思います。