AI の出力は、Markdown で返ってくる
2022年の終わりごろから、仕事の進め方が変わりました。調べもの、設計のたたき台、議事録の整形、コードの説明。たいていのものは、AI に投げれば返ってきます。
ただ、最初から一つだけ腑に落ちないことがありました。
画面に出ているのは、見出しも表も整った完成品です。ところが回答の下のコピーボタンを押すと、手元に来るのは Markdown のソースでした。見えているものと、手に入るものが違う。
欲しかったのは、画面に見えている、そのままのものです。
仕方がないので、貼り付けた先で手で直すことになります。Word の報告書、Outlook のメール、Notion、社内チャット — どこに貼っても同じです。見出しに一つずつスタイルを当て直し、崩れた表を組み直し、コードに色を付ける。整形し直している時間のほうが、AI に聞いていた時間より長い日もありました。
最近は、レンダリング済みの状態でコピーできるボタンを用意した AI ツールも出てきています。同じところで引っかかっていた人が、他にもいたということだと思います。ただ今のところ、多くのツールは今でもソースを返してきます。
そして AI 側がいくら速くなっても、出力の行き先が Word である限り、この最後の一メートルは誰かが手で埋めることになります。私が作ったのは、その一メートルを埋めるだけの道具です。
日本の現場では、この一メートルが少し長い
周りを見ていて思うのは、日本のオフィスは Word・Excel・PowerPoint への依存度が高い、ということです。文章と名のつくものは、まず Word で書き始める。同じ Office に入っている OneNote でさえ、あまり使われていないように見えます。
つまり、文章をコードのように書く習慣が、まだ一般的ではない。これは良し悪しの話ではなく、単に前提条件の違いです。ただその前提のもとでは、Markdown で書いたものを届けるコストが、他国より少しだけ高くつきます。
最初は、ただのローカル Web アプリだった
最初に欲しかったのは、手元で動く小さなアプリでした。デスクトップアプリにするとインストーラを作る話になります。いちばん手数が少ないのは Web アプリでした。ブラウザで開けば動く。それだけです。
技術スタックは素の HTML / CSS / JavaScript と markdown-it だけ。フレームワークもバンドラも使っていません。個人で長く面倒を見るものは、依存が少ないほど寿命が延びます。
左に Markdown を書き、右にプレビューが出て、Copy を押すとクリップボードに text/html と text/plain の両方が入る。Word でもメールでもチャットでも、書式を保ったまま貼れる。やっていることはこれだけです。
しばらく自分で使って形が落ち着いたので、サイトとして公開しました。インストールしなくても誰でも使えます。
ところが、客先にはそれすら持ち込めなかった
私は客先に常駐して仕事をしています。そこで困ったのは、モダンなエディタが手元にないことでした。VS Code がない。Markdown をまともに表示できるものがない。
では自分の Web アプリを持ち込もう、と考えて、すぐ行き詰まりました。ローカルで動かすには Node.js が要ります。客先の環境では、そういうものを入れること自体が禁止されています。
そこで考え方を逆にしました。HTML ファイルが1枚あるだけで、ほとんどの機能が動くようにできないか。
ファイルを1つ持ち込んでダブルクリックするだけなら、何もインストールしていません。禁止のしようがありません。
HTML を1枚にまとめる、その具体的なやり方
ここからが実際の作り方です。素朴に見えて、罠がいくつかありました。
1. 依存をすべて node_modules から読んでインラインにする
CDN の URL を埋め込んでも意味がありません。オフラインでは取りに行けないからです。ブラウザ向けのビルド済みファイルを、ビルドスクリプトでそのまま文字列として読み込みます。
const LIBS = [
'node_modules/markdown-it/dist/markdown-it.min.js',
'node_modules/markdown-it-footnote/dist/markdown-it-footnote.min.js',
'node_modules/markdown-it-task-lists/dist/markdown-it-task-lists.min.js',
'node_modules/markdown-it-mark/dist/markdown-it-mark.min.js',
];
そして最後に「リモートの <script src> がゼロになっているか」を必ず確認します。1つでも残っていれば、オフラインでそこだけ静かに壊れます。
2. </script> を壊しておく
インラインにするコードの中に文字列として </script> が入っていると、そこで外側の <script> タグが閉じてしまいます。
// インラインにするコードが外側の <script> を閉じてしまわないように潰す
const safe = (s) => s.replace(/<\/script/gi, '<\\/script');
3. String.replace に文字列を渡さない
これは実際にファイルを壊しました。
String.prototype.replace の第2引数に文字列を渡すと、その中の $& や $1 が置換パターンとして解釈されます。インラインにするのは CSS や JavaScript のかたまりで、その中には正規表現が普通に含まれています。つまり $& が混ざっている。結果、出力されたファイルの一部が静かに書き換わります。
// NG: css の中の "$&" が展開されてしまう
html = html.replace(/<link[^>]*>/, `<style>\n${css}\n</style>`);
// OK: 関数で返せば、中身は一切解釈されない
html = html.replace(/<link[^>]*>/, () => `<style>\n${css}\n</style>`);
エラーにならないので気づきにくい種類のバグです。インライン化するときは、置換は全部関数で書くのが安全です。
4. highlight.js は npm パッケージではなく cdn-assets を使う
客先で扱うのは Markdown だけではありません。PowerShell、YAML、JSON、SQL、設定ファイル。シンタックスハイライトは「あると嬉しい」ではなく、実務では必須でした。
ところが本体の highlight.js パッケージが配っているのは CommonJS と ESM だけで、そのままブラウザには載りません。バンドラを入れる話になります。
同じプロジェクトが @highlightjs/cdn-assets というビルド済みのブラウザ向け配布を出していて、こちらは <script> でそのまま読めます。サードパーティのコードをリポジトリに抱え込まずに済みました。
const HL_DIR = 'node_modules/@highlightjs/cdn-assets';
const HL_CORE = `${HL_DIR}/highlight.min.js`;
// highlight.min.js に約40言語が入っている。
// 足りないものは1ファイル1言語で追加し、必ず本体の後に読み込む。
const HL_EXTRA_LANGS = [
'powershell', 'dockerfile', 'nginx', 'apache', 'dos', 'groovy', 'scala',
'dart', 'elixir', 'erlang', 'haskell', 'julia', 'latex', 'vim', 'awk',
'matlab', 'fortran', 'prolog', 'verilog', 'lisp', 'clojure', 'ocaml', 'fsharp',
];
最終的に 59 言語が1枚の中に入っています。
5. サイズを「予算」として扱う
ここが地味に大事でした。客先に持ち込むファイルが大きいと、それだけで怪しまれます。
数 MB の HTML を渡されたら、受け取る側は中身を疑います。だから最初から上限を決めて、入れるものを選びました。最終的に 415 KB です。
この予算があったので、逆に「入れないもの」がはっきりしました。オフライン版にはスマホ向けの画像生成機能を入れていません。そのために必要なライブラリだけで 194 KB あり、オフラインのエディタが背負うものではないと判断しました。PDF 出力もブラウザの印刷ダイアログに任せています。
何を足すかより、何を足さないかを決めるほうが効きました。
サーバーがあってもなくても、同じコードが動く
もう一つ、設計で気に入っている点があります。フロントエンドは起動時に /api/health を叩いて、返ってきたものを見てモードを決めます。
const response = await fetch('/api/health', { cache: 'no-store' });
const contentType = response.headers.get('content-type') || '';
if (response.ok && contentType.includes('application/json')) {
const data = await response.json();
HAS_BACKEND = data && data.ok === true;
}
ローカルサーバーが動いていれば Local mode になり、ディスク上の .md を直接開いて保存できます。静的サイトとして置かれていれば探索は失敗し、Web mode で動きます。Save はファイルのダウンロードに変わります。コードは同じ1つです。
ステータスコードだけでなく Content-Type と中身まで見ているのには理由があります。静的ホスティングの多くは、存在しないパスに対して 404 ではなく index.html を 200 で返します。response.ok だけで判定すると、サーバーがいない環境で「いる」と誤判定します。
ついでに言うと、オフライン版が成立していること自体が、このアプリにサーバー側の処理が要らないことの証明になっています。入力したものはどこにも送られません。それを説明で主張するより、ネットワークアクセスがゼロのファイルを渡すほうが早い。
右クリックから、1クリックで開く
Local mode にはもう一つ ?file= という URL パラメータがあります。
http://localhost:3000/?file=/path/to/notes.md
Windows なら、右クリックメニューからこれを開くようにできます。中身は PowerShell 3行です。
param($filePath)
$encoded = [uri]::EscapeDataString($filePath)
Start-Process "http://127.0.0.1:3000/?file=$encoded"
EscapeDataString を挟んでいるのは、日本語のファイル名やスペースを含むパスがそのままでは壊れるからです。
あとはレジストリに SystemFileAssociations\.md\shell\... のキーを1つ足して、このスクリプトを "%1" 付きで呼ぶだけです。リポジトリに add-context-menu.reg と open-md.ps1 を同梱してあります(.reg の中のパスはご自分の環境に合わせて書き換えてください)。
正直に言うと、私が仕事中にサイト版をほとんど開かないのは、これがあるからです。 ブラウザでサイトを開いて、ファイルを選んで、読み込ませる — その手順がまるごと消えます。エクスプローラで見つけた .md を右クリックして、そのまま開く。書き換えて保存すれば、元のファイルに戻ります。
小さな差に見えますが、1日に何度もやることなので、体感はかなり変わりました。
現場で起きたこと
今は2つの客先で使っています。
一つは制限がかなり強い現場です。ここでは毎日、複数人が同時に使っています。エディタが選べない環境では、選択肢が1つあるだけで十分に価値があります。
もう一つは、実は VS Code が使える現場です。それでもチームで使われています。理由を聞くと「開くのが速いから」でした。やりたいことが1つに絞られている道具は、高機能な道具に負けないことがある。 これは作ってから知りました。
作った本人が一番使っている機能は、想定と違った
正直に書くと、私が個人的に一番使っているのは Markdown → リッチテキストの変換ではありません。ドキュメントをスマホ幅の縦長画像に変換する機能です。
作ったときは「日本ではあまり使われないだろう」と思っていました。今でもそう思っています。ただ、毎日スマホを触る時代に、整形済みの文章をそのまま1枚の画像にして投稿できるのは、想像以上に手軽でした。
そして面白いことに、仕事では私はほとんどサイト版を使っていません。 自分の PC には Node.js を入れてあり、さきほどの右クリックでディスク上のファイルを直接開いています。サイト版を開くのは、たいていスマホからです。
作った本人の使い方が、想定と一番ずれていました。
ドキュメントをコードとして書く
最後に、この道具を作りながらずっと考えていたことを書きます。
私はもう若くありません。それでも周りには「文章もコードとして書けるようになったほうがいい」と言っています。そしてその入り口が Markdown だと思っています。
理由は単純です。Markdown は、AI と人間の両方がそのまま読める、ほとんど唯一の素のテキストだからです。背後に見えないスタイル情報が溜まっていきません。差分が取れます。検索できます。20年後に開いても読めます。そして AI に渡すとき、変換が要りません。
Word が悪いという話ではありません。ただ、AI に下書きを頼み、人が直し、また AI に渡す — この往復が日常になった以上、その往復に耐える形式で書いておくほうが、結局は速いというだけのことです。
新しい波が来るたびに道具を全部入れ替える必要はないと思っています。今回やったことも、結局は HTML ファイル1枚です。ただ、書くものの形式だけは、そろそろ変えたほうがいい。私はそう思っています。
作ったものはこちらです。インストールは要りません。
- サイト: https://markpaste.com
- ソース (MIT): https://github.com/tokicode/markpaste
オフライン版は npm run build:local で markpaste-local.html が1つ出来ます。持ち込み先で困っている方がいたら、どうぞ。