無駄と文化

実用的ブログ

dangerouslySetInnerHTMLを避けてHTML文字列をReactに埋め込む

Markdown を React でレンダリングして表示するための react-markdown というライブラリがある。Markdown には HTML を直接記述可能で、どのようにして任意の HTML 文字列を React コンポーネントに埋め込んでいるのか気になった。 というのも、真っ先に思いつくのは dangerouslySetInnerHTML という推奨されない方法 だからだ。

 

react-markdown はいかにして dangerouslySetInnerHTML を回避しているのだろうか。
結論を先に書くと、rehype というライブラリ群を使っているようだ。これについて調べ、試してみた。

 

rehype とは

github.com

rehype は HTML 文字列を AST として扱うためのエコシステムで、

  • HTML 文字列を AST にパース
  • AST を加工
    • 無害化 (Sanitize) など
  • HTML 文字列に再レンダリング

などの仕組みを提供している。

 

ちなみに、rehype は unified というコンテンツ加工のライブラリ群の一部で。rehype に含まれるのは unified プラグインの数々という位置付けになっているようだ。

 

rehype を試す

早速だがデモを作った。

https://todays-mitsui.github.io/rehype-drill/

画面左側の <textarea> に入力された HTML 文字列がリアルタイムに画面右側に表示される。ただし <script> などのは安全に除去される。
このデモは React で作ってあって、dangerouslySetInnerHTML は使われていない。

 

処理の中核を抜き出すとこのようになる。

return unified()
  .use(rehypeParse, { fragment: true })
  .use(rehypeSanitize, schema)
  .use(rehypeReact, {
    Fragment,
    jsx,
    jsxs,
  })
  .processSync(html).result;

unified のパイプラインによって、

  • rehypeParse: HTML文字列のパース
  • rehypeSanitize: 無害化
  • rehypeReact: React.JSX.Element にレンダリング

を次々に行うようになっている。

 

.use(rehypeSanitize, schema) に着目してほしい。第二引数で schema を渡している。これは、

const schema = {
  ...defaultSchema,
  tagNames: [...(defaultSchema.tagNames ?? []), "section", "div", "span"],
  attributes: {
    ...defaultSchema.attributes,
    "*": [...(defaultSchema.attributes?.["*"] ?? []), "className"],
  },
};

このような形をしている。

  • schema.tagNames: 許可される (除去されない) HTML タグを列挙
  • schema.attributes: 許可される (除去されない) プロパティを列挙

このような形式で無害化の挙動を細かく指定できる。

 

おまけ

ソースコードがここにある。

github.com

 

まとめ

一般に使われるコンテンツメディアを作ろうと思ったら、このようなライブラリを大いに活用しなければいけないんだろうなという感想。
使い方は簡単で強力で便利。

 

 

私からは以上です。

本の虫

目を覚ますと、私は書架の谷間に横たわっていた。

見上げれば、天井は淡い光の靄の向こうに溶けて見えない。左右には黒ずんだ樫の本棚がそびえ、革と紙と埃の匂いが、呼吸のたびに肺の奥へ沈んでいく。立ち上がって歩き出しても、景色は変わらなかった。書架、通路、書架、通路。ランプの光はどこから来るのかわからず、影は常に足元から同じ角度で伸びていた。

自分の名前は思い出せた。だが、なぜここにいるのかだけが、ページを破り取られたように欠けていた。


最初に出会ったのは、老いた学者風の男だった。彼は梯子の下に椅子を据え、膝の上で本を開いたまま、私の足音にゆっくりと顔を上げた。

「おや。新しい方だね」

驚いた様子はなかった。彼は栞を挟み、静かに語った。

「ここに迷い込んだ者は数えきれない。だが、出ていった者を、私は一人も知らない。もう何年彷徨ったか――いや、年月の数え方すら、ここでは怪しいのだがね。気づいているかな。君はここで目覚めてから、一度でも空腹を感じたかい」

言われて初めて、気づいた。喉も渇いていない。眠気すら、来ない。

「だから私は確信している。ここは死後の世界だ。君も私も、もう向こう側には戻れない。そう思えば、これほど穏やかな場所もないものだよ」

彼は本に視線を戻した。それが別れの合図だった。


次に出会った若い女は、書架に頬を寄せるようにして背表紙を撫でていた。

「ねえ、見て。これ、ラテン語。こっちは日本語。この棚の奥には、私が見たこともない文字で埋め尽くされた本もあるの」

彼女の声には、怯えではなく陶酔があった。

「ここにはすべての本があるのよ。書かれた本も、まだ書かれていない本も、誰にも書けなかった本も。あなたが探している答えだって、必ずどこかの棚にある。――ただね」

彼女は初めて私の目を見た。

「それを引き当てるのは、星の数ほどの砂の中から、一粒の宝石を拾い上げるようなものだけれど」


三人目は、目を爛々と輝かせた青年だった。彼は私の袖を掴み、誰に聞かれるのを恐れているのか、声を潜めて囁いた。

「知ってるか。この図書館には、一人の人間の一生をまるごと記した本があるんだ。生まれた日の産声から、最後の息までも。行動だけじゃない。考えたこと、感じたこと、誰にも言えなかったことまで、全部だ」

「誰の一生が」

「誰の、だって?」青年は笑った。「全員さ。あらゆる人間の運命が一冊ずつ綴じられて、この棚のどこかに差してある。あんたのも、俺のも、な」


私は再び一人で歩いた。彼らの言葉をどこまで信じるべきか、わからなかった。死後の世界。無限の蔵書。運命の書。どれも荒唐無稽に聞こえた。だが、目の前に果てしなく続く本棚だけは紛れもない現実で、手に取る本のどれもが、びっしりと意味の通る文章で埋まっていることもまた、確かだった。

数えるのをやめた頃――何日目だったのか、もう曖昧だ――ヴィクターと出会った。

中年の男だった。他の住人たちと違い、彼の目にはまだ「外」があった。彼は私を一目見るなり言った。

「君、まだ諦めていない顔をしているな。なら話が早い。私は出口を探している」

「出口なんてあるのか」

「あるかどうかじゃない。見つけるんだ」ヴィクターは書架の列を指差した。「いいか、鍵は目録だ。この図書館のすべての蔵書を記した一冊。それさえ手に入れば、どんな本がどの棚にあるか即座にわかる。図書館の見取り図が載った本の場所も、出口までの順路を記した本の場所も、目録を引けば一行でわかる。問題はただ一つ――」

「その目録を、この無限の棚から探し当てなければならない」

「そのとおり」

「砂の中の宝石を、と言った女がいたよ。目録だけは例外だと、なぜ言える」

ヴィクターは笑った。その笑いには、信仰に近い、異様な熱があった。

「ある。絶対にある。私はそれを確信しているんだ。必ず存在する。どこかに、必ず」

根拠は最後まで語られなかった。それでも私は彼と歩くことにした。確信を持つ人間の隣は、絶望よりはいくらか暖かかったからだ。


数日も行動を共にすると、気づくことがあった。ヴィクターは布で包んだ何かを、常に胸に抱えていた。眠るときも腕から離さず、私の視線がそこに落ちると、さりげなく体の向きを変えた。

ある晩、私は訊いた。「それは何だ」

長い沈黙のあと、彼は観念したように布を少しだけ開いた。中には、分厚い一冊の本があった。背表紙に箔押しされた文字を、ランプの光が掠めた。

――それは、彼の名前だった。

「偶然、見つけてしまったんだ」ヴィクターの声は掠れていた。「最初の数ページを読んだ。生まれた町、母の口癖、八つの歳に犬に噛まれた左手の傷。誰も知らないはずのことまで、一字も違わず書いてあった。これは本物だ。私の一生が、最後の一行まで、ここに綴じられている」

「読み進めたのか」

「読めるわけがないだろう!」

彼は初めて声を荒げ、それから恥じるように俯いた。

「……考えてみてくれ。もし最後のページに、私がこの図書館で朽ち果てると書かれていたら? 出口を探すこの日々が、すべて無駄だと知ってしまったら? 私はもう、一歩も歩けなくなる。だから読まない。誰にも読ませない。希望というのはね、君、ページを開かないことでしか守れない場合があるんだ」


それからも私たちは歩き続けた。棚から棚へ、回廊から回廊へ。引き抜く本はどれも目録ではなかった。疲労よりも先に、徒労が骨に沁みた。

ある晩、書架にもたれて休んでいたとき、私はずっと喉につかえていた考えを、とうとう口にしてしまった。

「ヴィクター。君は目録が必ず存在すると言う。そして君がいずれそれを見つけるのなら――その本に、書いてあるはずじゃないか。いつ、どの棚で、君が目録を手にするのか。最後まで読む必要すらない。その一行さえ見つければ、明日にはここを出られるかもしれない」

ヴィクターは答えなかった。ランプの炎が一度だけ揺れた。

やがて彼は、長い、長いため息をついた。

「……君の言うとおりだ。本当は、私にもずっとわかっていた。この本を読む勇気がないのに、目録を探すと言い張る。滑稽だよな。これ以上怯えていても仕方ない」

彼は布の上から本をそっと撫でた。

「だが、今日は休ませてくれ。明日の朝、覚悟を決めて開く」

それが、彼と交わした最後の言葉になった。


翌朝、ヴィクターは冷たくなっていた。

書架の根元に、眠るのとは違う静けさで横たわっていた。自ら、終わらせたのだとわかった。傍らには、布の解かれた、あの本が落ちていた。

私はしばらく動けなかった。それから、震える手でその本を拾い上げ、開いた。

ヴィクターという男の一生が、そこにあった。最初は何かの偶然だと思おうとした。だが、でたらめにしては、あまりに詳細だった。ページを繰るうち、見覚えのある場面に行き当たる。図書館で私と出会う場面。あの晩の会話。「希望というのはね、君、ページを開かないことでしか守れない場合があるんだ」――一言一句、彼の言ったとおりに記されていた。私が昨夜口にした提案までもが、そこに書かれていた。

つまり、あの言葉を彼に言うことも、決まっていたのだ。

最後のページには、自分の運命を知ることに耐えられなかった一人の男が、夜のうちに自らすべてを終わらせる場面が、静かな筆致で記されていた。書かれていたから、そうしたのか。そうするから、書かれていたのか。本は、その問いには答えてくれなかった。


私は仲間と手掛かりを同時に失い、本を抱えたまま座り込んだ。顔を上げても、昨日と寸分違わぬ無限の書架が広がっているだけだった。

――いや。

視線の先、ちょうど目の高さの棚に、一冊だけ、妙に丁寧な装丁の分厚い本が差さっていた。昨日まで、そこにあっただろうか。

立ち上がり、近づく。背表紙の箔押しの文字を、指でなぞる。

それは、私の名前だった。

抜き取った本は、ずっしりと重かった。ヴィクターのものより、ずっと分厚いように思えた。それが何を意味するのか、考えないようにした。

震える指で、表紙を開く。

一ページ目。

――目を覚ますと、彼は書架の谷間に横たわっていた。

私はそこで、本を閉じた。

この本のどこかには、目録の在り処が書かれているのかもしれない。出口をくぐる私の姿が書かれているのかもしれない。あるいは、ヴィクターと同じ最後の一行が待っているのかもしれない。

確かなのは一つだけだ。

私はまだ、最後のページを開いていない。

NumPyのオーバーロード悪用っぷりが面白い

機械学習などの数値計算で使われる Python ライブラリの NumPy (ナンパイ)、DSL が独特で面白いので紹介します。

 

基本の ndarray とブロードキャスト

NumPy のクラスでよく使うのは、N次元配列を表現する ndarray (N-dimension Array) です。
例えばシンプルな一次元配列はこのように書けます。

import numpy

ndarray = numpy.array([1, 2, 3, 4, 5])
# => array([1, 2, 3, 4, 5])

 

この ndarray はさまざまな演算子をオーバーロードで独自定義しています。
例えば各要素に対する定数和, 定数倍はこのように書けます。

ndarray = numpy.array([1, 2, 3, 4, 5])

ndarray + 3
# => array([4, 5, 6, 7, 8]): 全ての要素に 3 を足す

ndarray * 3
# => array([3, 6, 9, 12, 15]): 全ての要素に 3 を掛ける

このような挙動は ブロードキャスト と呼ばれます。1

内部的には、

class numpy.ndarray:
  def __add__(self, right):
    # right を各要素に足した結果を返す

  def __mul__(self, right):
    # right を各要素に掛けた結果を返す

このように特殊メソッドが実装されていると思われます。便利ですね。

 

インデックスアクセス [] もオーバーロード!

インデックスアクセス ndarray[...] にもオーバーロードで独自の実装が入っています。

例えば、真理値の配列をインデックスに指定できます。

ndarray = numpy.array([1, 2, 3, 4, 5])

ndarray[[True, False, True, False, True]]
# => array([1, 3, 5])

これだけ見ると何の役にたつかわかりづらいですね。[] に条件式を置くと実用的な雰囲気になります。

ndarray = numpy.array([1, 2, 3, 4, 5])

ndarray[ndarray % 2 == 1]
# => array([1, 3, 5])

2で割った余りが1になる (ndarray % 2 == 1) ような要素を取り出すと、array([1, 3, 5]) になると読めますよね。

 

順を追って解説すると、

ndarray = numpy.array([1, 2, 3, 4, 5])

arr1 = ndarray % 2
# => array([1, 0, 1, 0, 1])
#   : `% 2` のブロードキャスト

arr2 = arr1 == 1
# => array([True, False, True, False, True])
#   : `== 1` のブロードキャスト

arr3 = ndarray[arr2]
# => array([1, 3, 5])
#   : 真理値配列でインデックスアクセスして True の位置の要素だけ残す

# 上記をまとめて書くと、
ndarray[ndarray % 2 == 1]
# => array([1, 3, 5])

このようにブロードキャストの連鎖によって上手く働くように作られています。

 

メソッドっぽいけどメソッドでないもの

二つの ndarray を連結するのはこのように書けます。

a = numpy.array([1, 3, 5])
b = numpy.array([2, 4, 6])

numpy.r_[a, b]
# => array([1, 3, 5, 2, 4, 6])

r_ という メソッド で連結ができるんだね!」→ 違います!

.r_[a, b] はメソッド呼び出しではなく、クラス定数 .r_ に対するインデックスアクセスです。振る舞いはメソッドっぽいけどメソッドじゃないんです。

 

なぜパーレン () ではなくブラケット [] でアクセスするのか。それは slice() を受け入れるためです。

numpy.r_[1:6:2, 2:7:2]
# => array([1, 3, 5, 2, 4, 6])

Python ではスライス記法 start:stop, start:stop:step はインデックス [] の中にしか書けません。
もし .r_ がメソッドだったら、 .r_(1:6) のような書き方は Python の文法の制約として許されないんですね。そこでインデックスアクセスのオーバーロード!(悪用!)

 

複素数オブジェクトも悪用するぜ

.r_[] のオーバーロード悪用っぷりは止まりません。スライス記法の step 部に虚数 j を指定できます。

numpy.r_[0:10:5]
# => array([0, 5])

numpy.r_[0:10:5j]
# => array([0., 2.5, 5., 7.5, 10.])

step 部が実数の場合、「step ずつ飛ばして数列を作る」です。
一方で step 部が虚数だと「start から stop までを step 個に分割して数列を作る」になります。オーバーロードを貪欲に悪用する姿勢に痺れますね。

 

まとめ

NumPy の DSL はオーバーロードをどこまで悪用できるか面白がっている節がある。

 

 

私からは以上です。


  1. ブロードキャストの概念は、実際にはスカラー演算だけでなくサイズの異なる配列同士にも適用される広い概念です