Visual Studio Codeの拡張機能を自作して、理想の執筆環境を手に入れた 〆(ﾟ_ﾟ*)

貴方の好きな言語は何ですか？
私の手にはScalaがよく馴染みますが、生まれてこの方やはり日本語が大好きです。

貴方の好きなテキストエディタは何ですか？
私は長らくGinnieと共に生きてきましたが、最近Visual Studio Codeに移行しました。

好きな言語を、好きなエディタで書くことは、健康で文化的な生活ですね？
どうせならもっと自分好みにカスタマイズしたおすのがエンジニアですね？
Visual Studio Codeでは、とても簡単に拡張機能を作ることができますね？

ということで、投稿中のネット小説『[深層の令妹 ζ(*ﾟｗﾟ)ζ](https://github.com/piyo7/deep-sister)』専用の拡張機能をいくつか作ってみたので、その紹介です。
Visual Studio CodeはElectronベースなので、拡張機能の中身はJavaScriptです。話の流れ的にScala.jsで実装できると格好良かったのですが、Microsoftのチュートリアル通りTypeScriptで実装しました！

# アイコン表示

『[深層の令妹 ζ(*ﾟｗﾟ)ζ](https://github.com/piyo7/deep-sister)』は、ほぼ地の文無しの会話小説です。
小説において地の文による描写を封じるのは相当キツい縛りではあるのですが、個人的にリミテッドな創作が向いているのと、いつかチャットボットの学習データに転用したい夢があり、そのようなスタイルを採用しています。
そんなわけで、台本のように台詞の話者を明示できると、何かと便利そうです。すなわち、こうです。

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/798cb06c-9814-311d-4e19-f70deef73c4a.png)

行頭の「01」「07」が、続く台詞を発しているキャラのIDです。そのIDに紐付くアイコン画像を、左側に表示しています。「兄」は主人公のことを、「井」は井内ほおずきというキャラのことを指すアイコン画像で、この表示はオフにすることもできます。
なぜ行頭に直接「兄」「井」と書かないのかというと、後述の＜投稿用整形＞によって投稿時には削除される情報だからです。執筆の際は、台詞の話者が誰なのか読者に自然と分かってもらえるよう工夫する必要があるのですが、話者の表示に見慣れているとその配慮を忘れてしまいがちです。そのため、＜アイコン表示＞は普段はオフにして、さらにキャラIDもできるだけ薄く表示することで、それを防止しています。

さて、この機能は便利なのかというと、実際のところ＜アイコン表示＞はキャラIDを付け間違えてないかどうかのチェックくらいにしか使っていません。しかし、キャラIDはよく検索キーワードとして使っています。

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/37d2926c-c12a-8a32-ed8a-6beb890654b8.png)

久々に登場するキャラの口調を忘れてしまっても、これですぐに思いだせるというわけです……！
実装はこんな感じ :muscle: 

```ts
const decorationIconsColor = vscode.window.createTextEditorDecorationType({
    'color': '#c0c0c0'
})

let decorationIconsDict: { [key: string]: TextEditorDecorationType } = {}

function decorateIcons(editor: TextEditor) {
    let match: RegExpExecArray = null
    let iconLineDict: { [key: string]: number[] } = {}
    let iconLines: number[] = []
    const iconRegex = /^(\d\d)[「『]/gm
    while (match = iconRegex.exec(editor.document.getText())) {
        const icon = match[1]
        if (iconLineDict[icon] == null) iconLineDict[icon] = []
        iconLineDict[icon].push(editor.document.positionAt(match.index).line)
        iconLines.push(editor.document.positionAt(match.index).line)
    }

    editor.setDecorations(
        decorationIconsColor,
        iconLines.map(line => new vscode.Range(line, 0, line, 2)))

    for (const key in decorationIconsDict) {
        editor.setDecorations(decorationIconsDict[key], [])
    }
    if (iconFlag) {
        for (const key in iconLineDict) {
            if (decorationIconsDict[key] == null) {
                decorationIconsDict[key] = vscode.window.createTextEditorDecorationType({
                    'gutterIconPath': iconPath(key),
                    'gutterIconSize': 'contain'
                })
            }

            editor.setDecorations(
                decorationIconsDict[key],
                iconLineDict[key].map(line => new vscode.Range(line, 0, line, 0)))
        }
    }
}

function iconPath(name: string): string {
    return path.join(vscode.workspace.rootPath, '.vscode', 'icon', name + '.png')
}
```

# 不正タイトル検知

『[深層の令妹 ζ(*ﾟｗﾟ)ζ](https://github.com/piyo7/deep-sister)』のエピソードタイトルは、その本文中の特徴的な台詞を抜粋しています。『SHIROBAKO』第4話「私ゃ失敗こいちまってさ」みたいなスタイルですね。
タイトル決めがコピペで済むため楽なのですが、推敲の際にうっかり本文だけ加筆修正してしまい、タイトルと食い違ってしまうことがあります。たとえば、次みたいな感じ。

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/15698ef9-fd34-c571-91ec-1b2343bc0292.png)

これは、「姉キャラより妹キャラの方が人気出るだろ。常識的に考えて」という友人のレビューを受けて、主人公の姉が妹に設定変更されてしまった時の様子です。本文中の「姉」という単語を「妹」に修正したため、Gitの差分が出ていることが青線の表示から分かります。
しかし、お姉ちゃんの亡霊がタイトルに残ってしまっていますね。このような状況にすぐ気付けるよう、不正なタイトルを赤塗りすることにしてみました。

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/8b4fa057-58e0-e27f-1034-553b4a6e26dd.png)

これで無事、ボツキャラと化したお姉ちゃんを見過ごさず、祓うことができますね……！
実装はこんな感じ :muscle: 

```ts
const decorationInvalidTitleColor = vscode.window.createTextEditorDecorationType({
    'backgroundColor': '#FF8080'
})

function decorateInvalidTitle(editor: TextEditor) {
    let titleSpeech: [string, number] = null
    let lineNumbers: number[] = []
    for (let i = 0; i < editor.document.lineCount; i++) {
        const line = editor.document.lineAt(i)
        if (line.text.length > 0) {
            if (line.text[0] == '#') {
                if (titleSpeech != null) {
                    lineNumbers.push(titleSpeech[1])
                }

                const match = /[「『](.+)[」』]/.exec(line.text)
                if (match != null) {
                    titleSpeech = [match[1], line.lineNumber]
                } else {
                    titleSpeech = null
                }
            } else {
                if (titleSpeech != null && line.text.includes(titleSpeech[0])) {
                    titleSpeech = null
                }
            }
        }
    }
    if (titleSpeech != null) {
        lineNumbers.push(titleSpeech[1])
    }

    editor.setDecorations(
        decorationInvalidTitleColor,
        lineNumbers.map(line => new vscode.Range(line, 0, line + 1, 0)))
}
```

# ルビ振り

青空文庫、カクヨム、小説家になろう、といった小説投稿サイトにはルビ記法があります。微妙に差異はありますが、 ```｜約束された勝利の剣《エクスカリバー》``` のように書けば、どこでも期待通りのルビを振ることができるでしょう。
これまでのスクリーンショットでも出てきていますね。

『[深層の令妹 ζ(*ﾟｗﾟ)ζ](https://github.com/piyo7/deep-sister)』では今のところ、作中独自の人名や地名などを対象として、ページごとに一回だけルビを振ることにしています。それだけのルールでも、人力でやると結構ミスるので、JSONの辞書を用意して、

```json
{
  "譚丁": "たんてい",
  "風蛇": "かざへび",
  "奉双譜": "ほうそうふ",
  "名塚": "なつか",
  "令": "れい",
  "有栖川": "ありすがわ",
  "和紀": "かずき",
```

ルビ振っていない素のテキストに、＜ルビ振り＞コマンドを一発打てば、

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/92735b9e-2c75-dab6-9c8c-5f5b5ad83937.png)

とルビが振られるようにしています。また、ルビも薄く表示するようにしています。
これに後述の＜投稿用整形＞コマンドを打って、たとえばカクヨムに投稿すると、

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/ac82316d-af88-9575-7cb4-bdffb89f5ed7.png)

ちゃんとルビ表示されていますね。
ただし、ルビ振りはシンプルな正規表現で行っているため、たとえば一般的な単語に人名が引っかかってしまい「命｜令《れい》」のようになってしまうことがあります。そのため残念ながら、人力チェックは必要です。

実装はこんな感じ :muscle:

```ts
function insertRuby() {
    const rubyDict: { [key: string]: string } = JSON.parse(
        fs.readFileSync(path.join(vscode.workspace.rootPath, '.vscode', 'ruby.json'), 'utf8'))

    const editor = vscode.window.activeTextEditor
    editor.edit((edit) => {
        let text = editor.document.getText().replace(/｜(.+?)《.+?》/g, '$1')
        let sections: string[] = []
        for (let section of text.split('#')) {
            for (const key in rubyDict) {
                section = section.replace(new RegExp(key), '｜' + key + '《' + rubyDict[key] + '》')
            }
            sections.push(section)
        }
        edit.replace(new Range(0, 0, editor.document.lineCount + 1, 0), sections.join("#"))
    })
}

const decorationRubyColor = vscode.window.createTextEditorDecorationType({
    'color': '#808080'
})

function decorateRuby(editor: TextEditor) {
    let ranges: Range[] = []
    let match: RegExpExecArray
    const rubyRegex = /｜(.+?)(《.+?》)/g
    while (match = rubyRegex.exec(editor.document.getText())) {
        const pos = editor.document.positionAt(match.index)
        ranges.push(
            new Range(pos.line, pos.character, pos.line, pos.character),
            new Range(
                pos.line, pos.character + 1 + match[1].length,
                pos.line, pos.character + 1 + match[1].length + match[2].length))
    }

    editor.setDecorations(decorationRubyColor, ranges)
}
```


# 投稿用整形

これまで見てきたとおり、エディタ上では次のように執筆しているわけですが、

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/07bd38ab-a611-b24e-64c6-d185081d9441.png)

投稿時には＜キャラアイコン表示＞のキャラIDを削除したり、改行を増やしたりしています。これも＜投稿用整形＞コマンドを一発打てば、

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/2b922cbb-f157-c0a1-31d9-844c1afac971.png)

と整形されるようにしています。
ただし、改行は演出上のニュアンスもあり、なかなか完全なルール化は難しく、やはり最終的には人力で修正をかけています。

実装はこんな感じ :muscle:

```ts
function publish() {
    const editor = vscode.window.activeTextEditor
    editor.edit((edit) => {
        edit.replace(new Range(0, 0, editor.document.lineCount + 1, 0), editor.document.getText().
            replace(/^\d\d([「『])/gm, '$1').
            replace(/([」』])$/gm, '$1\r\n').
            replace(/\r\n^---$/gm, '　～～～'))
    })
}
```


# 本文文字数カウント

文字数をカウントして、最下部にステータス表示するだけの機能です。

![vscode.png](https://qiita-image-store.s3.amazonaws.com/0/55208/c1011517-a875-03b3-5bce-3af7a24a0c0b.png)

興味があるのは本文の文字数なので、見出し以外の行をカウントしています。また、キャラIDは投稿時に削除するため、非ASCII文字をカウント対象としています。ルビを含めるかどうかは微妙なラインですが、まぁ目安が分かればいいので、カウントしてもいいでしょう。

実装はこんな感じ :muscle:

```ts
function displayWordCount() {
    const document = vscode.window.activeTextEditor.document
    let count = 0
    for (let i = 0; i < document.lineCount; i++) {
        const line = document.lineAt(i).text
        if (line.length > 0 && line[0] != '#') {
            for (let j = 0; j < line.length; j++) {
                if (line.charCodeAt(j) > 127) count++
            }
        }
    }
    vscode.window.setStatusBarMessage(count.toString() + "文字")
}
```

# ふゅーちゃーわーく

かくして5機能ほど実装して運用しているわけですが、執筆＆投稿における面倒な定型作業がおおむね自動化できて、たいへん快適です。
ここまで来たら小説も継続的インテグレーション、手元でバージョン管理に使っているGitをプッシュしたら自動でテスト＆デプロイが走る環境まで作れると最高なのですが、まぁ小説投稿サイトにAPIとかあるはずもないので、そこまでする気力はないかなという感じです。

それよりは、せっかく会話小説なので、いずれLINE風だったりTwitter風の見せ方をしてみたいです。専用のサイトを作る必要があるでしょうけれど、今だったらMastodonのインスタンスを立てちゃうのもいいかもしれませんね。まぁ問題は、個人の小説サイトにアクセスなんてまず来ないという点ですが……。

＜追記＞LINE風なサイトを作りました～。

- [ゆるふわチャット小説をGitHubで構築しましたので╭( ･ㅂ･)و ̑ 〜初めてのフロントエンド編〜](http://qiita.com/piyo7/items/f9217292fd8224f7e9f2)
- [深層の令妹 ζ(*ﾟｗﾟ)ζ　〜ゆるふわチャット小説〜](https://piyo7.github.io/deep-sister/)