Next.jsでカスタムローダーを使ってmdxをAMP対応させる

TL;DR

markdownファイルやmdxファイルはそのままだと<img>タグなどを使う。さらにAMP下での数式のレンダリングやコードシンタクスに対応させることも出ない。なので、mdxのカスタムローダーを自作することでAMPに対応する。

カスタムローダーに関してはmdxの公式などが詳しい。

JSXを使ってamp対応

mdxフォーマットはjsxに対応している。そして、jsxにはamp-componentsが存在する。なので、amp対応するには、それぞれのdefaultのタグ(imgなど)をamp-components(<amp-img ... />)に変換してしまえば良い。

基本

astの中でjsx記法は以下のように表される。

type: "jsx"
value: "<button>push!!!!</button>"
position: ...

なので、あるタグを含むnodeを見つけたら、そのタグが対応するamp-componentsをvalueの中に埋め込んだJSXノードに変換してしまえばいい。

数式

数式をレンダリングするAMPタグは<amp-mathml>を使う。また、インラインの数式では<amp-mathml inline>すればインライン数式になる。

remark-mathを使えば、$$で囲まれた部分がmath$で囲まれた部分がinlineMathに変換されるので、math<amp-mathml>に、inlineMath<amp-mathml inline>に変換する。

const visit = require("unist-util-visit");

module.exports = toMathml;

function toMathml() {
  return transformer;

  function transformToJsxNode(parent, index, value, position) {
    const newNode = {
      type: "jsx",
      value: value,
      position: position,
    };

    parent.children[index] = newNode;
  }

  function transformer(ast) {
    visit(ast, "math", mathVisitor);
    function mathVisitor(node, index, parent) {
      const value = `<amp-mathml layout="container" data-formula="\\[${node.value}\\]" />`;
      transformToJsxNode(parent, index, value, node.position);
    }

    visit(ast, "inlineMath", inlineMathVistor);
    function inlineMathVistor(node, index, parent) {
      const value = `<amp-mathml
                            layout="container"
                            inline
                            data-formula="\\[${node.value}\\]"
                            />`;
      transformToJsxNode(parent, index, value, node.position);
    }
  }
}

img

数式に関しては単純に変換するだけなので単純で良かった。しかし、imgタグに対応するのamp-componentsは<amp-img />なのだが、このタグはwidthheightが必須という特徴がある。一つの対応策としてはCSSなどでうまくresizeしてしまうことらしいのだが(参考)、widthかheightのどちらかは固定する必要があり、固定された側の大きさに引っ張られる。なので、スマホとかを見ると画像の上下に不自然な空白が生まれてしまうことがある。

今回は、どうせmdxをパースする作業はサーバーサイドでやるので、nodeモジュールで対応するイメージのsizeをとってきてちゃんとサイズを入れることにした。

image-sizeというパッケージで簡単にサイズを取得できる。また、urlからサイズを取ってくるときが少しめんどうで、非同期処理を使えない。使ってしまうとparseが終わった後にやっとwidthとheightがわかる、みたいなことになるっぽい。このあたりしっかり理解しきれていないのだが、sync-requestという同期処理でrequestするモジュールを使って強引に解決した。

** 注意 ** ただsync-requestは非推奨らしいので(参考)、使用する場合は自己責任で...。問題になってるのはクライアント側がクラッシュしやすくなるとかなので、buildするときに走るだけだから問題ないと思いたいのだが。dynamic importとか始めると問題になるかもしれない。

const visit = require("unist-util-visit");
const p = require("path");
const sizeOf = require("image-size");

// sync-requestを使わないと整形が終わったあとにリクエストされる。
const sr = require("sync-request");

module.exports = toAmpImg;

function toAmpImg() {
	return transformer;

	function makeValue(url, alt, dimensions) {
		const width = dimensions.width;
		const height = dimensions.height;
		const value = `<amp-img layout="responsive" src="${url}" alt="${alt}" height="${height}" width="${width}" />`;
		return value;
	}

	function transformToJsxNode(parent, index, value, position) {
		const newNode = {
			type: "jsx",
			value: value,
			postion: position,
		};

		parent.children[index] = newNode;
	}

	function transformer(ast) {
		visit(ast, "image", visitor);
		function visitor(node, index, parent) {
			const url = node.url;
			const alt = node.alt;
			const position = node.position;
			let path = url;

			if (url.startsWith("/")) {
				path = p.join(process.cwd(), "public", url);
				const dimensions = sizeOf(path);
				const value = makeValue(url, alt, dimensions);

				transformToJsxNode(parent, index, value, position);
			} else if (url.startsWith("http") || url.startsWith("ftp")) {
				const res = sr("GET", url);
				const buf = Buffer.from(res.getBody());
				const dimensions = sizeOf(buf);
				const value = makeValue(url, alt, dimensions);

				transformToJsxNode(parent, index, value, position);
			}
		}
	}
}

syntax highlight

prismjs側でやる処理であるTokenizeをカスタムローダー側でやるだけ。refactor.registerのところで使いたい言語をロードすればよい。これに関してはamdxのコードをそのまま使用させていただいた。というかこのレポジトリは熟読させていただいています。ありがとうございます。

const visit = require("unist-util-visit");

module.exports = highlighter;

// @ts-ignore
const refractor = require("refractor/core.js");

refractor.register(require("refractor/lang/javascript.js"));

function highlighter() {
	return (tree) => {
		visit(tree, "code", (node) => {
			const [lang] = (node.lang || "").split(":");
			if (lang) {
				node.lang = lang;
				if (!refractor.registered(lang)) {
					return;
				}
				if (node.data == null) {
					node.data = {};
				}
				node.data.hChildren = refractor.highlight(node.value, lang);
			}
		});
	};
}

この記事に関するIssueをGithubで作成する

Read Next