ちゃっくの泣き言

ブログのシステムを改修した

投稿日時 2025-04-26 13:37

改修前

このブログは記事はMarkdownをTMLに変換し配信しており、Next.jsで実装している。

もう少し詳細に説明すると、Markdownをremark-parseでパースし、remark-rehypeでhast(HTML AST)に変換した後に、rehype-stringifyでHTML文字列に変換している。 そして、HTML文字列をdivタグにdangeroudlySetInnerHTMLで埋め込んでいた。 実装については以下のような感じになっていた。

import rehypeHighlight from "rehype-highlight";
import remarkParse from "remark-parse";
import remarkRehype from 'remark-rehype';
import rehypeStringify from "rehype-stringify"
import { unified } from "unified";

const markdownToHtml = async (markdown: string): Promise<string> => {
    const result = await unified()
        .use(remarkParse)
        .use(remarkRehype, { allowDangerousHtml: true })
        .use(rehypeHighlight)
        .use(rehypeStringify, { allowDangerousHtml: true })
        .process(markdown);
    return result.toString();
}

const Page = async () => {
    // 省略
    const html = await markdownToHtml(markdown);
    return (
        <div
            dangerouslySetInnerHTML={{_html: html}}
        />
    );
}

解決したい問題と原因

このブログで利用するMarkdownはHTMLの埋め込みを可能にしている。 Twitter PublishをHTMLとして埋め込んでツイートを表示しようとした。しかし、トップページから記事に遷移すると、埋め込んだTweetが表示されないという問題が発生した。一方で、他のページから遷移せずに直接記事のURLを開くとTweetが表示されていた。

調査するとMarkdownに埋め込んだHTMLにscriptタグが含まれている場合に今回の問題が発生する事がわかった。Twitter Publishを用いてTweetを埋め込むとscriptタグを利用して https://platform.twitter.com/widgets.js を読み込むので、今回の問題が発生してしまった。

このブログの実装ではページ遷移には next/linkを利用している。next/link による遷移はクライアントサイドのナビゲーションが行われており、Reactは dangerouslySetInnerHTMLを処理する際に内部的に innerHTMLを利用する。しかし、HTML5ではinnerHTMLはscriptタグを実行しない。そのため上記の問題が発生してしまった。

一方で直接URLを開いた場合はサーバーサイドレンダリングが行われてからクライアントに渡されるので、scriptタグの実行が行われ、問題が発生しなかった。

検討した方針

調べていると、 @next/mdxnext-mdx-remoteを利用するとやりたいことは実装できそうに見えた。ただ、今のremarkとrehypeで変換する方法は、比較的トラブルシューティングがしやすそうな構成でありできれば維持したいなぁと思った。 正直に言うと @next/mdxの導入資料を見ると next.config.js を変更しているが、next.config.jsの変更に対する苦手意識があり、これを変更したくなかった。

最終的な方針

最終的には rehype-reactというライブラリを見つけ、これを利用することにした。 このライブラリはrehypeの中間表現(hast)をReact Componentに変換してくれる。 これにより dangerouslySetInnerHTMLを利用する必要がなくなった。 また、rehype-reactはコンポーネントの置き換えにも対応しており、scriptタグを next/scriptに置き換えている。 実際の実装は以下のような感じになった。

import rehypeHighlight from "rehype-highlight";
import remarkParse from "remark-parse";
import remarkRehype from 'remark-rehype';
import * as prod from 'react/jsx-runtime';
import React from "react";
import rehypeReact from "rehype-react";
import { unified } from "unified";
import rehypeRaw from "rehype-raw";
import Script from "next/script";

const markdownToReact = async (markdown: string): Promise<JSX.Element> => {
    const result = await unified()
        .use(remarkParse) // md -> mdast
        .use(remarkRehype, { allowDangerousHtml: true }) // mdast -> hast
        .use(rehypeRaw) // hast -> hast
        .use(rehypeHighlight) // hast -> hast
        .use(rehypeReact, {
            ...prod,
            components: {
                script(props) {
                    return React.createElement(Script, {
                        ...props,
                        strategy: "lazyOnload"
                    })
                }
            }
        })
        .process(markdown);
    return result.result;
}


const Page = async () => {
    // 省略
    const rc = await markdownToReact(markdown);
    return rc;
}

参考

(stackoverflow)React: Script tag not working when inserted using dangerouslySetInnerHTML

(MDN) innerHTML