Editor.jsでリンク埋め込み
リリースしたばかりの機能です。記事エディタに実装されていますが、それ以外のノート等でも動きます。
貸し本棚の記事、ノートエディタはEditor.jsを採用しています。このブロックエディタはプラグインで駆動するエディタで、様々なプラグインが公式、非公式問わず用意されています。
ブロックエディタというのは、ここnoteもそうであるように、ブロック単位でレイアウト設定を行うモダンエディタのことです。要素がブロック単位で管理されるので、並び替えや構造を直感的に把握できるメリットがあります。
noteにURLをペーストすると、自動的にURLを読み込んでOGPを展開し、以下のように綺麗な表示にしてくれます。
この機能を実現するプラグインは、Editor.jsにはありません。そこで自前で作ってしまいます。
Embedを流用する
プラグインを一から書くのは労力の無駄です。この機能を実現するために、Embedというプラグインのカスタムサービス機能を使います。
このプラグイン、貼られたURLがyoutubeだったりvimeoやTwitterだった場合に、iFrameを使って適切なタグ構成に書き換えてくれます。予めサービスは決められていますが、プリセット以外のサービスを自分でカスタム設定できるようになっています。
処理の流れ
EmbedでiframeのURLに貸し本棚の埋め込みパスとurlのパラメータを渡し、そのurlパラメータからリンク先データを取りに行きます。
取得したデータはキャッシュして、レイアウト済みのリンク画面をEmbedのiframeに返します。
2回目以降のアクセスでは、キャッシュがあればそれを返します。リンク先は見に行きません。キャッシュには有効期限を設けます。
カスタムサービス設定
embed: {
class: Embed,
config: {
services: {
youtube: true,
facebook: true,
instagram: true,
twitter: true,
"twitch-video": true,
"twitch-channel": true,
miro: true,
vimeo: true,
imgur: true,
codepen: true,
ogp: {
regex: /(https?:\/\/[\w!?/+\-_~;.,*&@#$%()'[\]]+)/,
embedUrl: '/embed?url=<%= remote_id %>',
html: "<iframe height='150' scrolling='no' frameborder='no' allowtransparency='true' allowfullscreen='true' style='width: 100%;'></iframe>",
height: 150,
width: 600,
id: (groups) => groups.join('')
}
}
}
},
ogpと書かれているのがカスタムサービスです。ひとまず全てのURLを対象にしてみます。このカスタムサービスは優先順位が低いのではないかと予想しました。
ここまではフロントサイドの作業となります。このiframeが正常に出力されることを確認したら、その中身を作ります。
リンク先データを取得する
サーバ側にurlパラメータが飛んできたら、いくつかの安全対策を施します。その上で、リンク先からogpデータを取得します。
$internalErrors = libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="utf-8" ?>' . $res->getBody());
libxml_clear_errors();
libxml_use_internal_errors($internalErrors);
$meta = $doc->getElementsByTagName("meta");
$ogp = [];
foreach ($meta as $m) {
$prop = $m->attributes->getNamedItem("property");
if ($prop) {
if (preg_match("/^og:([^:]+)$/", $prop->nodeValue, $match)) {
if (!isset($ogp[$match[1]])) {
$ogp[$match[1]] = $m->attributes->getNamedItem("content")->value ?? null;
}
} else if ($twitter and preg_match("/^twitter:image/", $prop->nodeValue)) {
$ogp["image"] = $m->attributes->getNamedItem("content")->value ?? null;
}
}
}
今回はリンク作成に必要最低限のデータしか取得しないので、og:image:widthなどの細かい情報は無視します。twitter:imageは外部記事リンクのときに使います(ここでは使われません)。
上記コードの$resはguzzle/httpで取得しています。これをパースして、og:で始まるpropertyのmetaタグを拾っていきます。取得が完了したらvalidationして、キャッシュ処理を行います。validateに失敗したら、単純なテキストリンクとして返します。
TwitterやFacebookなどでは画像データもしっかりキャッシュしてくれますが、同じことをやろうとしたらキャッシュ8レコード程度でで3MBを超えたので、零細らしく通常のリンクにします。
生bladeで返す
取得したデータは生bladeで返します。外部ファイルを一切読み込まないので、かなり軽量です。
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<style>
.embed {
position: relative;
border: 1px solid #ddd;
padding: 0;
overflow: hidden;
border-radius: .5rem;
display: table;
width: 100%;
}
.embed .body {
display: table-cell;
vertical-align: middle;
padding: 1rem;
}
.embed .body .title {
font-weight: bold;
font-size: 1rem;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.embed .body .title .stretched-link {
text-decoration: none;
color: #222;
}
.embed .body .title .stretched-link::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
pointer-events: auto;
content: "";
background-color: rgba(0,0,0,0);
}
.embed .body .description {
display: -webkit-box;
font-size: .7rem;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
margin-top: .5rem;
}
.embed .image {
display: table-cell;
overflow: hidden;
line-height: 0;
}
.embed .image img {
object-fit: cover;
max-height: 140px;
overflow: hidden;
}
@media (max-width: 575px) {
.embed .image {
max-width: 140px;
}
}
</style>
</head>
<body>
@if ($ogp)
<div class="embed">
<div class="body">
<div class="title">
<a href="{{request()->url}}" target="_blank" class="stretched-link">
{{$ogp->title}}
</a>
</div>
<div class="description">{{$ogp->description}}</div>
</div>
@if ($ogp->image)
<div class="image">
<img src="{{$ogp->image}}" alt="{{$ogp->title}}" class="left">
</div>
@endif
</div>
@else
<a href="{{request()->url}}" target="_blank">{{request()->url}}</a>
@endif
</body>
</html>
bladeは特段設定しなくても、ダブルブレースはすべてエスケープされます。このページ単体で表示できるかテストを行います(本番環境では独立表示はできません)。
問題なく表示できたら、Embed経由で表示テストを行います。
キャプションバーがちょっとダサいですが、許容範囲です。スマホサイズでは画像の横幅を制限してテキストが表示できるようにしています。このiframeはコンテンツに応じて高さを変えてくれないので、折返しできません。
他の埋め込みと被らないか確認
youtubeや画像のURL埋め込みと被っては意味がありません。念の為、それぞれURLを貼って確認します。
Editor.jsはプラグインの優先順位について説明が無いので、おそらく設定された順番で判定してるのかなーと思いますが、まだ深く追求していません。
Editor.jsは微バグが多いですが、外側でなんとかこねくり回せば色々と使えます。設計思想は優れていると思うので、もっとプラグインが増えてくれればいいなと思っています。