🐥

esa を CMS に VuePress v2 で管理しやすいドキュメントサイトを作る

2021/07/26に公開

esa を CMS に VuePress v2 でサイトを作る機会があったので紹介します。

🛠 何を作った?

こちらのツイートで紹介されている LAPRAS 組織ハンドブックというサイトの基盤を esaVuePress v2 で作りました。

https://twitter.com/320KZCD/status/1417679812114616321

👨‍💻 システム構成

基本構成はこちらです。

esa の GitHub Webhook (β) を使い、esa 上の特定のディレクトリ配下に記事を追加したとき、Webhook で指定した GitHub リポジトリの特定のディレクトリ配下にファイルが追加されます。そのファイル追加をフックに GitHub Actions が起動し、VuePress のビルドと GitHub Pages へのデプロイが行われます。esa の記事更新の場合も GitHub 上の該当ファイルが更新されます。

本サイトのリポジトリはプライベートで公開できないのですが、ほぼ同様の構成を持つサンプルリポジトリも作りました。構成の大枠はこちらと同様です。以降のサンプルコードも全てこちらのリポジトリにあります。

https://github.com/kawamataryo/esa-vuepress-example

※ 利用ライブラリのバージョン

なぜ esa を CMS に?

esa を CMS (Contents Management System) として利用したのは、エンジニアの介入を最小限に、サイトのコンテンツ作成の速度と内容更新の容易さを実現したかったからです。

全社的なドキュメント管理ツールとして esa を使っていたのでドキュメント執筆のメインとなるメンバーが esa での Markdown に慣れていました。そのため、esa をエディタとして利用できれば、前述のコンテンツ作成の速度と更新の容易さを実現できると考えました。

また、esa の Webhook の特性上 wip の更新はリポジトリに反映されないなど、執筆を進めるうえで良い機能があったことも理由のひとつです。

https://docs.esa.io/posts/176

なぜ VuePress?

Markdown からの静的サイトビルドフレームワークはJekyllHUGODocusaurusなど様々あります。その中らVuePressを選んだ理由は、日頃 VuePress で作られた Vue の公式ドキュメント、各種サイトを閲覧していて、 UX の良さを感じていたからです。なので今回の組織ハンドブック作成ではテーマカラーとロゴを変更するだけでデザイン変更をほぼ行わず、ある程度見れるサイトが作れました。

他、現在β版の v2 を選んだ理由は UX がより洗練されていたことと、より拡張しやすそうな API が提供されていたこと、β版でもドキュメントが充実していたことが挙げられます。最初はβ版ということで少し不安だったのですが、今のところ安定して動いているので、結果としてよかったかなと思います。

https://github.com/vuepress/vuepress-next/releases/tag/v2.0.0-beta.22

📍 実装のポイント

esa を CMS として VuePress でビルドするうえでいくつか考慮すべきポイントがあったのでまとめます。

独自 Plugin で動的にページを生成する

esa の webhook から生成されるファイルのパスをdocs/hoge配下にすれば、そのまま何の追加設定も不要で、ビルドするだけで自動的にページが作られます。ただ、その方式だといくつか問題がありました。

1. URL が esa の記事ID.html.htmlとなる
VuePress の個別ページの URL は、デフォルトでファイルパスとファイル名から生成されます。そして、webhook で生成される esa の記事は、<記事ID>.html.mdです。これをこのままビルドすると、個別ページの URL が、<ディレクトリ名>/<記事ID>.html.htmlとなります(ビルド時にmdの拡張子がhtmlになるらしい)。閲覧に支障はないのですが、ちょっと見栄えが悪いですよね。

2. FrontMatter を変更できない
esa の webhook はとても気が利いていて、以下のような FrontMatter を生成ファイルにつけてくれます。

---
title: <記事名>
category: <カテゴリ>
tags: <タグ>
created_at: <作成日>
updated_at: <更新日>
published: true
number: <記事ID>
---

しかし、VuePress の想定する FrontMatter と微妙に形式が異なります(更新日が updated_at ではなく lastUpdated など)。また、個別に FrontMatter を編集したいという場合もあると思います。そのようなときに Webhook が生成する md ファイルをそのまま使うと実現が難しいです。

これらの解決策として、Webhook が生成するファイルを直接利用せず、ローカルに独自 Plugin を作りました。
そこでビルドフックの API として公開されている onInitialized を使ってビルド時に動的にページを作成するようにしました。

そのプラグインのコードはこちらです。トップページと、コンテンツページを生成しています。

https://github.com/kawamataryo/esa-vuepress-example/blob/main/docs/.vuepress/plugins/generatePages.ts

generatePages.ts
//...

const extension = [".md"];

const extractPageData = (dirPath: string) => {
  const dirName = join(resolve(), dirPath);
  const filenames = readdirSync(dirName).filter((name) => extension.includes(extname(name)));

  return filenames.map((fileName) => {
    const path = join(dirName, fileName);
    const fileContents = readFileSync(path).toString();
    const fmInstance = fm<EsaFrontMatter>(fileContents);

    return {
      frontMatter: fmInstance.attributes,
      body: fmInstance.body,
    };
  });
};

const createFrontPageOption = () => {
  const [{ frontMatter, body }] = extractPageData("/esa/frontpage");

  return {
    path: `/`,
    frontMatter: {
      ...frontMatter,
      home: true,
      lastUpdated: dayjs(frontMatter.updated_at).format("YYYY-MM-DD"),
      date: dayjs(frontMatter.created_at).format("YYYY-MM-DD"),
    },
    content: body,
  };
};

const createContentsPageOptions = () => {
  const pageData = extractPageData("/esa/contents");
  const pageOptions = pageData.map(({ frontMatter, body }) => {
    return {
      path: `/contents/${frontMatter.title}`,
      frontMatter: {
        ...frontMatter,
        lastUpdated: dayjs(frontMatter.updated_at).format("YYYY-MM-DD"),
        date: dayjs(frontMatter.created_at).format("YYYY-MM-DD"),
      },
      content: body,
    };
  });

  return pageOptions.filter(
    (option) => !isArchivedPage(option.frontMatter.title)
  );
};

const plugin: PluginFunction = () => ({
  name: "generatePages",
  async onInitialized(app) {
    // frontPageの生成
    const frontPageOption = createFrontPageOption();
    const frontPage = await createPage(app, frontPageOption);

    // contentsの生成
    const contentsPageOptions = createContentsPageOptions();
    const contentsPages = await Promise.all(
      contentsPageOptions.map(async (pageOption) => {
        return await createPage(app, pageOption);
      })
    );

    [frontPage, ...contentsPages].forEach((page) => {
      app.pages.push(page);
    });
  },
});

export default plugin;

extractPageDataで、指定ディレクトリの.mdファイルから FrontMatter とテキストを抜き出し、createXXXPageOptionsでページ生成のオプションデータを生成、それを元に VuePress のcreatePageAPI でページを生成しています。

createXXXPageOptionsの段階で、明示的にpathfrontMatterを指定することで、最初に課題として上げていたページの URL と FrontMatter の問題を解消しています。記事別に自由に任意の FrontMatter をつけたい場合でも、esa の Markdown 上に任意のキーワードを設定し、前処理でそれを抜き出し FrontMatter に指定すれば可能です。

サイドメニューを動的に生成する

VuePress デフォルトテーマのサイドメニュー はconfig.tsthemeConfig.sidebarで以下のように指定します。
この例では/guide/のページに、'/guide/readme.md', '/guide/getting-started.md'の 2 ページのコンテンツリンクがサイドメニューに表示されます。

docs/config.ts
module.exports = {
  themeConfig: {
    sidebar: {
      '/guide/': [
        {
          text: 'Guide',
          children: ['/guide/readme.md', '/guide/getting-started.md'],
        },
      ],
    },
  },
}

固定パスとして記載するので、このままだと esa 上でページを追加した際に、themeConfig.sidebarの項目をエンジニアに依頼して修正する必要があります。

その運用は面倒なので、サイドメニューの項目も動的に作成するようにしました。
以下がsidebarの項目を、esa に出力された記事ファイルから生成するcreateSidebar関数です。

https://github.com/kawamataryo/esa-vuepress-example/blob/main/docs/.vuepress/utils/createSidebar.ts

docs/utils/createSidebar
//...

const extension = [".md"];

export const createSidebar = (folder: string, title: string) => {
  const dirName = join(resolve(), `/esa/${folder}`);
  const files = readdirSync(dirName)
    .filter((item) => {
      return (
        statSync(join(dirName, item)).isFile() &&
        extension.includes(extname(item))
      );
    })
    .map(
      (fileName) =>
        fm<EsaFrontMatter>(readFileSync(join(dirName, fileName)).toString())
          .attributes.title
    )
    .sort((a, b) => {
      if (a < b) return -1;
      if (a > b) return 1;
      return 0;
    })
    .filter((title) => !isArchivedPage(title))
    .map((title) => {
      return `/${folder}/${title}`;
    });

  return [{ text: title, children: files }];
};

createSidebarでは指定フォルダのファイルを読み、そのファイルに含まれる FrontMatter の title を使ってパス名を作っています。また、サイドメニューには並び順があるので、それは記事タイトル文字列の昇順としています。esa 側で1. xxxxなどのタイトル名を設定していることを前提としています。

この関数をconfig.tsで呼び出してthemeConfig.sidebarに指定すれば完了です。

docs/config.ts
const config = () => {
  // Sidebarの生成
  const contentsSidebar = createSidebar("contents", "contents");

  return defineUserConfig<DefaultThemeOptions>({
    // ...
    themeConfig: {
      navbar: [
        {
          text: "Contents",
          children: contentsSidebar[0].children,
        },
      ],
      sidebar: {
        "/contents": contentsSidebar,
      },
    },
    // ...
  });
};

export default config();

これであとは、esa/contents/配下にファイルが増えれば、それに連動して特にconfig.tsを編集することなくサイドメニューも表示されます。

ページの非表示に対応する

esa の GitHub Webhook とても便利なのですが、esa 上の記事の削除・アーカイブと GitHub 上のファイルの連動に対応していません(当初は esa 上の記事を削除 or アーカイブすれば GitHub 上の該当ファイルの FrontMatter の published を false にしてくれると思っていた)。

記事を非表示にしたい場合はエンジニアに GitHub 上の該当ファイルの削除を依頼をするという運用でも良かったのですが、ここまでやったら全部 esa 上でできるようにしたいと考え、今回は esa の記事タイトルをビルド時に検証しフィルターする方法で対処しました。

先程紹介したcreateContentsPageOptionsと、createSidebarの最後で、タイトルに[archived]という文字列が含まれている記事を除外することで実現しています。

docs/plugins/createPages
const createContentsPageOptions = () => {
  // ...

  return pageOptions.filter(
    (option) => !isArchivedPage(option.frontMatter.title)
  );
};
docs/utils/createSidebar
export const createSidebar = (folder: string, title: string) => {
//...
  const dirName = join(resolve(), `/esa/${folder}`);
  const files = readdirSync(dirName)
    .filter(/* ... */)
    .map(/* ... */)
    .sort(/* ... */)
    .filter((title) => !isArchivedPage(title))
    .map(/* ... */});

  return [{ text: title, children: files }];
};

filter で呼んでいるisArchivedPageの実装はこちらです。文字列に[archived]が入っているか判定しているだけです。

docs/utils/isArchivedPage
export const isArchivedPage = (pageName: string) => {
  return pageName.includes("[archived]");
};

これで[archived]がタイトルに含まれる記事は、サイドメニューと記事生成の対象とならないので、記事の非表示を実現できます。

おわりに

VuePress の API が柔軟で、思ったよりもいい感じの構成が出来た気がします。esa を CMS に使った点も、基盤構築から約 1 週間でサイトの公開が出来たことを考えると当初の目的は達成できたのかなと思っています。

LAPRAS 組織ハンドブックには、 LAPRAS がどのような組織を目指しているかなど抽象的な話から、給与制度・休暇制度など具体的な話まで本当に良い内容が書かれているので、是非読んで LAPRAS に興味をもらえると嬉しいです!

https://organization-handbook.lapras.com/

参考

Discussion