(Neo)VimでAsciiDoc(+PlantUML埋め込み)をDenops+Honoを使ってリアルタイムプレビューする

2023.08.14

はじめに

AsciiDocは、軽量なマークアップ言語の1つで、標準のマークダウンより表現力が高く便利です。GitHubのマークダウンがMermaidを対応したこともあり、軽いシーケンス図はGitHub上で書けるので最近あまり使っていませんでした。

ただPlantUMLの表現力の高さが必要になり、埋め込みが可能なAsciiDocを利用することになりました。2020年頃NeovimでPlantumlをプレビューするプラグインを作っていたのですが、メンテしきれず現代のやりやすい技術構成で作り直しました。

プラグイン開発初心者向けの記事なので、その点ご了承頂けると幸いです。実装もかなり薄いので逆にこれからトライしたい方には、読みやすいとは思います。

モチベーション

AsciiDocと埋め込まれたPlantUMLのプレビューをする必要がありました。プレビューツールの中には、PlantUMLの内容をplantuml.comへ送信するケースがありますが、セキュアではないため使えません。asciidoctor-diagramを利用した場合、plantuml.jarが自動でインストールされ、ローカルで完結したプレビューが可能です。

構成

リポジトリは、shuntaka9576/preview-asciidoc.vimです。

要素 昔版(2020年) 今回版
プラグイン機構 リモートプラグイン(MessagePack-RPC) Denops
言語 Node.js Deno
Webフレームワーク Node.jsのhttpモジュール Hono

仕組み

概要図

preview-asciidoc

動作概要

demo

実装関連

AsciiDocビルド関連

Vimのバッファの内容をパイプで渡して、asciidoctorにstdinで渡しています。引数で指定できればよかったのですが、オプションがなかったので断念しました。Denoでパイプを繋げたコマンドを実行する場合、少し癖がありました。

該当箇所

プラグイン内部で実行するコマンド

echo 'バッファの内容' |\
asciidoctor \
-a imagesdir@=images \
-a imagesoutdir=${config.imagesDir} \
-r asciidoctor-diagram \
-o - -;

denoでパイプ付きコマンドを実行する方法

const getDefaultShell = (): string => {
  let shell = "bash";
  const envShell = Deno.env.get("SHELL");

  if (envShell != null) {
    shell = envShell;
  }

  return shell;
};

const shell = getDefaultShell();
const p = Deno.run({
  cmd: ,
  stdout: "piped",
  stdin: "piped", // stdinをpipedにしないと、p.stdinがundefined属性になる(そもそも標準入力を許可しないことになり、受け取れないので、設定は必ずする)
});

const encoder = new TextEncoder();
const decoder = new TextDecoder();

const command = "echo '=== aaa' | asciidoctor -r asciidoctor-diagram -o - -";

await p.stdin.write(encoder.encode(command));
p.stdin.close(); // 標準入力を解放したら閉じないと、永遠に入力待ちでブロックした状態になる

const output = await p.output();
p.close();

console.log(decoder.decode(output));

パスルーティング

該当箇所

PlantUMLの画像はプラグインのパス/imagesに保存されます。localhost:8064/images/*プラグインのパス/imagesをマッピングする必要があります。

const initializeWebSocketServer = async (config: Config) => {
  let port = 8064;
  const hostname = "localhost";
  const app = new Hono();

  app.get("/ws", (c: any) => {
    const { response, socket } = Deno.upgradeWebSocket(c.req);

    socket.onopen = () => {};
    socket.onclose = () => {};
    socket.onmessage = (_message) => {};

    sockets.add(socket);

    return response;
  });

  app.get("/", (c) => {
    return c.html(template({ port: port }));
  });

  app.get("/images/*", async (c) => {
    const url = new URL(c.req.url);
    const path = join(config.pluginPath, url.pathname);

    const content = await Deno.readFile(path);
    const mimeType = getMimeType(path);
    if (mimeType) {
      c.header("Content-Type", mimeType);
    }

    return c.body(content);
  });

  for (let retry = 0; retry < 3; retry++) {
    try {
      const server = new Server({
        port: port,
        hostname: hostname,
        handler: app.fetch,
      });

      Console.log(`preview port: ${port}`);
      await open(`http://${hostname}:${port}`);

      return await server.listenAndServe();
    } catch (e) {
      if (e.code === "EADDRINUSE") {
        Console.log(`already in use ${port} port. retry ${retry}`);
        port += 1;
        continue;
      } else {
        throw e;
      }
    }
  }
};

最後に

WebSocketとの連携も、パスルーティングもHonoでサクッと書けてよかったです!この使用感があらゆるランタイムやクラウド上で動作するのは便利ですね!Denopsも分かりやすく、denoもライブラリが増えてきたこともあり快適に開発できました。npmモジュールも使えるので幅が広がりますね。

ツール自体は個人でメンテしているので、問題があればリポジトリissueにお願いします。