📚

マークダウン執筆で画像挿入が面倒だったのでNeovimプラグインを開発した話

に公開

not-by-ai

はじめに、多くのVimmerZennや個人ブログの記事を執筆するときもVimで執筆すると思います。もちろん手慣れたテキストエディターなのでサクサク記事を書けますが、ただ唯一の欠点と言っていいほどに画像の挿入の面倒臭さに悩まされました。

従来の画像挿入、例えばZennの記事を執筆する際の画像挿入をワークフローをあげると、私は以下の工程で画像の追加をしていました。

1. ファイルマネージャーで画像ディレクトリを開く
2. 画像ディレクトリにインターネットからダウンロードしていた画像を任意の名前で保存する
3. Neovimで画像要素を手打ちする

この通りに三工程あって画像を挿入するだけのことに一苦労です。今回はこのワークフローをNeovimから離れずにコマンド一つで行えるようにしたプラグインを開発しました。

mdxsnap.nvim

mdxsnap.nvimはクリップボードに保存された画像をマークダウンもしくは、MDX形式の文書にコピペするプラグインです。

注目すべき特徴は主にこれら5つが挙げられます。

  • 覚えるコマンドは一つだけ :PasteImage [filename]
  • プロジェクト単位でのプロファイル分け (ProjectOverrides)
  • 画像の保存場所に任意のディレクトリパスを使用可能 (PastePath, DefaultPastePath)
  • Markdownの![]()画像形式構文を任意の形式に置き換える (customTextFormat)
  • MDXで画像用のコンポーネントや関数が不足している際に自動インポート (customImports)

これらの機能によって、様々なディレクトリ構造を取るCMSやブログサービスに対応しています。

https://github.com/HidemaruOwO/mdxsnap.nvim

インストール

通常のインストール方法でインストールできます。

  • Example for lazy.nvim
{
  'HidemaruOwO/mdxsnap.nvim',
  config = function()
    require('mdxsnap').setup()
  end
}
  • Example for jetpack.vim (packer style)
{
	"HidemaruOwO/mdxsnap.nvim",
	cmd = "PasteImage",
	ft = { "markdown", "mdx" },
	config = function()
        require('mdxsnap').setup()
	end,
},

依存関係についてOS標準のクリップボードツールが必要なためインストールされていない場合はインストールもしくは、OSの更新を必要としてます。

  • Windows: Get-Clipboard
  • macOS: pbpaste, osascript, sips
  • Linux (X11): xclip
  • Linux (Wayland): wl-paste

使い方

前述の通りmdxsnap.nvimは覚えるコマンドは一つだけでシンプルに設計されています。

" 引数なしで実行できて、この場合はクリップボードの画像をランダムな文字列で保存します
:PasteImage

" クリップボードの画像を引数で指定したファイル名で保存します。人気があるのはこちらでしょう
:PasteImage [filename]

動作デモ

  • マークダウン

以前は面倒な手順を3ステップ踏まないと画像挿入がご覧の通り、たった1コマンドで瞬時に貼り付けられます。我ながらチョー便利に感じられました。

マークダウンのスナップ

マークダウンのスナップ

  • MDX

実装の際工夫した機能として、:PasteImageコマンドを実行したのちに、ひょこっとmdxドキュメントの上部にてastro:assetsmdxsnapとプロジェクトのディレクトリの互換レイヤーをインポートしているのをご覧になったと思います。また、考えられるバグの一つは重複インポートで、こちらについてはcheckRegexで対策しています。

MDXのスナップ

MDXのスナップ

設定

mdxsnap.nvimは設定なしで動作しますが、ユーザーが任意のCMS向けの設定をする前提で設計したため使用体験はとても悪くやはり設定するべきです。

設定のサンプル
require("mdxsnap").setup({
  -- 画像保存のデフォルトパス
  DefaultPastePath = "snaps", -- デフォルト: "snaps/images/posts"
  DefaultPastePathType = "relative",       -- デフォルト: "relative" ("absolute"も指定可能)
  -- ファイル内に存在させるグローバルなカスタムインポート文
  customImports = {
    {
      line = 'import { Image } from "astro:assets";', -- 完全なインポート行
      checkRegex = 'astro:assets',                   -- 既存インポートをチェックする文字列/正規表現
    },
  },
  -- 挿入される画像参照テキストのグローバル形式
  customTextFormat = "![%s](%s)", -- デフォルト: Markdown画像形式 "![alt](src)"
  -- 特定プロジェクト用のデフォルト設定オーバーライド
  ProjectOverrides = {
    {
      -- プロジェクトディレクトリ名でマッチ
      matchType = "projectName",             -- "projectName" または "projectPath"
      matchValue = "my-astro-blog",        -- プロジェクトルートディレクトリ名
      PastePath = "src/assets/blog-images", -- このプロジェクト用カスタムパス
      PastePathType = "relative",
      customImports = { -- このプロジェクト用グローバルcustomImportsをオーバーライド
        { line = 'import { BlogImage } from "@/components/BlogImage.astro";', checkRegex = "@/components/BlogImage.astro" },
      },
      customTextFormat = '<BlogImage alt="%s" src="%s" />', -- グローバルcustomTextFormatをオーバーライド
    },
       -- 必要に応じてさらにルールを追加
  },
})

重要な設定としてcustomTextFormatcustomImportsが挙げられます。これらが他のプラグインにはないmdxsnapの特徴と言えます。

  • customTextFormat: Markdownの![]()画像形式構文を任意の形式に置き換えます。

これによってmdxsnap:PasteImageコマンド実行時に出力するマークダウンの出力を変更することができます。

たとえば、<img>タグで画像を読み込んだり、独自に実装されたコンポーネントで画像を読み込むことができます。
この際に代入詞の%salt -> srcの順番で書いてください。(mdxsnapの次のアップデートでは{{alt}}, {{src}}のようなディレクティブで書けるようにするべきですね・・・)

<img alt="%s" src="%s" />

<BlogImage alt="%s" src="%s" />
  • customImports: MDXで画像用のコンポーネントや関数が不足している際に自動インポートします。

    • line: インポートする全文です。
    • checkRegex: インポートの重複対策として既存のインポートを検出するために必要なフレーズです。

最後に覚えておく設定はProjectOverridesです。この機能について平たくいえばプロファイル機能で、プロジェクトに順応したcustomImportsなどの設定を行えます。

  • ProjectOverrides: プロジェクト単位でcustomImportscustomTextFormatPastePathなどの変更を可能にします。

    • matchType: プロジェクトを上書きする際にプロジェクトを判定するロジックを選択します。"projectName"もしくは"projectPath"が代入可能です。

      • "projectName": (推奨) プロジェクトルートのディレクトリ名で判断します。ここがプロジェクトルートかの判断は.git.svnなどのディレクトリの存在を基準に判断します。
      • "projectPath": プロジェクトの絶対パスを指定して設定を上書きします。gitなどのDev-Opsを使用してないプロジェクトでの利用などを考慮しています。
    • matchValue: ここにはmatchTypeに対応した任意の値が代入されます。matchType"projectName"の場合はプロジェクトルートのディレクトリ名 (portfolio, dotfiles, zenn-articlesなど)。"projectPath"の場合はプロジェクトがあるディレクトリの絶対パスを代入します。

    • PastePath: 記事に使用する画像ファイルの保存場所を決める設定項目です。

PastePathで指定したディレクトリの下に、以下の構造で画像が保存されます:

プロジェクトルート/
└── [PastePathで指定したディレクトリ]/
    └── [マークダウンファイル名]/
        └── [画像ファイル].png
  • PastePathType: 記事の画像ファイルの保存先を決めるPastePathのパスを絶対パスか相対パスか決めます。代入する値は"relative""absolute"です。

    • "relative": (推奨) 画像の保存先をプロジェクトルートからの相対パスに指定します。
    • "absolute": 画像の保存先を絶対パスに指定します。

これらはmdxsnap.nvimの他のCMSなどへの対応といった柔軟性に貢献している機能です。

Zennでmdxsnapをつかう

mdxsnapは仕組み上Zennの記事を書くことにも貢献できて、現にこの記事の画像挿入はmdxsnapで行われています。

Zenn CLI向けのサンプルは以下の通りです。ProjectOverridesにこの記述を追記したのちに、matchValue"zenn-articles"という値をご自身のリポジトリ名にご変更ください。

{
    matchType = "projectName",
    matchValue = "zenn-articles", -- この項目をあなたのリポジトリ名に置き換えてください。
    -- :%s/zenn-articles/YOUR_REPO_NAME/g
    PastePath = "images",
    PastePathType = "relative",
}, -- ProjectOverrides に追加
  • Directory Structure
.
├── articles
│   └── introduce-mdxsnapnvim.md
├── books
├── images
│   └── introduce-mdxsnapnvim
│       ├── github.png
│       ├── neovim.png
│       ├── not-by-ai.png
│       └── zenn-setting.png
├── README.md
├── bun.lock
└── package.json

今後の課題

このプラグインはほぼ一日で完成させたものなので、どうしても粗い実装などが見つかります。v1.1.0にリリースにあたって修正項目などをリストアップします。

  • customImportsの代入詞

customImportsではaltテキストおよびsrc画像ファイルパスの入力に%sを仮置きの代入詞として使用していますが、あまりヒューマンライクな使用ではないため{{alt}}, {{src}}のような代入詞を実装する必要があります。

  • ファイルコピーペーストの効率的な実装

現在mdxsnapがコピペする際の実装の一部として、クリップボード内の画像をプラグインに受け渡しする目的とクリップボード内のファイルをリネームするために任意のOSのコマンドで~/.local/share/nvim/mdxsnap_tmpに画像を作成したのちにluaの標準の機能でファイルコピーなどを行うため、あまり良い実装とは呼べません。vim.apiもしくはlua標準の機能で外部依存を減らした実装にシフトチェンジしたいですね。OSのクリップボードツールなどを極力呼び出さずにvim.apiのクリップボード関連のユーティリティなどがありましたらそちらで直接データを受け渡しできる構造に実装したいですね。(一工夫すればtempファイル作らなくても受け渡しできそう)

  • macOS環境でgif画像を挿入できない

macOSでのクリップボード周りの実装は、クリップボード内のデータをそのまま出力する方法が分からなかったので、osascriptpng形式で出力して受け渡ししてしまっているため、gif画像を貼り付けると静止画になってしまいます。私のリサーチ不足でこのような実装になってしまったので悔やむ限りです。

  • そもそもコードが汚い

はじめて作ったNeovimプラグインにしてもコードがとても汚いです。手っ取り早く作りたかったためVibeCodingをしましたが、ノウハウなどがないためLLMに対して満足にカウンセリングもできずご覧の次第です。これからじっくりコードリーディングを行って私主体で改修を進めたいと考えています。

また、mdxsnapを使用していてバグ発見、もしくは「こんな機能が欲しいな」「こうしたらいいじゃないの」のような提案などございましたらGitHubissueページで気軽にご連絡ください。 ここまで記事をご覧いただきありがとうございました!

こちらのプラグインを気に入られましたら是非ともスターをお付けください ⭐️

https://github.com/HidemaruOwO/mdxsnap.nvim

GitHubで編集を提案

Discussion