Tech

Nuxt.jsとFirebaseでサイトを作った話 続編-1- markdown変換の見直し

TL;DR

このHPのMarkdownの変換機構をprocessmd へ渡してお任せ状態から markdown-it を使った自作に作り変えたのでメモ
コードばっかりですがすべて説明しているとかなり長くなりそうなのでコードをどうぞ&メモスタイルです。

半分こちらの内容の続きとなります。
Nuxt.jsとFirebaseでサイトを作った話

目的

  • 今までだとprocessmdに変換を一任していたので最適なjsonを出力できていなかった
  • tag一覧を将来的に作りたいものの、それもprocessmdによって作られた情報から取得するか(そしてこれはしんどい)別途作る必要があった
  • リンクの内部リンク/外部リンクを判定して新規タブを開くかを分岐したい
  • 絵文字を一応使えるようにしたい
  • その他簡単に追加できそうな機能については追加したい

など

使用するもの一覧

react系がremark、vue系がmarkdown-itを使っていそうな空気があったので、markdown-itを採用しました。

実装

ファイル構成

全部ひとまとめに書いていたら結構ぐちゃぐちゃになってしまったのである程度分割しました。

  • /scripts/markdown/convertArticle.js
  • /scripts/markdown/index.js
  • /scripts/markdown/markdown-it.js
  • /scripts/markdown/utils.js

index.js

index.js エントリーファイルです。
パスの初期化とclean処理を行って書き出しの関数を呼び出します。

#!/usr/bin/env node

const path = require('path')
const rimraf = require('rimraf')
const glob = require('glob')

const { convertArticle } = require('./convertArticle')

const baseSrcDir = path.resolve(process.cwd(), 'note/')
const globPath = path.resolve(baseSrcDir, 'markdown/**/**.md')

const mdFiles = glob.sync(globPath)
const exportDir = path.resolve(baseSrcDir, 'json')
const summaryFilePath = path.resolve(baseSrcDir, 'json', '_summary.json')
const fileMapFilePath = path.resolve(baseSrcDir, 'json', '_filemap.json')

rimraf.sync(exportDir)
convertArticle(mdFiles, exportDir, summaryFilePath, fileMapFilePath)

convertArticle.js

実際に変換して書き出すコード群です。
ちょっと荒いかもしれません…。

const fs = require('fs')
const path = require('path')
const mkdirp = require('mkdirp').sync
const { md } = require('./markdown-it')
const { sourceFileNameToUrl, convertDateFormat } = require('./utils')

const createData = (mdFiles, exportDir) => {
  const result = []

  mdFiles.forEach((mdPath) => {
    const content = fs.readFileSync(mdPath, 'utf-8')
    const body = md.render(content)
    const meta = Object.assign({}, md.meta)
    meta.createdAt = convertDateFormat(meta.created_at)
    delete meta.created_at
    meta.href = sourceFileNameToUrl(mdPath.replace(process.cwd(), ''))
    const parse = path.parse(mdPath)
    const jsonPath = path.resolve(exportDir, `${parse.name}.json`)
    result.push(Object.assign({}, meta, { body, mdPath, jsonPath }))
  })

  return result
}

const exportSingleArticle = (articles) => {
  articles.forEach((elem) => {
    const exportData = {
      title: elem.title,
      tag: elem.tag,
      createdAt: elem.createdAt,
      body: elem.body,
      href: elem.href,
      ogimage: elem.ogimage,
      description: elem.description,
    }
    fs.writeFileSync(elem.jsonPath, JSON.stringify(exportData, null, 2), 'utf8')
  })
}

const exportSummary = (articles, summaryFilePath) => {
  const summaryData = articles.map(elem => ({
    title: elem.title,
    tag: elem.tag,
    createdAt: elem.createdAt,
    href: elem.href,
  }))

  fs.writeFileSync(summaryFilePath, JSON.stringify(summaryData))
}

const exportFileMap = (articles, fileMapFilePath) => {
  const fileMap = articles.map(elem => ({
    jsonPath: elem.jsonPath,
    mdPath: elem.mdPath,
    href: elem.href,
  }))
  fs.writeFileSync(fileMapFilePath, JSON.stringify(fileMap))
}

const convertArticle = (mdFiles, exportDir, summaryFilePath, fileMapFilePath) => {
  const articles = createData(mdFiles, exportDir)

  mkdirp(exportDir)

  exportSingleArticle(articles)
  exportSummary(articles, summaryFilePath)
  exportFileMap(articles, fileMapFilePath)
}

module.exports = {
  convertArticle,
}

util.js

ユーティリティです。
ファイル名から実際のパスに変換する関数と日付フォーマットを変換する機構があります。
これは以前vueファイル側で行っていたのですが、事前に変換する処理に取り込めたのはjsonもきれいになるしよかったと思います。
(ついでにファイル名からもう少し区切りが判定しやすいように変えればよかったかもしれませんね…)

/**
 * Convert local path to link url
 * @param filePath /note/markdown/2017-11-17-mac-global-env.md 形式のパス
 * @returns {string}
 */
const sourceFileNameToUrl = (filePath) => {
  const deleteExt = filePath.replace('.md', '')
  const fileName = deleteExt.split('/')[deleteExt.split('/').length - 1]
  const splitArray = fileName.split('-')
  return `/note/${splitArray.slice(0, 3).join('-')}/${splitArray.slice(3).join('-')}`
}

/**
 * Convert string date format value to YYYY/MM/DD
 * @param dateString 2018-05-21T00:00:00.000Z
 * @returns {string}
 */
const convertDateFormat = (dateString) => {
  const date = new Date(dateString)
  const year = `0000${date.getFullYear()}`.slice(-4)
  const month = `00${date.getMonth() + 1}`.slice(-2)
  const day = `00${date.getDate()}`.slice(-2)

  return `${year}/${month}/${day}`
}

module.exports = {
  sourceFileNameToUrl,
  convertDateFormat,
}

markdown-it.js

markdown-itの初期化とプラグインの登録やカスタムしている部分の書き換えを行っています。

一点ここは注意点として anchor と table-of-contentsをこのように使う場合に、アンカーへの変換時にtable-of-contentsの方が日本語を消してしまうため差異が発生してうまく繋がりません。
ですので、anchorのslugyfyをそのまま適用して共通化しています。

const md = require('markdown-it')()
const hljs = require('highlight.js')
const emoji = require('markdown-it-emoji')
const container = require('markdown-it-container')
const mark = require('markdown-it-mark')
const meta = require('markdown-it-meta')
const anchor = require('markdown-it-anchor')
const toc = require('markdown-it-table-of-contents')

md.set({
  html: true,
  linkify: true,
  typographer: true,
  langPrefix: 'hljs language-',
  highlight (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(lang, str).value
      } catch (__) {
        console.log(__)
      }
    }

    return ''
  },
})

md
  .use(emoji)
  .use(container)
  .use(mark)
  .use(meta)
  .use(anchor, {
    permalink: true,
    permalinkBefore: true,
  })
  .use(toc, {
    slugify: anchor.defaults.slugify,
    includeLevel: [2, 3],
  })

// toc custom
md.renderer.rules.toc_open = () => '<div class="table-of-contents"><div class="toc-title">- table of contents -</div>'
md.renderer.rules.toc_close = () => '</div>'

// link custom
const defaultRender = md.renderer.rules.link_open || function render (tokens, idx, options, env, self) {
  return self.renderToken(tokens, idx, options)
}

md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
  const tokensLocal = tokens
  const hrefIndex = tokensLocal[idx].attrIndex('href')

  if (hrefIndex >= 0) {
    const link = tokensLocal[idx].attrs[hrefIndex]
    const href = link[1]
    const isExternal = /^https?:/.test(href) && !/\/\/isoppp.com\//.test(href)

    if (isExternal) {
      const aIndex = tokensLocal[idx].attrIndex('target')
      if (aIndex >= 0) {
        tokensLocal[idx].attrs[aIndex][1] = '_blank'
      } else {
        tokensLocal[idx].attrPush(['target', '_blank'])
      }
    }
  }

  // pass token to default renderer.
  return defaultRender(tokensLocal, idx, options, env, self)
}

module.exports = {
  md,
}

結果

サポートされた機能

  • (元からあったけど) yaml形式のmeta情報のサポート
  • [[toc]]で目次が表示
  • 使う予定のない絵文字 :grinning:
  • リンク形式の文字が直接リンクとなる https://isoppp.com
  • 外部リンクは新規タブになる https://github.com/isoppp
  • ==マーカーが引ける== ようになった
  • containerは使う予定が無い(ただ裏処理なので入れちゃって問題ない)
  • ページ内アンカーをサポート

出力されるjson

記事単品データ

記事表示時に実際に使用する

{
  "title": "Example Title",
  "tag": [
    "Tag1",
    "Tag2"
  ],
  "createdAt": "YYYY/MM/DD",
  "body": "<p><div class=\"table-of-contents\">...",
  "href": "/note/YYYY-MM-DD/example-note",
  "desciption": "/note/2015-08-12/mac-global-env",
  "ogimage": "example-image.png"
}

記事一覧データ

記事一覧等で使うもの

[
  {
    "title": "Macの環境変数の書き方を整理してみた",
    "tag": [
      "Bash"
    ],
    "createdAt": "2015/08/12",
    "href": "/note/2015-08-12/mac-global-env"
  },
  ...
]

各パスとURL情報の一覧データ

feed作成時や裏処理で使うもの

[
  {
    "jsonPath": "/Users/isogai/personal/isoppp.com/note/json/2015-08-12-mac-global-env.json",
    "mdPath": "/Users/isogai/personal/isoppp.com/note/markdown/2015-08-12-mac-global-env.md",
    "href": "/note/2015-08-12/mac-global-env"
  },
  {
    "jsonPath": "/Users/isogai/personal/isoppp.com/note/json/2018-02-10-how-to-catch-console-error-use-puppetter.json",
    "mdPath": "/Users/isogai/personal/isoppp.com/note/markdown/2018-02-10-how-to-catch-console-error-use-puppetter.md",
    "href": "/note/2018-02-10/how-to-catch-console-error-use-puppetter"
  },
  ...
]

おわりに

諸々markdown変換周りで大きく鬱憤がたまっていた部分をある程度解消できたので悪くはないところまでは改善できた気がします。
将来的には埋め込みコードとかコンポーネントを突っ込めるようにする必要があるかもしれませんがそれはまた別の話…。

そしてコードを晒すのって結構やはり怖いですね。予防線として自己サイトを半分趣味でぱっぱ作ってるので変なコードがあったらすみません。
指摘頂けましたらとても喜びます!

最後に補足的ですが、このサイトのNuxt.jsとFirebaseでサイトを作った話で作った方はこの後、vueファイル修正やnuxt.config.jsの調整が必要です。
これは結構たいへんなので割愛しますが、困ることがあれば問い合わせかTwitter等で絡んで頂ければ可能な限りはご協力致します。

それでは!

参考

補足

現在Nuxt.jsのバグでページ内アンカーリンク付きのURLを直接開いた際にその位置にスクロールしないようです。 issue

ここにある通り暫定的に下記のコードをnuxt.config.jsに記載すると動作します。

module.exports = {
  // ...
  router: {
    scrollBehavior (to, from, savedPosition) {
      if (savedPosition) {
        return savedPosition
      } else {
        let position = {}
        if (to.matched.length < 2) {
          position = { x: 0, y: 0 }
        } else if (to.matched.some(r => r.components.default.options.scrollToTop)) {
          position = { x: 0, y: 0 }
        }
        if (to.hash) {
          position = { selector: to.hash }
        }
        return position
      }
    },
  },
}
share