ReactでYAMLとJSONを相互に変換する方法

​​React​JSON,TypeScript,YAML

YAML と JSON の変換は、npm の yaml パッケージの parse() と stringify() で書けます。YAML から JSON へは parse() で読んで JSON.stringify() に渡し、JSON から YAML へは JSON.parse() で読んで stringify() に渡します。

ただ、オプションを付けずに使うと、つまずくところがいくつかあります。JSON の文字列 "no" が引用符なしの no で書き出され、古い YAML 1.1 の読み手では false に変わります。<<: *base のマージキーは展開されません。ここでは、そうした点を確かめながら、2つの欄を行き来して変換する YamlJsonConverter コンポーネントを作りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは yaml 2.9.1、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。変換する関数は Node.js で結果を確かめました。コンポーネントは tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを幅 375px の Chromium で Playwright から操作しました。Firefox と Safari、スマートフォンの実機、何 MB もある YAML、--- で区切った複数の文書の変換、コメントを残したままの変換は試していません。

幅375pxの画面で撮ったコンポーネント。上の YAML の欄はピンクの枠になり、city の行がタブで字下げされている。欄の下にピンクの文字で「YAML として読めません(9行目・1列目、TAB_AS_INDENT):Tabs are not allowed as indentation」と出ている。下の JSON の欄は、前の変換結果のまま文字が灰色になっている
YAML の欄の city の行をタブで字下げしたところ。JSON の欄は前の結果を灰色で残している

変換する関数

変換の処理は convert.ts にまとめました。yamlToJson() と jsonToYaml() は、どちらも変換した文字列、エラーのメッセージ、注意のメッセージを返します。

ts
// YAML と JSON を相互に変換する関数
import { parse, stringify, YAMLParseError } from 'yaml';

export type Converted =
  | { text: string; error: ''; warning: string }
  | { text: ''; error: string; warning: '' };

const empty: Converted = { text: '', error: '', warning: '' };

function fail(error: string): Converted {
  return { text: '', error, warning: '' };
}

// yaml が投げたエラーを、行と列の付いた短いメッセージにする
function yamlErrorMessage(e: unknown): string {
  if (e instanceof YAMLParseError) {
    // message の1行目は「理由 at line 2, column 1:」。2行目からはエラーの箇所の抜き出し
    const reason = e.message.split('\n')[0].replace(/ at line \d+, column \d+:$/, '');
    const where = e.linePos ? `${e.linePos[0].line}行目・${e.linePos[0].col}列目` : '位置不明';
    return `YAML として読めません(${where}、${e.code}):${reason}`;
  }
  if (e instanceof ReferenceError) {
    // ReferenceError は、エイリアスの展開が maxAliasCount を超えたときと、
    // & で定義していない名前を *名前 で参照したときに投げられる
    if (e.message.startsWith('Excessive alias count')) {
      return 'YAML のエイリアス(*名前)の展開が多すぎるため、変換をやめました';
    }
    return `YAML のエイリアスを読めません:${e.message}`;
  }
  return `YAML として読めません:${(e as Error).message}`;
}

export function yamlToJson(source: string): Converted {
  if (source.trim() === '') return empty;
  let value: unknown;
  try {
    // merge: true で、<<: *名前 のマージキーを展開する(YAML 1.2 では既定で無効)
    value = parse(source, { merge: true, maxAliasCount: 100 });
  } catch (e) {
    return fail(yamlErrorMessage(e));
  }
  // .inf や .nan は JSON にない値で、JSON.stringify では null になる。いくつあったかを数えておく
  let lost = 0;
  const text = JSON.stringify(
    value,
    (_key, v) => {
      if (typeof v === 'number' && !Number.isFinite(v)) lost++;
      return v;
    },
    2,
  );
  const warning = lost > 0 ? `JSON で表せない数(.inf、.nan)が ${lost} か所あり、null にしました` : '';
  return { text: text + '\n', error: '', warning };
}

export function jsonToYaml(source: string): Converted {
  if (source.trim() === '') return empty;
  let value: unknown;
  try {
    value = JSON.parse(source);
  } catch (e) {
    return fail(`JSON として読めません:${(e as Error).message}`);
  }
  // compat: 'yaml-1.1' で、YAML 1.1 では真偽値や日付として読まれる文字列(no、on、2024-01-01 など)も引用符で囲む
  // lineWidth: 0 で、長い文字列を途中で折り返さない
  const text = stringify(value, { compat: 'yaml-1.1', lineWidth: 0 });
  return { text, error: '', warning: '' };
}

次の YAML を yamlToJson() に渡し、出てきた JSON を jsonToYaml() に渡すと、元と同じ形の YAML に戻りました。その YAML をもう一度 JSON にすると、1回目の JSON と同じ文字列になりました。ただし、1行目のコメント # 猫の情報 は JSON に入らないので、戻した YAML からは消えています。

yaml
# 猫の情報
name: たま
age: 3
indoor: true
answer: "no"
birthday: "2022-04-01"
toys:
  - ねずみ
  - ボール
owner:
  name: Sato
  city: Tokyo
note: |
  ごはんは朝と夜。
  水はいつも新しく。
json
{
  "name": "たま",
  "age": 3,
  "indoor": true,
  "answer": "no",
  "birthday": "2022-04-01",
  "toys": [
    "ねずみ",
    "ボール"
  ],
  "owner": {
    "name": "Sato",
    "city": "Tokyo"
  },
  "note": "ごはんは朝と夜。\n水はいつも新しく。\n"
}

note の | は、改行をそのまま残す書き方です。JSON では \n の入った1つの文字列になり、YAML に戻すと、また | の形で書き出されました。

YAML から JSON にするときに気を付けること

no や yes は、YAML 1.2 では文字列のまま

yaml パッケージは、%YAML の指定がない文書を YAML 1.2 として読みます(オプションの説明)。YAML 1.2 の既定のスキーマ(core)では、no や yes は真偽値になりません。a: no を変換すると、"a": "no" になりました。

YAML 1.1 では、no、yes、on のような言葉も真偽値として扱われます。比べるために version: '1.1' を付けて読むと、結果が大きく変わりました。

YAML YAML 1.2(既定) YAML 1.1
a: no "a": "no" "a": false
b: yes "b": "yes" "b": true
n: 017 "n": 17 "false": 15
d: 2024-01-01 "d": "2024-01-01" "d": "2024-01-01T00:00:00.000Z"

n: 017 の行では、キーの n まで false になり、017 は8進数の15として読まれました。日付も、YAML 1.1 では日付として読まれ、JSON では時刻の付いた文字列になりました。この記事のコンポーネントは既定の YAML 1.2 で読みます。

マージキーは merge: true を付けて展開する

&base で名前を付けた値は、*base で別の場所から使えます(アンカーとエイリアス)。JSON にはこの仕組みがないので、parse() はエイリアスの位置に中身を展開します。cat: *base は "cat": { "color": "white" } になりました。

一方、<<: *base で別のマップのキーを取り込むマージキーは、YAML 1.1 の機能で、1.2 の仕様には入っていません。そのため merge オプションの既定値は、YAML 1.2 では false です(マージキーの説明)。付けずに読むと、<< がそのままキーとして残りました。

yaml
base: &base
  color: white
cat:
  <<: *base
  name: tama
text
--- 比較:merge を付けない parse のマージキー
{"base":{"color":"white"},"cat":{"<<":{"color":"white"},"name":"tama"}}

merge: true を付けた yamlToJson() では、"cat": { "color": "white", "name": "tama" } に展開されました。

エイリアスの展開には上限を付ける

エイリアスは、展開すると何倍にも増えます。9個の要素の配列を次の行で9回参照し、その行をさらに次の行で9回参照する、と10段重ねると、たった10行でも、展開後の要素は9の10乗(約35億)個になります。

yaml
a: &a [x, x, x, x, x, x, x, x, x]
b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a]
c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b]
(d から i も同じ形で続く)
j: &j [*i, *i, *i, *i, *i, *i, *i, *i, *i]

yaml パッケージには、こうした展開を止める maxAliasCount オプションがあり、既定値は100です。上限に達すると ReferenceError が投げられます(エラーの説明)。上の YAML を yamlToJson() に渡すと、「YAML のエイリアス(*名前)の展開が多すぎるため、変換をやめました」というメッセージが返りました。yamlToJson() の100は既定値と同じですが、上限があることがコードから分かるように明示しています。

ReferenceError は、& で定義していない名前を * で参照したときにも投げられました。a: *nope を yamlToJson() に渡すと、エラーのメッセージに Unresolved alias (the anchor must be set before the alias): nope が出ました。上限に達したときのメッセージは Excessive alias count indicates a resource exhaustion attack でした。* のあとの名前を打ち間違えただけで「展開が多すぎる」と出ないように、yamlErrorMessage() ではメッセージが Excessive alias count で始まるかどうかで分けています。この文言はドキュメントには書かれていないので、yaml のバージョンを上げたときは、分け方がまだ合っているかを確かめてください。

読めないときは、行と列とエラーの種類を出す

parse() は、YAML として読めないと YAMLParseError を投げます。linePos には1から数えた行と列が、code にはエラーの種類が入っています(エラーの説明)。タブで字下げした YAML では、message は次の文字列でした。1行目が理由と位置で、2行目からはエラーの箇所の抜き出しです。yamlErrorMessage() では、1行目から位置の部分を除いた理由だけを使いました。

text
"Tabs are not allowed as indentation at line 2, column 1:\n\na:\n\tb: 1\n^\n"
入力 yamlToJson() のエラー(「YAML として読めません」に続く部分)
字下げにタブを使った (2行目・1列目、TAB_AS_INDENT):Tabs are not allowed as indentation
字下げがずれた (3行目・1列目、BAD_INDENT):All mapping items must start at the same column
同じキーが2回ある (2行目・1列目、DUPLICATE_KEY):Map keys must be unique
--- で区切った文書が2つある (2行目・1列目、MULTIPLE_DOCS):Source contains multiple documents; please use YAML.parseAllDocuments()

parse() が読めるのは1つの文書だけです。--- で区切った複数の文書を扱うには、parseAllDocuments() を使います(parse の説明)。

.inf と .nan は JSON では null になる

YAML の .inf と .nan は、JavaScript の Infinity と NaN として読まれます。JSON にはこれらを表す書き方がなく、JSON.stringify() は null として書き出します。黙って値が変わらないように、JSON.stringify() の2つ目の引数の関数で数を数え、注意を出しています。speed: .inf、ratio: .nan、list: [1, -.inf] を変換すると、3つとも null になり、「JSON で表せない数(.inf、.nan)が 3 か所あり、null にしました」と返りました。

このほか、12345678901234567890 のような大きな整数は、YAML から読んだ時点で 12345678901234567000 になりました。JavaScript の number で正確に表せる桁を超えた部分は、JSON にする前に失われています。

JSON から YAML にするときに気を付けること

YAML 1.1 で別の値になる文字列は、compat で引用符を付ける

stringify() は、そのまま書くと別の値に読まれてしまう文字列には、引用符を付けます。文字列の "123" は、num: "123" と引用符付きで書き出されました。ところが、YAML 1.2 では文字列のまま読まれる no や on は、引用符なしで書き出されました。

text
--- 比較:compat を付けない stringify
answer: no
flag: on
date: 2024-01-01
code: "017"
num: "123"
y: y

この YAML を YAML 1.1 として読むツールに渡すと、answer は false に、date は日付に変わります。compat: 'yaml-1.1' を付けると、YAML 1.1 でも同じ値に読まれる書き方を選びます(オプションの説明)。jsonToYaml() では、次のように引用符が付きました。キーの y にも引用符が付いています。

yaml
answer: "no"
flag: "on"
date: "2024-01-01"
code: "017"
num: "123"
"y": "y"

長い文字列は lineWidth: 0 で折り返さない

stringify() は、既定では80文字を目安に長い文字列を折り返します(lineWidth の既定値が80)。「ねこ」を空白で区切って30回並べた文字列は、オプションなしでは2行に分かれました。

text
--- 比較:lineWidth を付けない stringify
memo: ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ ねこ
  ねこ ねこ ねこ ねこ ねこ

折り返した YAML も、parse() で読むと元の1行の文字列と同じになりました。ただ、1つの文字列が2行に分かれて見え、紛らわしいので、lineWidth: 0 で折り返しを止めました。

読めない JSON は、JSON.parse() のメッセージをそのまま出す

JSON.parse() は、JSON の文法に合わない文字列を受け取ると SyntaxError を投げます。jsonToYaml() では、そのメッセージをそのまま出しています。{"a": 1,} では「JSON として読めません:Expected double-quoted property name in JSON at position 8 (line 1 column 9)」になりました。

2つの欄を行き来して変換するコンポーネント

tsx
import { useId, useState } from 'react';
import { jsonToYaml, yamlToJson } from './convert';

type Side = 'yaml' | 'json';

export function YamlJsonConverter({ initialYaml = '' }: { initialYaml?: string }) {
  const id = useId();
  const [yaml, setYaml] = useState(initialYaml);
  const [json, setJson] = useState(() => yamlToJson(initialYaml).text);
  // 最後に書き換えた欄と、その欄を変換した結果のエラー・注意
  const [edited, setEdited] = useState<Side>('yaml');
  const [error, setError] = useState('');
  const [warning, setWarning] = useState('');

  function update(side: Side, text: string) {
    const result = side === 'yaml' ? yamlToJson(text) : jsonToYaml(text);
    if (side === 'yaml') setYaml(text);
    else setJson(text);
    setEdited(side);
    setError(result.error);
    setWarning(result.warning);
    // 読めないときは、反対側の欄を前の結果のまま残す
    if (result.error === '') {
      if (side === 'yaml') setJson(result.text);
      else setYaml(result.text);
    }
  }

  // エラーのある欄の反対側は、前の結果のままなので、文字を灰色にする
  const stale = (side: Side) => error !== '' && edited !== side;

  return (
    <div className="yaml-json">
      {(['yaml', 'json'] as const).map((side) => (
        <div key={side} className="pane">
          <label htmlFor={`${id}-${side}`}>{side === 'yaml' ? 'YAML' : 'JSON'}</label>
          <textarea
            id={`${id}-${side}`}
            rows={13}
            spellCheck={false}
            value={side === 'yaml' ? yaml : json}
            aria-invalid={error !== '' && edited === side}
            aria-describedby={edited === side && (error || warning) ? `${id}-message` : undefined}
            className={stale(side) ? 'stale' : undefined}
            onChange={(e) => update(side, e.target.value)}
          />
          {edited === side && (error || warning) && (
            <p id={`${id}-message`} className={error ? 'error' : 'warning'} role={error ? 'alert' : 'status'}>
              {error || warning}
            </p>
          )}
        </div>
      ))}
    </div>
  );
}
css
.yaml-json {
  display: grid;
  gap: 12px;
  max-width: 560px;
}
.yaml-json .pane {
  display: grid;
  gap: 4px;
}
.yaml-json label {
  font-weight: bold;
}
.yaml-json textarea {
  font-family: ui-monospace, monospace;
  font-size: 14px;
  color: #1f2a30;
  padding: 6px 8px;
  border: 1px solid #00c8e8;
  border-radius: 6px;
}
.yaml-json textarea[aria-invalid='true'] {
  border-color: #d85878;
}
/* エラーのとき、反対側の欄は前の結果のまま。灰色にして、いまの入力と合っていないことを示す */
.yaml-json textarea.stale {
  color: #9aa5ab;
}
.yaml-json .error,
.yaml-json .warning {
  margin: 0;
  font-size: 14px;
}
.yaml-json .error {
  color: #d85878;
}
.yaml-json .warning {
  color: #067a98;
}

YAML の欄を書き換えると update('yaml', …) が呼ばれ、yamlToJson() の結果で JSON の欄を書き換えます。JSON の欄では、その逆です。どちらの欄も、利用者が書き換えるたびに、反対側の欄を作り直します。そのため、2つの欄の文字列を両方とも state に持ち、変換はイベントハンドラーの中で行いました。

読めない間は、反対側の欄を前の結果のまま残す

YAML を書いている途中は、読めない状態が何度も起きます。そのたびに JSON の欄を空にすると、それまでの変換結果が見えなくなります。そこで、エラーのときは反対側の欄を書き換えず、前の結果のまま残しました。代わりに、文字を灰色(#9aa5ab)にして、いまの入力と合っていないことが分かるようにしています。

上の画像は、YAML の欄の city の行をタブで字下げしたところです。YAML の欄に aria-invalid="true" が付いて枠がピンクになり、欄の下に role="alert" のメッセージが出ました。JSON の欄は前の内容のまま、文字の色が rgb(154, 165, 171) になりました。タブをスペースに戻して city を Osaka にすると、メッセージが消え、JSON の欄は "city": "Osaka" になって、文字の色も戻りました。

JSON の欄の側でも同じです。JSON の最後の値のあとに , を残すと、JSON の欄がピンクの枠になり、YAML の欄の文字が灰色になりました。

.inf の注意のように、変換はできたものの知らせたいことは、role="status" の水色の文字で出しています。YAML の欄に speed: .inf と ratio: .nan を入れると、JSON の欄は "speed": null と "ratio": null になり、「JSON で表せない数(.inf、.nan)が 2 か所あり、null にしました」と出ました。

​Posted by 86