この記事は DeNA Advent Calendar 2020 の 18 日目の記事です。
17 日目は「Pococha の LP を支える技術」でした。いつもお世話になっています。
19 日目は「定理証明支援系 Isabelle のお役立ち TIPS をお話しします」です。こちらも必読でしょう。
DeNA Design
DeNA Design は DeNA のデザイン本部という横断組織のポートフォリオサイトになります。
前身である DeNA Design Blog に加えて、新たに実績に関するコンテンツも追加してリニューアルを行いました。
技術スタックも Nuxt.js + Vue.js というスタックから Next.js + React に変わっています。
ぜひ下記の記事もご覧いただければと思います。
今回はその DeNA Design のコンテンツに色をつけるために HTML の AST に手を入れた話をします。
こんなことを話します
- マークダウンの表現力が低い
- マークダウンの AST には手を入れたくない
- HTML の AST に手を入れれば簡潔に済む
なお、今回掲載するサンプルコードはすべて実際に動作しているコードとは異なります。
マークダウンの表現能力が低い
マークダウンで書かれたコンテンツに対して色をつけようとした時、マークダウンの表現能力の乏しさが問題になります。
例えば写真に対してキャプションを付けたいといった要求に対してそれを表現できる書き方はマークダウンにはありません。
その他でも細かい色付けに対する指定をマークダウンに含めることは出来ません(当然すべきでもありませんが)。
ですのでよりリッチな HTML の表現やスタイリングを求めるのであればマークダウンから HTML として出力する過程に処理を含める必要があります。
マークダウンの AST には手を入れたくない
これらの課題に対しての策として、まずはマークダウンをパースする際に独自の構文解釈を差し込むことを考えます。
これは markdown-it や marked といったマークダウンパーサのライブラリを使用することで実現できるでしょう。
マークダウンのパース処理の戦略は基本的な構文解析と同じですから、特定の文字列に対してパース処理を行い構文木を生成します。
これによって独自のマークダウン構文を定義することも可能です。
実際にリプレイス前はこの方法で処理を行っていましたが、課題もありました。
マークダウンの AST にはスタンダードと言えるものはなく、どうしても初めて触る人に対する学習コストを要求することになります。
また、あくまでライブラリによって提供されるインターフェースを触らなければいけないため、そのインターフェースに依存した書き方をしなければいけません。
これはリプレイス以前の img タグに対する処理です。
ライブラリは markdown-it を使用していました。
markdownImageParser.ts
function markdownImage(callback) {
return (markdown) => {
markdown.core.ruler.after(
"inline",
"replace-src-link",
({ tokens, env }) => {
const { relativeDir } = env;
for (let token of tokens) {
if (/^<img/.test(token.content)) {
const element = parser(token.content);
for (let attr in element.attributes) {
const value = callback(
attr,
element.attributes[attr],
relativeDir
);
if (value !== null && value !== undefined) {
element.attributes[attr] = resolvePath(value, relativeDir);
}
}
const img = tag(element.tagName, element.attributes);
token.content = tag("p", { class: "img" }, img);
} else if (token.type === "inline" && token.children.length) {
for (let child of token.children) {
if (child.tag === "img") {
for (let attr of child.attrs) {
const value = callback(attr[0], attr[1], relativeDir);
if (value !== null && value !== undefined) {
attr[1] = resolvePath(value, relativeDir);
}
}
const img = tag("img", {
src: child.attrs[0][1],
alt: child.attrs[1][1],
});
child.content = tag("p", { class: "img" }, img);
child.type = "html_block";
child.tag = "";
}
}
}
}
}
);
};
}
Ruler.after()
第三引数は rule を記述する関数ですが、そこに引き渡される引数をインターフェースとして各操作を行います。
ここに渡ってくる tokens
はライブラリによってパースされた要素が詰め込まれるため、完全にライブラリに依存することになります。
その結果条件分岐が複雑になり、コードの見通しとしてはあまり良くないものになってしまいました。
これを処理ごとに書いていくというのはかなり重い作業になりますし、開発者にとってはあまり優しくありません。
もちろんサービスの特性によってはきちんとマークダウンの段階でパース処理をすることが望ましいケースもあるでしょう。
HTML の AST に手を入れれば簡単に済む
前述したとおり、マークダウンの AST 操作に課題を感じたため別の方法を考えました。
今回検討したものは HTML の AST に対して操作をする方法です。
この方法であればおよそイメージできる HTML タグの組み合わせと class の付与によってある程度の表現をカバーすることができます。
例えば今回のリニューアルでとったものの一つで、h6 と img が隣り合う場合に caption にするという処理はこの方法を使用しました。
また h1 ~ h6 の見出しタグに共通のクラスを持たせるといった処理もこの方法を使用すれば容易です。
独自の構文こそ作れませんがある程度のルールによってスタイリングの補助的な役割は担うことができると思います。
実装方法
それでは具体的にどのような実装をしたかを見ていきます。
今回は初見でも分かりやすいように、Babel プラグインの visitor インターフェースを模倣することにしました。
ここには htmlparser2 の domutils を用います。
applyDomModifier.ts
export function applyDomModifier(
tree: Node,
modifier: DomModifier,
env: { [key: string]: string }
) {
for (const tagName in modifier) {
for (const node of domUtils.getElementsByTagName(tagName, tree, true)) {
const { parent, next } = node;
const newNode = modifier[tagName](node, env);
if (newNode) {
if (next) {
domUtils.prepend(next, newNode);
} else if (parent === node.parent) {
domUtils.replaceElement(node, newNode);
} else {
domUtils.appendChild(parent as Element, newNode);
}
}
}
}
return tree;
}
そしてヘルパーに渡すための img タグに対する操作はこのようになっています。
imageModifier.ts
export const imageModifier: DomModifier = {
img(node) {
const isInline = node.parent && (node.parent as Element).name === "p";
const wrapper = new Element(isInline ? "span" : "div", {
class: classNames("image", isInline ? "inline" : "block"),
});
domUtils.appendChild(wrapper, node);
return wrapper;
},
};
こういった処理によってタグに対してある程度自由な変換を行うことができます。
また、マークダウンの AST 操作によるものと比較しても、ライブラリに強く依存せずに可読性とメンテナンス性の問題を解消できていることが分かります。
ただし通常 HTML をパースする戦略は以下の点に注意する必要があります。
- innerHTML を使用することになるので XSS の危険性が生じる
- この理由でエディタを内包するような UGC での採用には向かないと考えられます
- マークダウンのパースに加えて HTML のパース処理が加わるのでオーバーヘッドが増え、またライブラリ分の容量も肥大する
今回は Next.js の SSG を用いたため、パース処理のオーバーヘッドを気にしなくても良いという理由から、この方法を採用しました。
まとめ
HeadlessCMS を使って Next.js や Nuxt.js で SSG というのは昨今のフロントエンド界隈のテーマではありますが、個人的にはそこにコンテンツの表現力を強められる技術が乗っかってくるとよりリッチな Web 体験を提供しやすくなるのかなと考えています。
これからのマークダウンパーサーライブラリにも期待するばかりですね。
この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします!
また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog 記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!