ネタバレ記事を書くときに便利な VuePress v2 用プラグイン
映画『屍人荘の殺人』についてのブログ記事を書こうと思ったのですが、あらすじ等では触れられていないキーワードがあり、いわゆるネタバレにあたるため書き方に注意する必要がありました。
そこで「ネタバレ Switch」なるものをつくり、そのスイッチを ON
にしたときだけ、そのキーワードが現れるようなものを作ろうと思い立ちました。
先日ブログを VuePress v2 + Vite にした際にプラグインを npm パッケージとして公開したので、同じようにプラグインにしようと思ったのです。
動作イメージはこうなります。
実際の記事はこちらです。
完成した npm パッケージはこちらです。
VuePress v2 のプラグイン作成の大まかな流れ
前回がはじめての作成で、今回が2回目なのでまだ経験値としては充分ではないものの、何となく手順は次のような感じだと思います。
- 機能を作成する
- プラグインにする
- プラグインのオプション等を作成する
- ドキュメント(やテスト等)を整える
- 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.vue
や NetaBareDiv.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
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 モードに切り替えても違和感がないようにします。
<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>
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 をプラグインにする
通常プラグインにする段階でオプション等の指定により汎用的に扱えるように変更します。
今回はとくにプラグインの機能として追加するオプションは用意しませんでしたので、設定もとても簡単です。
plugins: [
['vuepress-plugin-netabare-switch'], // 追加
],
とはいえ Component の名称や LocalStorage に保存するキー名は、利用者側の機能と重複する可能性がゼロではありません。
そのため、たとえば次のように指定できるようにしておきます。
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 です。
引数で options
や app
を受け取って、必要な情報を返します。
公式ドキュメントを読みながら進めていきましょう。
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 に登録します。
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 するだけのファイルを作っておきます。
// @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
をしましょう。
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日くらいかけて解決したのですが、概ね次のようなポイントを押さえる必要があります。
-
node
とclient
で動作ロジックが違う-
node
側ではcommonjs
な記述が必要 -
client
側ではESModule
な記述が必要
-
ということです。
分かってしまえばとても単純なことでしたが Bundler の知識等も必要で、苦労した分、スキルアップができました(強がり)
途中で @unjs/unbuild
を使うことにしました。
下記の記事がわかりやすいのですが、過渡期的に mjs
と cjs
が混在するいま、両方同時に書き出す Bundler を利用するのが良さそうです。
@unjs/unbuild
は Nuxt 3 等でも利用されているもの。
細かい設定なしに rollup.js による書き出しを行ってくれます。
記事内では設定なしに両ファイルが書き出されるとありましたが、試した限りでは自分で設定する必要がありました。
ほかにも.vue
内の TypeScript にも対応してくれるとのことですが、なぜかそのまま書き出されてしまいます。
またcjsBridge
はrollup.cjsBridge
に仕様変更があります。
unbuild の設定ファイルを作成する
そしてできあがった設定ファイルがこちらです。
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
},
})
{ // 抜粋
"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 でリリースノートを書いたら、無事完成です🎉
おわりに
機能の実装に半日、リファクタリングとプラグイン化に1日、そして npm パッケージ化に2日くらいかかりました。
想定よりは時間がかかってしまいましたが、完成してほっとしています。
この記事が、誰かの一助になれば幸いです。
書いた内容は執筆時点の理解のため、間違いや誤解を招く表現等が混ざっている可能性があります。
正確な記述に直したいと思いますので、気づいた方は修正内容をコメント等で教えてもらえますでしょうか。
Discussion