3行まとめ
-
.env/ JSON / YAML を相互変換するツールをブラウザ完結で作った。APIキーや接続文字列もサーバーに送らない - 3形式 × 双方向で6通りの変換を、全パターン個別実装せず 「中間表現(共通のJSオブジェクト)」経由にして parse 3本 + serialize 3本に畳んだ
-
.envはフラット限定なのでネストはエラー。js-yaml のdumpはlineWidth: -1を入れないと長い値が勝手に折り返される
Docker Compose の YAML、Node.js の .env、Kubernetes Secret の JSON。同じ設定値を、ツールごとに違うフォーマットで書き直す場面はやたら多い。手で変換すると地味にミスるし、オンライン変換ツールに API キーを貼るのは気が引ける。
ぱんだツールズに作った .env ↔ JSON ↔ YAML 変換ツールは、これをブラウザ内で完結させる。実装で面白いのは「3形式の総当たり変換をどう実装量を抑えて書くか」という設計の部分なので、そこを中心にまとめる。
N×N の変換を全部書くと地獄になる
3形式の相互変換は、素直に数えると .env→JSON .env→YAML JSON→.env JSON→YAML YAML→.env YAML→JSON の 6通り。形式が増えるたびに変換関数が二乗で増えていく(4形式なら12通り、5形式なら20通り)。これを1個ずつ実装するのは保守性が最悪だ。
なので 「中間表現」を1枚噛ませる。どの形式も一度 JavaScript のオブジェクト(プレーンな Record や配列)に parse し、そこから目的の形式へ serialize する。こうすると必要なのは「各形式の parser」と「各形式の serializer」だけ。3形式なら parse 3本 + serialize 3本の 計6本で、6通りの変換すべてをカバーできる。
type Format = 'env' | 'json' | 'yaml'
function convert(input: string, from: Format, to: Format): string {
if (from === to) return input
// 1. 入力形式 → 中間表現(JSオブジェクト)
let parsed: unknown
if (from === 'env') {
parsed = parseEnv(input)
} else if (from === 'json') {
parsed = JSON.parse(input) as unknown
} else {
parsed = jsyaml.load(input)
}
// 2. 中間表現 → 出力形式
if (to === 'json') {
if (parsed === undefined) {
throw new Error('入力を解析できませんでした(空のドキュメントまたは無効な形式)')
}
return JSON.stringify(parsed, null, 2)
} else if (to === 'yaml') {
return jsyaml.dump(parsed, { indent: 2, lineWidth: -1 })
} else {
return serializeToEnv(parsed)
}
}
from === to のときは何もせず入力をそのまま返す。あとは parse 側と serialize 側がそれぞれ独立しているので、新しい形式(TOML とか)を足したくなっても parser と serializer を1本ずつ追加するだけで全組み合わせに対応できる。JSON と YAML は標準/ライブラリに parse・serialize が揃っているので、自前で書くのは .env だけ。
.env パーサを自前で書く
JSON は JSON.parse、YAML は js-yaml の load で一発。問題は .env。これはブラウザ標準にもパーサが無いので自前で書く。フォーマット自体は素朴だが、地味に踏む点がいくつかある。
function parseEnv(text: string): Record<string, string> {
const result: Record<string, string> = {}
for (const line of text.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue // 空行・コメント行はスキップ
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1) continue
const key = trimmed.slice(0, eqIdx).trim()
if (!key) continue
let value = trimmed.slice(eqIdx + 1)
// 値全体がクォートで囲まれていたら剥がす
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1).replace(/\\"/g, '"').replace(/\\'/g, "'")
}
result[key] = value
}
return result
}
勘所は indexOf('=') で最初の = だけで分割すること。split('=') で割ってはいけない。.env の値には = や : が普通に入る。
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb?sslmode=require
この行を split('=') すると値の中の =require で割れてバラバラになる。「キーは最初の = の左、値はそれ以降全部」と決めて slice で切り出せば、値に何が入っていても壊れない。あとは空行・# コメント行をスキップし、値全体がクォートで囲まれていたら剥がしてエスケープを戻す。
.env へ書き出すときのクォート判定
逆方向(中間表現 → .env)はシリアライザを書く。.env は「1行1変数・値は文字列」というシンプルな形式だが、値に空白や特殊文字が入るときだけクォートで囲む必要がある。
function serializeToEnv(data: unknown): string {
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
throw new Error('.env 形式はオブジェクト(キー・バリューのマップ)のみ変換できます')
}
const obj = data as Record<string, unknown>
const lines: string[] = []
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null) {
throw new Error(
`.env 形式はネストされたオブジェクト・配列に対応していません(キー: "${key}")`
)
}
const strValue = String(value ?? '')
const needsQuote = /[\s"'\\=#]/.test(strValue) || strValue === ''
lines.push(
needsQuote
? `${key}="${strValue.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
: `${key}=${strValue}`
)
}
return lines.join('\n')
}
needsQuote の正規表現 /[\s"'\\=#]/ がクォート要否の判定。空白・ダブルクォート・シングルクォート・バックスラッシュ・=・# のいずれかを含むか、空文字ならダブルクォートで囲む。これらをクォート無しで素のまま書くと、コメント開始(#)や区切り(=)と誤読されたり、別ツールの .env パーサで値が途中で切れたりする。
囲む場合はさらに、値の中の \ を \\ に、" を \" にエスケープする。置換の順序が重要で、先にバックスラッシュをエスケープしてからダブルクォートを処理する。逆にすると、" をエスケープして入れた \ を後段の \→\\ 処理がもう一度食ってしまって二重エスケープになる。
.env はフラット限定 — ネストは正直にエラーにする
JSON や YAML はネスト(階層構造)や配列を持てるが、.env は フラットなキー・バリューしか表現できない。なので JSON/YAML 側にネストオブジェクトや配列があると .env には落とせない。
ここで無理やり parent.child=... みたいに平坦化する手もあるが、ツールとしては素直にエラーを投げることにした。どのキーで詰まったかを添えて返す。
if (typeof value === 'object' && value !== null) {
throw new Error(`.env 形式はネストされたオブジェクト・配列に対応していません(キー: "${key}")`)
}
平坦化は「区切り文字をどうするか(_? .?)」「配列のインデックスは?」で流儀が割れて、変換先のツールが同じ規則で読み戻せる保証がない。曖昧な変換で静かに壊れたデータを吐くより、「これは .env にできない構造です」と明示するほうが事故が少ない。トップレベルが配列やプリミティブのときも同様に弾く。
js-yaml の罠:lineWidth: -1 を入れないと長い値が折り返される
YAML 生成で一度ハマったのが、js-yaml の dump がデフォルトで 80桁を超える文字列を勝手に折り返すこと。長い接続文字列や URL が複数行に割れて出力され、別ツールで読むと意図しない改行が混ざる。
jsyaml.dump(parsed, { indent: 2, lineWidth: -1 })
lineWidth: -1 で折り返しを無効化(実質無制限)すると、どんなに長い値でも1行のまま出る。indent: 2 はインデント幅。設定ファイルの変換ツールでは、値を勝手に折り返さないことのほうが大事なので -1 一択。
もう一点、型の扱いが形式で変わるのも押さえどころ。js-yaml の load は YAML の数値・真偽値・null・配列を JavaScript の型として復元し、JSON 変換時もその型が保たれる。一方 .env は値がすべて文字列なので、String(value) で文字列化される。「YAML だと DEBUG: true(真偽値)が、.env だと DEBUG=true(文字列)になる」のはこの違いによる。
まとめ
設定ファイルの相互変換ツールを作るときの要点。
- N形式の総当たり変換は 中間表現(共通のJSオブジェクト)を1枚噛ませる。parse N本 + serialize N本で N×N 通りを賄える
-
.envパーサは 最初の=だけで分割(split('=')は厳禁。値に=:が入る)。空行・#・クォートを処理 -
.envシリアライズは 空白・"・'・\・=・#・空文字でクォート。エスケープは\→"の順 - ネスト構造は
.envに落とせないので 曖昧に平坦化せず明示的にエラー -
js-yaml.dumpはlineWidth: -1で折り返し無効化。型は YAML/JSON で保持、.envは全部文字列
中間表現を挟む設計は、フォーマット変換系のツールでは定番だが効果が大きい。形式が増えても実装が線形にしか増えないし、parse と serialize を別々にテストできる。.env まわりの細かいエスケープだけ丁寧にやれば、機密値をサーバーに送らず安全に変換できる。
ぱんだツールズ では他にもJSON整形・JSON差分・CSV変換・正規表現テストなど、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・入力はサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。