blog

プレビューモードの不具合を修正

当ブログのプレビューモード の修正がようやく終わりました。思ったより時間がかかった…

Next.jsのプレビューモードで500エラーが出ていた原因は2つあって、ひとつはまだ公開していない記事のpublishedAt(公開日時)をNext.js側が取得できず、そこでエラーになっていた、というしょうもないもので、これはすぐに発見して修正できました。

もうひとつはコードハイライトに使っているshikiライブラリのテーマや言語解析の設定JSONファイルが本番環境では見つからないというもので、こちらの原因の調査はなかなか時間がかかりました。理由は、このエラーがローカルの環境では発現しなかったためです。

今回、Vercelの本番環境でプレビューモードを使おうとしていた時に発生していたエラーがこちらです。

[GET] /blog/some-post
11:43:39:35
2021-04-06T02:43:40.276Z	441914c8-1c6d-499b-9e0b-c36e6d3b502e	ERROR	[Error: ENOENT: no such file or directory, open '/var/task/node_modules/shiki/themes/nord.json'] {
  errno: -2,
  syscall: 'open',
  path: '/var/task/node_modules/shiki/themes/nord.json'
}
RequestId: 441914c8-1c6d-499b-9e0b-c36e6d3b502e Error: Runtime exited with error: exit status 1
Runtime.ExitError

Vercelにデプロイした時だけエラーが発生した原因

これは、shikiがコードをハイライトする際に、ハイライトのテーマや言語解析の設定JSONファイルを、Node.jsライブラリのfsを使って動的にアクセスしていることが原因でした。要はビルドした結果に、shikiのハイライトの処理は含まれていても、テーマや言語解析の設定JSONファイルは含まれていなかったのです。

じゃあなんでローカルでnext buildでビルドしたアプリをnext startで起動した時は普通に動いたのか?それはビルドした環境、つまり同じプロジェクトの中のnode_modulesshikiから、必要な設定ファイルを読み込んでいたからです。

その証拠に、next buildした後に、npm uninstall shikiを実行して、プロジェクト内からshikiを削除した後にnext startを実行すると、Vercelの本番環境と同じエラーが発生しました

対処法

対処としては、プロジェクトにsrc/data/shikiディレクトリを作って、さらにその中にthemesディレクトリとlanguagesディレクトリを作り、そこに必要最低限のテーマや言語解析用のJSONファイルを引っ張ってきて、そのファイルをshikiのシンタックスハイライターに参照させるように修正しました。

ハイライトのテーマ用のJSONは普段使うテーマひとつがあれば良いし、言語解析用のJSONについても、とりあえず自分の場合はtstsxが使えれば事足ります。

// src/lib/transpiler.ts
import path from 'path';
import unified from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import rehypeShiki from '@leafac/rehype-shiki';
import { loadTheme, getHighlighter } from 'shiki';

const shikiDirectory = path.join(process.cwd(), 'src', 'data', 'shiki');
const shikiThemes = path.join(shikiDirectory, 'themes');

const theme = loadTheme(path.join(shikiThemes, 'nord.json'));

const shikiLanguages = path.join(shikiDirectory, 'languages');

export const markdownToHtml = async (markdown: string) =>
  unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypeShiki, {
      highlighter: await getHighlighter({
        theme: await theme,
        langs: [
          {
            id: 'typescript',
            scopeName: 'source.ts',
            path: path.join(shikiLanguages, 'typescript.tmLanguage.json',),
            aliases: ['ts'],
          },
          {
            id: 'tsx',
            scopeName: 'source.tsx',
            path: path.join(shikiLanguages, 'tsx.tmLanguage.json'),
          },
        ],
        theme: await shikiTheme,
        langs: languages,
      }),
    })
    .use(rehypeStringify)

しかし、テーマはともかく、言語に関しては後々使いたい言語が増えた場合に、上記の設定をいちいち書き換えるの面倒です。

そこで、新たに以下のような関数を追加しました。

// src/lib/highlighter.ts
import fs from 'fs';
import path from 'path';
import { loadTheme } from 'shiki';

const shikiDirectory = path.join(process.cwd(), 'src', 'data', 'shiki');
const shikiLanguages = path.join(shikiDirectory, 'languages');

type ShikiLanguage = {
  id: string;
  scopeName: string;
  path: string;
  aliases?: string[];
};

const getShikiLanguages = (
  langAliases: { [lang: string]: string[] } = {},
): ShikiLanguage[] => {
  const allLangs = fs.readdirSync(shikiLanguages);
  const langs = allLangs.map((lang) => {
    const fullPath = path.join(shikiLanguages, lang);
    const fileContent = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
    const name: string = fileContent?.name ?? '';
    const scope: string = fileContent?.scopeName ?? '';
    const langData = {
      id: name,
      scopeName: scope,
      path: fullPath,
    };
    const isAliases = name in langAliases;

    return isAliases ? { ...langData, aliases: langAliases[name] } : langData;
  });

  return langs;
};

const langAliases = {
  typescript: ['ts'],
};

// これをshiki.getHighlighterに渡してやる
export const shikiLangs = getShikiLanguages(langAliases);

こうすれば、使いたい言語の設定JSONファイルをsrc/data/shiki/languagesに追加するだけで、getShikiLanguages()shiki.getHighlighter()に渡す用の言語設定の配列を生成してくれます。

ちなみに

Next.jsでshikiを使おうとするとこういう問題が起きることについては、GitHubのスレッドでも報告がなされていました。(報告をされていたのは、Prismaの創業者であるJohannes Schickling氏でした)

Usage with Next.js SSR

ただ、その後のやり取りは途絶えていますし、Schickling氏が要望していたshikinftへの対応がなされるかはどうかはわかりませんね。

とは言っても、shikiのシンタックスハイライターとしての性能は申し分ないですし、結局はそれを使わせてもらっている側が、自分の使いたいフレームワークで上手くチューニングしていくのが筋なんでしょうね。

おわりに

やっぱ、こういう長い記事を書く時は、プレビューモードが使えた方が快適ですね😌

portrait

takahira

デザインと関数型プログラミングが好きです。