「小説家になろう」の小説をVSCodeで書く

## 横書きルビ対応プレビューアが見当たらない
VSCodeの拡張機能は非常に多彩に用意されています。
その多くはMicrosoftの努力とユーザー達の献身によるものですが、やはり、大手様が作るものは機能が多すぎて自分にしっくりくるコンフィグまでたどり着くのに息切れがしますし、ユーザーが作ったものというのはそのユーザーの用途に合わせて作られがちです。当たり前です。

私は「小説家になろう」というサイトでSF小説を執筆しています。以前は音声入力に頼ることもあってGoogle Document で書いていたのですが、とにかく動作が遅く作業が長期化しやすいことと、Emacsキーバインドが使えないことがストレスでした。

なので VSCodeで小説を書き始めたところ比較的しっくり手に馴染みました。ところがしばらくするとちょっと物足りなさを感じてきたのですね。その一つが Preview 機能です。
VSCodeのマーケットには青空文庫のように縦書きのプレビューアはあるのですが、日本語ルビに対応した横書きプレビューアの拡張機能がありません。

しょうがないので自分で作ることにしました。
VSCodeのバージョンは1.100.2 を使用しています。(2025/05/26現在)

## 仕様を決める

機能としてはシンプルです。

- 「小説家になろう」のルビ機能を持たせる
- 傍点機能には対応しない（私が使わないから）
- 文頭に句読点が来なくするなどの禁則処理は今回は行わない
- 横全角37文字で改行する
- ドキュメントフォルダに入っている .txt, .md ファイルをプレビュー対象とする
- プレビュー画面を出した直後はアクティブな .txt または.md ファイルをプレビューする
- フォント、フォントの大きさ、背景などは少ない自由度ながら変えられる
- テキストファイルを編集中もプレビューアは追従する
- 今編集しているところを Preview タブの真ん中の高さに合わせる (2025/05/25 追加)

以上です。



## コード
では、コードに移ります。
まず、適当なディレクトリを掘ります。
```
$ mkdir novel_preview
$ cd novel_preview
```
ここで、２つファイルを作ります。一つは package.json, もう一つは extension.jsです。
この記事を読んでいてVSCodeを使っていない方はいないと思いますのでインストール作業などは割愛します。[ここ](https://code.visualstudio.com/download)から適当にご自分の環境に合ったものをダウンロードして、ぺちぺちキーを叩けばVSCodeはインストールが終わります。 
(リンク確認 2025/05/26)

では、この2つのファイルをコピペでもなんでも良いので novel_preview ディレクトリ内に作って下さい。

```package.json
{
  "name": "novel-preview-minimal",
  "displayName": "Novel Preview (Minimal)",
  "description": "Preview Markdown novels with furigana in VS Code",
  "version": "0.1.0",
  "publisher": "yourname",
  "engines": {
    "vscode": "^1.80.0"
  },
  "activationEvents": [
    "*"
  ],
  "main": "extension.js",
  "contributes": {
    "commands": [
      {
        "command": "novelPreview.start",
        "title": "Novel Preview: Start"
      }
    ]
  }
}
```
versionとかpublisherとかはご自由にどうぞ。
サポートはたぶんしないと思いますので、ChatGPTやGemini, Copilotと相談でもしながらカスタマイズして下さい。

(2025/05/26 改稿)
```extensions.js
// extension.js
const vscode = require('vscode');
const fs     = require('fs');
const path   = require('path');

function activate(context) {
  context.subscriptions.push(
    vscode.commands.registerCommand('novelPreview.start', () => showPreview(context))
  );
}

function deactivate() {}

async function showPreview(context) {
  const ws = vscode.workspace.workspaceFolders;
  if (!ws || !ws.length) {
    vscode.window.showErrorMessage('まずワークスペースフォルダを開いてください');
    return;
  }
  const root = ws[0].uri.fsPath;
  const files = fs.readdirSync(root).filter(f => f.endsWith('.md') || f.endsWith('.txt'));
  if (!files.length) {
    vscode.window.showWarningMessage('.md/.txt ファイルが見つかりません');
  }

  const active = vscode.window.activeTextEditor;
  let initialFile = files[0] || '';
  let initialLine = 0;
  if (active && ['markdown','plaintext'].includes(active.document.languageId)) {
    const name = path.basename(active.document.fileName);
    if (files.includes(name)) {
      initialFile = name;
      initialLine = active.selection.active.line;
    }
  }

  let initialContent = '';
  try {
    const txt = await fs.promises.readFile(path.join(root, initialFile), 'utf8');
    initialContent = convertToHtml(txt);
  } catch (e) {
    console.error('初期ファイル読み込み失敗:', e);
  }

  const panel = vscode.window.createWebviewPanel(
    'novelPreview',
    'Novel Preview',
    vscode.ViewColumn.Beside,
    { enableScripts: true }
  );
  panel.webview.html = getHtml(files, initialContent, initialFile);
  panel.webview.postMessage({ command: 'cursorPos', line: initialLine });

  const disposables = [];

  disposables.push(
    panel.webview.onDidReceiveMessage(async msg => {
      if (msg.command === 'loadFile') {
        try {
          const txt = await fs.promises.readFile(path.join(root, msg.filename), 'utf8');
          panel.webview.postMessage({ command: 'rendered', content: convertToHtml(txt) });
        } catch (err) {
          console.error('ファイル読み込み失敗:', err);
        }
      }
    })
  );

  disposables.push(
    vscode.workspace.onDidSaveTextDocument(doc => {
      if (panel.visible && ['markdown','plaintext'].includes(doc.languageId)) {
        panel.webview.postMessage({ command:'loadFile', filename:path.basename(doc.fileName) });
      }
    })
  );

  disposables.push(
    vscode.workspace.onDidChangeTextDocument(evt => {
      const d = evt.document;
      if (panel.visible && ['markdown','plaintext'].includes(d.languageId)) {
        panel.webview.postMessage({ command:'rendered', content:convertToHtml(d.getText()) });
      }
    })
  );

  disposables.push(
    vscode.window.onDidChangeTextEditorSelection(evt => {
      const ed = evt.textEditor;
      if (panel.visible && ['markdown','plaintext'].includes(ed.document.languageId)) {
        panel.webview.postMessage({ command:'cursorPos', line:evt.selections[0].active.line });
      }
    })
  );

  panel.onDidDispose(() => disposables.forEach(d => d.dispose()), null, context.subscriptions);
}

function convertToHtml(text) {
  return text
    .split(/\r?\n/)
    .map((line, i) => {
      let html = line
        // 1) 文字列(読み) or 文字列（読み）形式のルビ
        .replace(
          /([一-龯々〆ヵヶ]+)[(（]([^()（）]+?)[)）]/g,
          (_, body, ruby) => `<ruby>${body}<rt>${ruby}</rt></ruby>`
        )
        // 2) 半角/全角パイプ｜|＋文字列《読み》形式のルビ
        .replace(
          /[｜|]([^《]+?)《([^》]+?)》/g,
          (_, body, ruby) => `<ruby>${body}<rt>${ruby}</rt></ruby>`
        );

      if (!html.trim()) html = '<br>';
      return `<div class="line" data-line="${i}">${html}</div>`;
    })
    .join('');
}



function getHtml(files, initialContent, initialFile) {
  const opts = files.map(f =>
    `<option value="${f}"${f===initialFile?' selected':''}>${f}</option>`
  ).join('\n');

  return `<!DOCTYPE html>
<html lang="ja"><head><meta charset="UTF-8"><style>
:root{--bg-cream:#fefae0;--bg-white:#ffffff;--bg-dark:#1e1e1e;
      --text-cream:#2c2c2c;--text-white:#000000;--text-dark:#d4d4d4;}
body{margin:0;padding:0;background:var(--bg-cream);}
.toolbar{position:fixed;top:0;left:0;right:0;
  background:rgba(255,255,255,0.9);padding:8px;
  display:flex;gap:1em;align-items:center;border-bottom:1px solid #ccc;z-index:10;}
#content-container{margin-top:48px;padding:1em;
  overflow:auto;height:calc(100vh-48px);padding-bottom:50vh;
  color:var(--text-cream);font-family:var(--font-family,Meiryo,sans-serif);
  font-size:var(--font-size,14pt);}
.line{max-width:37em;margin:0 auto;line-height:1.86em;
  white-space:pre-wrap;word-break:break-word;color:inherit;
  font-family:inherit;font-size:inherit;}
.heading{text-align:center;font-weight:bold;
  font-size:1.142em;margin:1em 0;}
#fileSelect{visibility:hidden;}
</style></head><body>
  <div class="toolbar">
    フォント:<select id="fontSelect">
      <option value="Meiryo, sans-serif" selected>メイリオ</option>
      <option value="'Yu Mincho', serif">明朝</option>
    </select>
    サイズ:<input id="fontSize" type="range" min="10" max="16" value="14">
    背景:<select id="themeSelect">
      <option value="cream" selected>クリーム</option>
      <option value="white">白</option>
      <option value="dark">黒</option>
    </select>
    📂 ファイル:<select id="fileSelect">${opts}</select>
  </div>
  <div id="content-container" style="
    --font-family:Meiryo,sans-serif;--font-size:14pt;">
    ${initialContent}
  </div>
  <script>
    const vscode   = acquireVsCodeApi();
    const ctn      = document.getElementById('content-container');
    const fileEl   = document.getElementById('fileSelect');
    const fontEl   = document.getElementById('fontSelect');
    const sizeEl   = document.getElementById('fontSize');
    const themeEl  = document.getElementById('themeSelect');

    themeEl.addEventListener('change', e=>{
      const t = {cream:{bg:'var(--bg-cream)', text:'var(--text-cream)'},
                 white:{bg:'var(--bg-white)', text:'var(--text-white)'},
                 dark:{bg:'var(--bg-dark)', text:'var(--text-dark)'}}[e.target.value];
      document.body.style.background = t.bg;
      ctn.style.color = t.text;
    });

    fontEl.addEventListener('change', e=>
      ctn.style.setProperty('--font-family', e.target.value)
    );
    sizeEl.addEventListener('input', e=>
      ctn.style.setProperty('--font-size', e.target.value+'pt')
    );
    fileEl.addEventListener('change',e=>
      vscode.postMessage({command:'loadFile',filename:e.target.value})
    );

    window.addEventListener('message', ev=>{
      const m = ev.data;
      if(m.command==='rendered'){
        ctn.innerHTML = m.content;
        fileEl.style.visibility='visible';
      }
      if(m.command==='cursorPos'){
        const el = ctn.querySelector(\`.line[data-line="\${m.line}"]\`);
        if(el) el.scrollIntoView({block:'center'});
      }
    });

    fileEl.style.visibility = 'visible';
  </script>
</body></html>`;
}

module.exports = { activate, deactivate };

```
## Node.js と npm のインストール
この拡張機能は制作するのに Node.js とnpmが必要です。Qiitaの中だと[こちら]( https://qiita.com/mk185/items/7ad004bf202f400daea1 )のサイトなどを参考にインストールして下さい。私はWSL2環境でやりましたが、npmが動く環境ならどこでも良いような気がします。WSL2のインストール方法は[こちら]( https://qiita.com/zakoken/items/61141df6aeae9e3f8e36 ) が参考になります。


## 拡張機能の作成
次ですね。
LICENSE.txt はあるとWarningが一つ減るので作っておくと良いと思います。
中身は何でも構いません。
そうするとディレクトリ構造は以下のようになると思います。
```
└─ novel_preview/
   ├─ LICENSE.txt [0.12 KB]
   ├─ extension.js [6.11 KB]
   └─ package.json [0.44 KB]
```
このディレクトリの中で以下のコマンドを打ちます。

```bash
$ npm install -g @vscode/vsce
$ vsce package
```
@vscode/vsce が VSCode拡張機能作成用のあれやこれやをやってくれるスクリプトです。
上のpackage.jsonのバージョンを変えなければ novel-preview-minimal-0.1.0.vsix というファイルが出来上がるはずです。「これこれの記述はないけど大丈夫か？」みたいなWARNINGはでますが、プロトタイプなので全部 y を押してしまいましょう。
```
 WARNING  A 'repository' field is missing from the 'package.json' manifest file.
Use --allow-missing-repository to bypass.
Do you want to continue? [y/N] y
 WARNING  Using '*' activation is usually a bad idea as it impacts performance.
More info: https://code.visualstudio.com/api/references/activation-events#Start-up
Use --allow-star-activation to bypass.
Do you want to continue? [y/N] y
 WARNING  Neither a .vscodeignore file nor a "files" property in package.json was found. To ensure only necessary files are included in your extension, add a .vscodeignore file or specify the "files" property in package.json. More info: https://aka.ms/vscode-vscodeignore
```
## 拡張機能のインストール
novel-preview-minimal-0.1.0.vsix が出来上がったら、VSCodeを起動し、左側の柱にある拡張機能のアイコンをクリックしましょう。拡張機能のメニューがでてきます。
拡張機能、と書かれた上端に🔃と「…」があると思いますので「…」を押下しましょう。
プルダウンメニューのかなり下の方に「VSIXからのインストール」というのが見つかると思います。
そこを選択し、先程の novel-preview-minimal-0.1.0.vsix を選択、インストールします。
![extension.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87287/db5bd415-0485-481d-90b1-2ccfbf22e51d.png)


## プレビュー画面の出し方
まず、VSCodeの「ファイル」メニューから「フォルダーを開く」を選択し、 .txt や .md など、あなたが小説を書き溜めているディレクトリを開きます。どれか一つ、テキストファイルを開いてみて下さい。
次に F1キーを押します。
VSCodeの画面上部中央にサブウィンドウが開き、">" というプロンプトへの入力が求められます。下にはその入力候補がずらーっと並んでいると思いますが Novel Preview: Start を選択して実行して下さい。
![F1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87287/245685ad-a2ef-447a-a40a-5053437a210a.png)

開いていたテキストファイルの横にプレビュー画面が開くと思います。


## おわり
以上でVSCode用「小説家になろう」プレビュー拡張機能（不完全版）の作成記事は終わりです。今後気力があれば禁則処理や傍点機能などにも対応したいですが、実際私は「なろう」サイトの禁則処理や拡張書式がどれだけあるか知らないのです。
こんなんで良かったら使ってやって下さい。

(2025/05/26)