📰

ネタバレ記事を書くときに便利な VuePress v2 用プラグイン

2022/01/06に公開

映画『屍人荘の殺人』についてのブログ記事を書こうと思ったのですが、あらすじ等では触れられていないキーワードがあり、いわゆるネタバレにあたるため書き方に注意する必要がありました。

そこで「ネタバレ Switch」なるものをつくり、そのスイッチを ON にしたときだけ、そのキーワードが現れるようなものを作ろうと思い立ちました。
先日ブログを VuePress v2 + Vite にした際にプラグインを npm パッケージとして公開したので、同じようにプラグインにしようと思ったのです。

動作イメージはこうなります。

ネタバレ・スイッチの動画

実際の記事はこちらです。

https://kohji.blog/a/shijinsou-movie.html

完成した npm パッケージはこちらです。

https://www.npmjs.com/package/vuepress-plugin-netabare-switch

VuePress v2 のプラグイン作成の大まかな流れ

前回がはじめての作成で、今回が2回目なのでまだ経験値としては充分ではないものの、何となく手順は次のような感じだと思います。

  1. 機能を作成する
  2. プラグインにする
  3. プラグインのオプション等を作成する
  4. ドキュメント(やテスト等)を整える
  5. NPM に公開する

1 については Vue.js 特有の話ですが、ネタバレ Switch てきなものを作りたいひとは Svelte や React でも同様の手順で作成可能だと思います。
2 については VuePress v2 特有の話ですが、それ以降については一般的な npm パッケージの公開と同様なのかと思います。

ネタバレ Switch 機能を作成

機能の整理

機能の要件としては、概ね次のとおりです。

  • ネタバレ ON OFF の Switch がある
  • Switch は <input> 要素と Tailwind CSS (Windi CSS)で作成する(プラグインにする際に CSS 等は変更可能にする)
  • Switch が ON = true の場合と OFF = false の場合で表示される内容が変化する
  • <span><div> の2つの Component を用意する
  • ネタバレの内容と、ネタバレしてない内容は、それぞれ <slot> を用いて実装する

こんな感じでしょうか。
漏れがあったら作りながら修正していきたいと思います。

ちなみにこの記事自体もプラグイン作成と同時並行で書き進めています。
先にこの記事内にコードを書き、それを実際のリポジトリにコピーしながら作成しています。

使い方から考える

実際に利用する際のコードを考えてみます。
VuePress では記事ファイルは markdown ですが、そのなかで Vue Component を呼び出すことが可能です。

Switch 部分は次のように想定しました。

この記事は重大なネタバレを含みます。
鑑賞前の方のために、記事中ではネタバレに配慮した表現にしています。

鑑賞後の方については下記のスイッチをオンにすると、ネタバレを含む表現が表示され読みやすくなります。

<NetaBareSwitch/>

## 犯人は<NetaBareSpan bare="ヤス">◯◯</NetaBareSpan>

<NetaBareDiv>

ポートピア連続殺人事件は本格的な推理ゲームです。<br>
ゲームの進行とともに真実が明らかになっていき、最後はあっと驚く展開が待ち受けています。
</NetaBareDiv>


<NetaBareDiv bare>

ポートピア連続殺人事件は犯人の名前が知れわたっているゲームです。<br>
一緒に推理を進めていく刑事が真犯人であるため、プレイヤーははじめから真犯人についての情報を知っています。
</NetaBareDiv>

機能を実装する

ではさっそく機能を作っていきましょう。
さほど難しい機能ではありませんが Vue.js の Component や slot の知識は必要です。

まずは Switch から作ります。
components フォルダのなかに NetaBareSwitch.vueNetaBareDiv.vue 等を作成していきます。

docs/.vuepress/components 以下に作成することになります

components
- NetaBareDiv.vue
- NetaBareSpan.vue
- NetaBareSwitch.vue

このようなフォルダ構成にしておくと <NetaBareSwitch /> のように呼び出すことが可能です。

この時点で register-components をまだ使っていない方は、次のようにインストールおよび設定を行っておいてください。

npm i -D @vuepress/plugin-register-components@next
mkdir docs/.vuepress/components
docs/.vuepress/config.ts
import { defineUserConfig } from 'vuepress-vite'
import { path } from '@vuepress/utils'
export default defineUserConfig<DefaultThemeOptions, ViteBundlerOptions>({
  // 必要な箇所のみ記載しています
  plugins: [
    ['@vuepress/register-components', {
      componentsDir: path.resolve(__dirname, './components'),
    }],
  ],
})

デザインは Tailwind CSS (Windi CSS) で

トグルスイッチを input[type=checkbox] タグで作成します。
Tailwind CSS によるコードを見つけてきて、それをもとに実装しました。
色は Markdown Container の tip に合わせることで Dark モードに切り替えても違和感がないようにします。

docs/.vuepress/components/NetaBareSwitch.vue
<style scoped>
.toggle-checkbox {
  background-color: var(--c-text);
}
.toggle-checkbox:checked {
  right: 0;
  background-color: var(--c-tip-bg);
}
.toggle-checkbox + .toggle-label {
  box-sizing: border-box;
  border: 1px solid var(--c-tip);
  background-color: var(--c-tip-bg);
}
.toggle-checkbox:checked + .toggle-label {
  background-color: var(--c-tip);
}
</style>

Checkbox の値の変更をきっかけに呼び出し側の値を変更する

当初は、子 Component 側で Checkbox の値が変更されたら、親 Component に emit し、親 Component 側で値を制御するようにしていました。

Vue 3 では(複数の v-model を使用できるようになり)デフォルトの props も value から modelValue に変更になり、また emit についても 'input' から 'update:modelValue' と変更になっています。

しかし各 Component から Composable Function を使用して State を読み出せば、記事ファイル(ページコンポーネント)では値を制御する必要がなくなることに気づいて書き直しました。

それ以外の箇所で困ったことは <label> 要素の for 属性が <input type="checkbox">id を指定するため、任意の id を割り振る必要があるところ。
Composable Function 内で URL もしくは name props の値より id に使用できる文字を作成しています。

下記のコードに加え class 属性も props で指定できるようにし、一旦完成としました。

<script setup lang="ts">
import { useNetaBare } from '../composables/useNetaBare'

interface Props {
  name?: string
}

const { name } = defineProps<Props>()

const { checked, idKey: switchId } = useNetaBare(name)
</script>

<template>
  <div>
    <div>
      <input
        v-model="checked"
        :id="switchId"
        type="checkbox"
        class="toggle-checkbox"
      />
      <label :for="switchId" class="toggle-label" />
    </div>
    <label :for="switchId"><slot name="default">ネタバレを表示</slot></label>
  </div>
</template>
docs/.vuepress/composables/useNetaBare.ts
import { createSharedComposable, createGlobalState, useStorage } from '@vueuse/core'
import { useRoute } from 'vue-router'

const encode = (text: string) => encodeURIComponent(text).replace(/%/g, ':')

const createNetaBare = (key = useRoute().path) => {
  const idKey = `netabare-${encode(key)}`
  const storageKey = `netabare-${key}`
  const useState = createGlobalState(() => useStorage(storageKey, false))
  const checked = useState()
  return { checked, idKey, storageKey, useState }
}

export const useNetaBare = createSharedComposable(createNetaBare)

こちらは VueUse の機能を利用して複数の Component をまたいで使用可能な State を用意しています。
またリロードしたときのために LocalStorage に保存するようにしました。

ネタバレを記述する箇所で使用する Component

次のふたつとも、次のように Composable Function useNetaBare() を使って、トグルスイッチの値を取得しています。

<script setup lang="ts">
import { useNetaBare } from '../composables/useNetaBare'
interface Props {
  name?: string
}

const { name } = defineProps<Props>()
const { checked } = useNetaBare(name)
</script>

<NetaBareDiv>

段落 <p> まとめてネタバレを記述する際に使用します。
当初は <template #bare></template #bare> のなかにネタバレを含む内容を記述し、それ以外の箇所はネタバレに配慮した内容を記述するようにしました。

しかしこれだと、記事を書く際にインデントを必要とするため bare attribute と isBare prop を使って <NetaBareDiv> 自体に設定できるように変更しました。

基本的には <NetaBareDiv bare></NetaBareDiv> としてネタバレを記述し <NetaBareDiv></NetaBareDiv> もしくは <NetaBareDiv :isBare="false"></NetaBareDiv> のときはネタバレに配慮した記述をすることにします。

<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import { useNetaBare } from '../composables/useNetaBare'

interface Props {
  name?: string
  isBare?: boolean
}

// 初期値を設定しないと false になってしまうため undefined で設定する
const { name, isBare } = withDefaults( defineProps<Props>(), { isBare: undefined })
const attrs = useAttrs()

// isBare prop の値を優先し、なければ bare attribute があるかどうか
// isBare prop が false なら bare attribute があっても false
const bare = computed(() => isBare ?? Object.keys(attrs).includes('bare'))

const { checked } = useNetaBare(name)
</script>

<template>
  <div v-if="checked === bare">
    <slot></slot>
  </div>
</template>

<NetaBareSpan>

ネタバレを含む単語を記述する際に使用します。
bare prop にネタバレを含む語句で記述し、タグ内にネタバレに配慮した語句で記述するようにしました。

ネタバレ Switch をプラグインにする

通常プラグインにする段階でオプション等の指定により汎用的に扱えるように変更します。
今回はとくにプラグインの機能として追加するオプションは用意しませんでしたので、設定もとても簡単です。

docs/.vuepress/config.ts
plugins: [
  ['vuepress-plugin-netabare-switch'], // 追加
],

とはいえ Component の名称や LocalStorage に保存するキー名は、利用者側の機能と重複する可能性がゼロではありません。
そのため、たとえば次のように指定できるようにしておきます。

docs/.vuepress/config.ts
plugins: [
  ['vuepress-plugin-netabare-switch', {
    // LocalStorage のキーの prefix
    // default: 'netabare'
    keyPrefix: 'nb',
    // Component 名の prefix (この場合は <NBSwitch/> に)
    // default: 'NetaBare'
    componentPrefix: 'NB',
  }],
],

プラグイン用のフォルダを用意する

今回も docs/.vuepress/plugin フォルダのなかにプラグインを設置したいと思います。
npm パッケージにしてからは npm install -D すると node_modules 以下に配置されますので、作成中のコード用となります。

mkdir -p docs/.vuepress/plugin/vuepress-plugin-netabare-switch/src/node
cd docs/.vuepress/plugin/vuepress-plugin-netabare-switch
mkdir src/client

まずは Git リポジトリを作成します。

git init
echo 'lib' >> .gitignore
echo 'node_modules' >> .gitignore
echo 'tsconfig.tsbuildinfo' >> .gitignore
git add .
git commit

つづいて GitHub CLI を使い GitHub にリポジトリを作成し Push します。

gh repo create
git push -u origin main

package.json を作成する

プラグインのフォルダ内に package.json を作ります。

npm init

TypeScript を使用しているので tsconfig.json も作成します。

npx tsc --init

開発中は "main": "src/node/index.ts" のように直接 .ts を利用するようにしておき JavaScript への書き出しを行うようになったら書き換えると(手順に慣れていないうちは)良さそうです。

プラグインのメイン

プラグインのメインは VuePress の build 等の際に、一度だけ実行される Function です。
引数で optionsapp を受け取って、必要な情報を返します。
公式ドキュメントを読みながら進めていきましょう。

https://v2.vuepress.vuejs.org/advanced/plugin.html

docs/.vuepress/plugin/vuepress-plugin-netabare-switch/src/node/index.ts
import type { Plugin } from '@vuepress/core'
import { path } from '@vuepress/utils'

export interface NetaBarePluginOptions {
  componentPrefix: string
  keyPrefix: string
}

export const netabareSwitchPlugin: Plugin<NetaBarePluginOptions> = ({
  componentPrefix = 'NetaBare',
  keyPrefix = 'netabare'
}) => {
  const name = 'vuepress-plugin-netabare-switch'
  const clientAppEnhanceFiles = path.resolve(
    __dirname,
    `../client/clientAppEnhance.ts`
  )
  const define = {
    __NETABARE_COMPONENT_PREFIX__: componentPrefix,
    __NETABARE_KEY_PREFIX__: keyPrefix,
  }

  return {
    name,
    clientAppEnhanceFiles,
    define,
  }
}

export default netabareSwitchPlugin

ポイントはふたつだけです。

clientAppEnhanceFiles

最終的に return する clientAppEnhanceFiles で、クライアント側で実行するファイルを指定します。
VuePress は node (Node App)client (Client App) のふたつの動作を行います。
サーバー側(.mdファイルをレンダリングする)と、ブラウザ側( SPA として表示する)です。

プラグインの登録はビルドのタイミングですから Node App が担います。
一方で Component 内の動作は Component が読み込まれる際に動作する必要があるので Client App で行います。

clientAppEnhanceFiles には Component の登録等を行うためのファイルを登録します。
後述する client/clientAppEnhance.ts です。

define

define には client/clientAppEnhance.ts 内に引き渡す変数を設定します。
ビルドの際にファイル内に指定した値を使用します。

オプションで指定した2点はどちらもクライアント側で利用するため、そのまま引き渡しています。

defineClientAppEnhance

client/clientAppEnhance.ts では Vue Component を Vue に登録します。

docs/.vuepress/plugin/vuepress-plugin-netabare-switch/src/client/clientAppEnhance.ts
import { defineClientAppEnhance } from '@vuepress/client'
import { NetaBareSwitch, NetaBareDiv, NetaBareSpan } from './components'

declare const __NETABARE_COMPONENT_PREFIX__: string

export default defineClientAppEnhance(({ app }) => {
  const prefix = __NETABARE_COMPONENT_PREFIX__
  app.component(`${prefix}Switch`, NetaBareSwitch)
  app.component(`${prefix}Div`, NetaBareDiv)
  app.component(`${prefix}Span`, NetaBareSpan)
})

components フォルダには Vue Component を export するだけのファイルを作っておきます。

docs/.vuepress/plugin/vuepress-plugin-netabare-switch/src/client/components/index.ts
// @ts-nocheck
export { default as NetaBareSwitch } from './NetaBareSwitch.vue'
export { default as NetaBareDiv } from './NetaBareDiv.vue'
export { default as NetaBareSpan } from './NetaBareSpan.vue'

Vue.js の Plugin であればこのページにあるような手順が必要ですが VuePress Plugin は上記の手順が用意されているので、さほど作業はありません。

動作確認

ここまでできれば、いったん通常通り動作するかを確認してみます。
VuePress のプロジェクトのルートフォルダに戻り config.ts を書き換えた後 yarn docs:dev をしましょう。

config.ts
import { defineUserConfig } from 'vuepress-vite'
import { path } from '@vuepress/utils'
export default defineUserConfig<DefaultThemeOptions, ViteBundlerOptions>({
  // 必要な箇所のみ記載しています
  bundlerConfig: {
    viteOptions: {
      plugins: [
        WindiCSS({
          scan: {
            include: [
              path.resolve(__dirname, './**/*.{vue,html,md}'),
              // 次の行を追加しプラグイン内も走査する
              path.resolve(__dirname, '../../node_modules/vuepress-plugin-netabare-switch/lib/**/*.{vue,html,md}'),
            ],
            exclude: [
              'node_modules/**/*',
            ],
          },
        }),
      ],
    },
  },
  plugins: [
    [path.resolve(__dirname, './plugin/vuepress-plugin-netabare-switch')],
  ],
})

うまく動作すればプラグインの完成です🎉

npm パッケージとして公開する

知識不足により、実はここからが難産でした。
前回作成したのは node 側だけのアプリだったのですが、今回は client もあるということで、理解が曖昧だったこともありとても苦労しました。

tsc コマンドで TypeScript を JavaScript に書き換えるだけでは .vue ファイルなどが適切に処理されません。
単純にコピーすればよいだろうと cpx というコマンドをビルド時に行うのですが、それでも謎のエラーが出続けました…

丸2日くらいかけて解決したのですが、概ね次のようなポイントを押さえる必要があります。

  • nodeclient で動作ロジックが違う
    • node 側では commonjs な記述が必要
    • client 側では ESModule な記述が必要

ということです。
分かってしまえばとても単純なことでしたが Bundler の知識等も必要で、苦労した分、スキルアップができました(強がり)

途中で @unjs/unbuild を使うことにしました。
下記の記事がわかりやすいのですが、過渡期的に mjscjs が混在するいま、両方同時に書き出す Bundler を利用するのが良さそうです。

https://antfu.me/posts/publish-esm-and-cjs

@unjs/unbuild は Nuxt 3 等でも利用されているもの。
細かい設定なしに rollup.js による書き出しを行ってくれます。

記事内では設定なしに両ファイルが書き出されるとありましたが、試した限りでは自分で設定する必要がありました。
ほかにも .vue 内の TypeScript にも対応してくれるとのことですが、なぜかそのまま書き出されてしまいます。
また cjsBridgerollup.cjsBridge に仕様変更があります。

https://github.com/unjs/unbuild

unbuild の設定ファイルを作成する

そしてできあがった設定ファイルがこちらです。

docs/.vuepress/plugin/vuepress-plugin-netabare-switch/build.config.ts
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  outDir: 'lib',      // デフォルトは dist
  clean: true,        // あらかじめ lib を空にする
  entries: [          // 対象のフォルダやファイルを指定
    { input: './src/', format: 'cjs' },
    { input: './src/', format: 'esm' },
  ],
  declaration: true,  // generate .d.ts files
  rollup: {
    cjsBridge: true,  // __dirname, require
  },
})
docs/.vuepress/plugin/vuepress-plugin-netabare-switch/package.json
{ // 抜粋
  "main": "lib/node/index.js",
  "types": "lib/node/index.d.ts",
  "files": ["lib"],
  "scripts": {
    "clean": "npx rimraf lib *.tsbuildinfo",
    "build": "unbuild",
    "prepare": "npm run build"
  },
}

ビルドする

npm run build

動作確認して問題なく動けば公開の準備がすべて整いました🎉

公開する

README.md を用意し必要なコミットをすべて終えたらいよいよ公開です。

今回も np を使用します。
最初の質問でずらずらとファイルが表示されましたが、そのまま進めます。

$ npx np --no-test

? The following new files will not be part of your published package:
- build.config.ts
- doc/images/netabare-switch.gif
- src/client/clientAppEnhance.ts
- src/client/components/index.ts
- src/client/components/NetaBareDiv.vue
- src/client/components/NetaBareSpan.vue
- src/client/components/NetaBareSwitch.vue
- src/client/composables/useNetaBare.ts
- src/node/index.ts
- tsconfig.json
Continue? Yes

Publish a new version of vuepress-plugin-netabare-switch (current: 0.0.1)

Commits:
(省略)

Commit Range:
e1d1fd94fcf42d24499b6b9c4832ff1972523ac4...main

Registry:
https://registry.npmjs.org/

? Select semver increment or specify new version Other (specify)
? Version 0.5.0

  ✔ Prerequisite check
  ✔ Git
  ✔ Installing dependencies using npm
  ✔ Bumping version using npm
  ✔ Publishing package using npm
  ✔ Enabling two-factor authentication
  ✔ Pushing tags
  ✔ Creating release draft on GitHub

 vuepress-plugin-netabare-switch 0.5.0 published 🎉

途中2段階認証を聞かれます。
たまにのことだと忘れてしまいますが npmjs.com で設定した際に使ったアプリ (Authenticator) 等で行います。

GitHub でリリースノートを書いたら、無事完成です🎉

https://www.npmjs.com/package/vuepress-plugin-netabare-switch

https://github.com/monsat/vuepress-plugin-netabare-switch

おわりに

機能の実装に半日、リファクタリングとプラグイン化に1日、そして npm パッケージ化に2日くらいかかりました。
想定よりは時間がかかってしまいましたが、完成してほっとしています。

この記事が、誰かの一助になれば幸いです。

書いた内容は執筆時点の理解のため、間違いや誤解を招く表現等が混ざっている可能性があります。
正確な記述に直したいと思いますので、気づいた方は修正内容をコメント等で教えてもらえますでしょうか。

Discussion