Nuxt3 + highlight.jsでシンタックスハイライト

Nuxt3

highlight.js

これまでハイライトにはPrism.jsを使ってたんですが、Nuxt3に移行してこれまでの使い方ではうまくいかなかったことと(headerで読み込むと問題ないようですが)、Vueのハイライトに対応してないことが気になってまして、ハイライトのライブラリとしては同じく有名なhighlight.jsに変えることにしました。

まあ、ハイライトの仕方はPrism.jsの方が好みだったんですが(highlight.jsはシンプルなハイライトが方針らしいですね)。あと、ついでにハイライト済みのデータを出力できるようにしようかなと考えた次第です。

インストール

まずは普通にインストール。

npm i highlight.js
# or
yarn add highlight.js

プラグイン本体の作成

ではプラグインを作っていきます。cheerioと組み合わせて作っていきます。

import * as cheerio from 'cheerio'
import hljs, { languages } from './registerLanguage'

export default defineNuxtPlugin(() => {
  return {
    provide: {
      highlightAll(text: string) {
        const $ = cheerio.load(text)
        $('code').each((_, elm) => {
          if (!($(elm).hasClass('nohighlight'))) {
            const className = $(elm).attr('class') || ''
            let result = ''
            if (Object.keys(languages).includes(className)) {
              result = hljs.highlight($(elm).text(), { language: className }).value
            } else {
              result = hljs.highlightAuto($(elm).text()).value
            }
            $(elm).html(result)
            $(elm).addClass('hljs')
          }
        })
        return $('body').html()
      },
    },
  }
})

記事全文のテキストを受け取り、nohighlightクラスを持たないcodeタグの中身をすべてハイライト。その際、codeに指定された言語クラスを読み取りその言語でハイライトするんですが、言語指定が無い場合でもhighlight.jsが自動判別でハイライトするようにしてます。return $('body').html()としているのは、出力にhtmlやbodyのタグが付与されないようにするためです。

言語ファイルの読み込みおよび登録

次に使用する言語ファイルの読み込みと登録。registerLanguage.tsというファイルを作りました。highlight.jsは全部読み込むと結構なサイズとなってしまいますので、coreファイルと使用する言語だけimportします。使うcssはお気に入りにのvs2015を少しカスタマイズしたものです。

import hljs from 'highlight.js/lib/core'
import './scss/vs2015-custom.scss'

import bash from 'highlight.js/lib/languages/bash'
import css from 'highlight.js/lib/languages/css'
import scss from 'highlight.js/lib/languages/scss'
import stylus from 'highlight.js/lib/languages/stylus'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import xml from 'highlight.js/lib/languages/xml'
import vue from './lib/languages/vue'

/* eslint-disable */
export const languages: { [k: string]: any } = {
  bash: bash,
  css: css,
  scss: scss,
  stylus: stylus,
  javascript: javascript,
  js: javascript,
  jsx: javascript,
  typescript: typescript,
  ts: typescript,
  tsx: typescript,
  html: xml,
  xml: xml,
  vue: vue,
}
/* eslint-enable */

Object.entries(languages).forEach(([language, parser]) => {
  hljs.registerLanguage(language, parser)
})

export default hljs

coreだけ読み込みし、それに使用する言語のみを登録したものをexport、それを先ほどのプラグインで利用します。

Vueのハイライトに対応させる

標準ではVueに対応してませんので、色んなサイトと他の言語ファイルを参考にVueに対応した言語ファイルを作ります。

function vue(hljs) {
  return {
    subLanguage: 'xml',
    contains: [
      hljs.COMMENT('<!--', '-->', {
        relevance: 10,
      }),
      {
        begin: /^(\s*)(<script>)/gm,
        end: /^(\s*)(<\/script>)/gm,
        subLanguage: 'javascript',
        excludeBegin: true,
        excludeEnd: true,
      },
      {
        begin: /^(?:\s*)(?:<script(?:\s+setup)?\s+lang=(["'])ts\1(?:\s+setup)?>)/gm,
        end: /^(\s*)(<\/script>)/gm,
        subLanguage: 'typescript',
        excludeBegin: true,
        excludeEnd: true,
      },
      {
        begin: /^(\s*)(<style(\s+scoped)?>)/gm,
        end: /^(\s*)(<\/style>)/gm,
        subLanguage: 'css',
        excludeBegin: true,
        excludeEnd: true,
      },
      {
        begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])(?:s[ca]ss)\1(?:\s+scoped)?>)/gm,
        end: /^(\s*)(<\/style>)/gm,
        subLanguage: 'scss',
        excludeBegin: true,
        excludeEnd: true,
      },
      {
        begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])stylus\1(?:\s+scoped)?>)/gm,
        end: /^(\s*)(<\/style>)/gm,
        subLanguage: 'stylus',
        excludeBegin: true,
        excludeEnd: true,
      },
      {
        begin: /\{\{/g,
        end: /\}\}/g,
        subLanguage: 'javascript',
        excludeBegin: true,
        excludeEnd: true,
      },
      {
        begin: /\s(:|v-|@)[a-zA-Z]+="/g,
        end: /"/g,
        subLanguage: 'javascript',
        excludeBegin: true,
        excludeEnd: true,
      },
      {
        begin: /\s:style="/g,
        end: /"/g,
        subLanguage: 'css',
        excludeBegin: true,
        excludeEnd: true,
      },
    ],
  }
}

export { vue as default }

highlight.jsの言語ファイルではsubLanguageなる機能がありまして、基本となる言語の中に正規表現でマッチする箇所があったらそこをさらに指定の言語でパースできます。Vueのファイル構造はまさにそのような仕様ですので、それぞれ指定していきます。基本はxmlにし、scriptやstyleでそれぞれ言語指定していきました。他にはマスタッシュ構文やディレクティブくらいでしょうか。

あと、"**.js.js"というファイルも必要っぽいんで、これは他の言語ファイルからコピーしてきました。

page内での利用

以上で、pageで随時利用できます。取得した記事を放り込むだけです。

<script setup>
const nuxtApp = useNuxtApp()
...
const highlightedText = ref('')
highlightedText.value = nuxtApp.$highlightAll(content.body)
...
</script>

これでtemplateで利用できますので、ビルド時にハイライト済みのHTMLが出力されるようになりました。

<div class="content" v-html="highlightedText"></div>

このようにcheerioとの組み合わせで比較的簡単に導入できました。