GatsbyJS - 從 Hexo 轉移到 GatsbyJS

08 June 2020 — Written by Sky Chang
#Node.js#Hexo#GatsbyJS

前言

上一次談論到,因小弟自己的私慾,選擇了 GatsbyJS,但其實沒有針對轉移的過程做比較詳細的敘述,所以就在這篇稍微紀錄一下,轉移的過程。

注意,這篇不會詳細解釋 GatsbyJS,若對 GatsbyJS 有興趣,請參與官網

喔,對了,這篇的環境是以 gatsby-starter-hello-friend 當作 start,不見得每個 Starter 都適合,但我相信透過這篇,大致上可以抓到如何去修改。

目標

  1. 將之前 v2 的網址與現在 v3 的網址能一樣,都是 skychang.github.io/年/月/日/文章名稱 的格式。
  2. 有部分的頁面,例如 About、DevOp 等頁面,希望能使用 skychang.github.io/about 的方式呈現。

當然,Hexo 的 NexT 樣板太強大,很難全部調整完,但至少這一步,希望能先將網址位置一樣先解決,不然無論是搜尋或是原本文章裡面的連結要修正都是很麻煩的一件事情。

開工

這次要修改的地方,大概就是如下圖位置。

2020 06 08 11 41 59

  • templates 目錄,裡面有 page.js 和 tag.js,我們會修改裡面的 GraphQL,因為我們到時候會自訂自己的網址。
  • gatsby-config.js 主要拿來設定 Plung 或是一些 blog 的名稱等等,這篇不會提到,但若是要調整 Blog 名稱、Title 等等,可以透過此 js 進行修改。
  • gatsby-node.js 這次的重點,我們會簡單調整一下這個 js 檔案,詳細的過程邏輯,可以往後面看。

先談談 Markdown 怎麼載入

在開始之前,我們要先知道一些東西,首先,如之前文章所提到的,GatsbyJS 是一個框架,他可以透過在 React 裡面撰寫 GraphQL,並且由 GraphQL 取得到的 JSON 資料 Bind 到 React 裡面來,這也就是 GatsbyJS 的基本。

而因為這個特性,所以 GatsbyJS 其實有很多的 PlugIn 可以當資料來源,而我們這邊的資料來源當然就是 Markdown。

gatsby-transformer-remark

在預設的情況下,Markdown 這個 PlugIn 當然不會被載入進來,而要使用 Markdown 的資源,則必須使用 gatsby-transformer-remark

當然,要使用 gatsby-transformer-remark 直接使用 npm install 即可。

npm install --save gatsby-transformer-remark

載入完成後,我們還要在 gatsby-config.js 加上 gatsby-transformer-remark 就可以。而我們使用的這個範本,預設就已經有幫忙使用這個 PlugIn,而且也多加上了程式碼 Highlight、圖檔等功能,所以原則上不用修改。

2020 06 08 17 47 27

到這邊後,其實就可以透過 GatsbyJS Cli Develop 模式

gatsbyjs develop

http://localhost:8000/___graphql 這個位置,用 GraphQL 查 allMarkdownRemark 和 markdownRemark,而 allMarkdownRemark 就是 目前所有的 MD,markdownRemark 則是單一筆 MD。

下圖就是查找出單一筆的案例,這邊部探討 GraphQL,但可以簡單地看到,透過安裝 gatsby-transformer-remark 後,我們就可以從 GatsbyJS 取得到我們寫的 MD 檔案 ( 請注意,MD 檔案要放到 posts 底下 ),而且透過 GraphQL,可以透過 eq 進行 id 的查詢。

2020 06 08 18 16 44

gatsby-node.js - onCreateNode

大致上了解 gatsby-transformer-remark 與 GraphQL 後,我們來順一下,整個頁面產生的流程。在正常情況下,我們希望我們輸入某個網址,舉例來說,可能是 skychang.github.io/about 後,就可以看到 about 的頁面。而目前,我們有了 about.md 產生出來的 html,但卻缺少了 skychang.github.io/about 對應到 about.md 產生出來的 Html 之方法,換言之,GastybyJS 裝了 gatsby-transformer-remark,並不會自動將 /about 對應到 about.md 產生出來的 html 這個資料欄位。

當然,你可能會想,啊,有 id 啊,id 是唯一的,透過 id 的方式,就可以讓我們找到對應的文章,如上面 GraphQL 範例一樣。沒錯,這是對的,但可能不符合我們的需求。因為如果要用 id,我們就必須在網址上面加上此 id,例如 skychang.github.io/xxxxx-xxxxx-xxxx 或是 skychang.github.io/about?id=xxxxx-xxxxx-xxxxx ,但這些都不是我們要的。我們希望能直接透過 /about 來解決。但整個 markdownRemark 是沒有記錄到 /about 這個網址對應的關係的,只有記錄到 about.md 這個 File Name。

那怎麼辦呢?,很簡單,不存在的東西,我們自己加一個進去就好。所以等下,我們會將 /about 這個值,加入到 markdownRemark 裡面,且自己定義一個 slug 這個欄位。這樣,當我們導向到 skychang.github.io/about 的時候,就可以透過 GraphQL 的 Filter,查找 slug eq "/about" ( 也就是 slug === "/about") 的方法,找到對應的文章。

所以我們下一個要看的就是 gatsby-node.js。

在 GatsbyJS 官網有提到,可以在 gatsby-node.js 裡面使用 onCreateNode 這個方法,這個方法就是當新的 Node 建立的時候,就會進行此流程,這個方法很適合在我們的需求,也就是每此有新的 Node 產生的時候,我們就將 slug 塞進去到 MarkdownRemark 這個節點裡面。

底下是官方提供的程式碼,和樣板可能有些不同,但概念是相同的。當 node.internal.type === MarkdownRemark 的時候,我們就偷塞 slug 進去,而 slug 的值,會透過 createFilePath 這個方法 ( 要先 npm install gatsby-source-filesystem ) 來協助,快速的產生網址。而 createFilePath 這個方法會擷取 about.md 這個 File Name,並產生對應的 /about ( 換句話說,就是自動的幫我們截掉 .md,或是 about.md 前面的路徑 ),讓我們快速使用。而最後,就可以透過 createNodeField 來將 slug 塞進去 node 裡面 (大心)。

const { createFilePath } = require(`gatsby-source-filesystem`)
exports.onCreateNode = ({ node, getNode, actions }) => {
  const { createNodeField } = actions
  if (node.internal.type === `MarkdownRemark`) {
    const slug = createFilePath({ node, getNode, basePath: `pages` })
    createNodeField({
      node,
      name: `slug`,
      value: slug,
    })
  }
}

到這一步後,其實未來節點裡面,都會有 slug,而 slug 存的就是相對路徑的網址, ex : /about

gatsby-node.js - createPage

完成了 slug 偷偷塞入後,我們就可以透過 createPage 來建立頁面。嗯,什麼是建立頁面?別忘了 GatsbyJS 最後產生的是一堆 HTML,所以我們要在這邊定義ㄝ要產生哪堆 HTML,例如,我們有了 about.md 檔案, /about 這個 slug 資料,也有了 html 資料,但是沒產生出 .html 這個檔案,還是一切是空啊,所以 createPage 就負責處理這件事情。

在官網的範例如下。

const path = require(`path`)

exports.createPages = ({ graphql, actions }) => {
  const { createPage } = actions // 後面會用到這個方法
  // 既然要建立頁面,當然還是要一個 templates,這邊就不敘述 templates。
  // 在 gatsby-starter-hello-friend 裡面,則是使用 index.js 當作範本。
  const blogPostTemplate = path.resolve(`src/templates/blog-post.js`)
  // 透過 GraphQL 查出所以的網址 ( slug )。
  return graphql(`
    query loadPagesQuery ($limit: Int!) {
      allMarkdownRemark(limit: $limit) {
        edges {
          node {
            frontmatter {
              slug
            }
          }
        }
      }
    }
  `, { limit: 1000 }).then(result => {
    if (result.errors) {
      throw result.errors
    }

    // 查出所有資料後,利用 forEach 來產生所有的靜態頁面
    result.data.allMarkdownRemark.edges.forEach(edge => {
      createPage({
        // path 參數就是代表著實際的目錄位置,舉例來說,slug 為 /about,就代表著,會建立一個 about 的目錄,然後裡面產生 index.html 這個靜態頁面
        path: `${edge.node.frontmatter.slug}`,
        // 同樣的,要產生頁面這邊也要有樣板,上面的 blog-post.js 代表著 List 列表,這邊的樣板,代表每一個文章的樣板
        component: blogPostTemplate,
        context: {
         // 這邊可以加上 context,來將資料傳遞到 blogPostTemplate 進行 bind。
        },
      })
    })
  })
}

到這邊為止·我們解釋了要執行 Markdown 的必要條件,所以,小弟之前有提過,GatsbyJS 其實真的不適合一般只想寫文章的人 QQ。

再談談如何符合我們的需求

前面講了很多基礎理論 ( 其實原本沒打算寫那麼多的,但結果... ),那現在來談談如何滿足我們的需求。

如前面提到,我們的需求有兩個。

  1. 將之前 v2 的網址與現在 v3 的網址能一樣,都是 skychang.github.io/年/月/日/文章名稱 的格式。
  2. 有部分的頁面,例如 About、DevOp 等頁面,希望能使用 skychang.github.io/about 的方式呈現。

所以,接下來要調整成我們的需求。

gatsby-node.js - onCreateNode

同樣,回到 gatsby-node.js 的 onCreateNode,在 gatsby-starter-hello-friend 樣板裡面,他其實是使用 markdown 裡面的 path 欄位來定義位置 ( 也就是說,不使用檔案名稱 )

例如 :

title: GatsbyJS - 從 Hexo 轉移到 GatsbyJS
author: "Sky Chang"
coverImage: ""
excerpt: ''
path: "/about"
date: 2020-06-08 00:38:00

所以我們要修改 onCreateNode,讓他使用 date 和 FileName 來產生 slug。

修改完後如下,這邊使用正規表示式,來 match date 這個欄位。也是小弟看到大大的做法。但小弟的情境略有不同,所以進行了調整。( 嗯,我知道正規表示式寫的很醜,未來有機會再改吧 QQ )

另外,我多加了 if 判斷,來判斷是否有在 markdown 裡面加上 path,若有的話,就以這個 path 為主,而不是以 FileName 為主。

const BLOG_POST_FILENAME_REGEX =
/^(19|2[0-9][0-9]{2})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]).([0-1][0-9][0-9])Z$/;

exports.onCreateNode = ({ node, getNode, boundActionCreators }) => {
  const { createNodeField } = boundActionCreators;

  // 確定是 Markdown 檔再做操作
  if (node.internal.type === 'MarkdownRemark') {

    let slug = node.frontmatter.path;

    // 判斷 markdown 裡面是有否有設定 Path,若無的話,使用 md 檔案名稱和日期。
    if(slug === undefined)
    {
      const { name } = getNode(node.parent);
      const match = BLOG_POST_FILENAME_REGEX.exec(node.frontmatter.date);
      const year = match[1];
      const month = match[2];
      const day = match[3];
      // 組出我們要的 slug pattern
      slug = `/${year}/${month}/${day}/${name}/`;
    }

    // 在該 node 上面多增加一個欄位,未來可以 Query
    createNodeField({
      node,
      name: 'slug',
      value: slug,
    });
  }
};

gatsby-node.js - createPage

然後 CreatePage 的地方,就簡單了,因為基本邏輯不變,只是原本的路徑改為使用我們自訂的 slug。

 forEach(({ node }, index) => {
      const previous = index === 0 ? null : sortedPages[index - 1].node
      const next =
        index === sortedPages.length - 1 ? null : sortedPages[index + 1].node
      const isNextSameType = getType(node) === (next && getType(next))
      const isPreviousSameType =
        getType(node) === (previous && getType(previous))

      createPage({
        path: node.fields.slug,//node.frontmatter.path,
        component: pageTemplate,
        context: {
          type: getType(node),
          next: isNextSameType ? next : null,
          previous: isPreviousSameType ? previous : null,
        },
      })
    }, sortedPages)

index.js

接下來,我們的資料面都準備好後,可以來看一下 index.js,我們可以看到如下面的程式碼,我們可以透過預設的 postsQuery,將 GraphQL 塞進去,而我們就可以在執行 Index 這個方法的時候傳入資料進來,並且 Bind 到 React 上。

而底下的 GraphQL 就會塞出所有 posts 目錄底下,請依據時間順序排序,再加上分頁的功能,來撈出資料,而且請注意,可以看到最底下,我們也補上了 slug 這個欄位,因為目前已經有這欄位了。 ( 原本的樣板,是沒有的,請自己補上 slug )

export const postsQuery = graphql`
  query($limit: Int!, $skip: Int!) {
    allMarkdownRemark(
      filter: { fileAbsolutePath: { regex: "//posts//" } }
      sort: { fields: [frontmatter___date], order: DESC }
      limit: $limit
      skip: $skip
    ) {
      edges {
        node {
          id
          excerpt
          frontmatter {
            title
            date(formatString: "DD MMMM YYYY")
            path
            author
            excerpt
            tags
            coverImage {
              childImageSharp {
                fluid(maxWidth: 800) {
                  ...GatsbyImageSharpFluid
                }
              }
            }
          }
          fields{
            slug
          }
        }
      }
    }
  }
`

而最後,我們可以在 index.js 裡面看到裡面有一個 Post 這個 React Components,這個 Components 在 components/post.js,而 Index.js 會將相關資料送進去到 post.js ( 也是基本的 React 概念 ),而 post.js 就是首頁產生短短的預覽文章的 Components。

( 我們這邊,就將底下原本 path={path} 改成我們自訂的 path={slug})

   <Post
    key={id}
    title={title}
    date={date}
    path={slug}
    author={author}
    coverImage={coverImage}
    tags={tags}
    excerpt={excerpt || autoExcerpt}
  />

而 post.js 裡面,就可以看到,從外面傳進來的 slug,到 Post Components 裡面後,會變成 path 變數,並塞到 Read me 這邊的超連結位置。

  <Link to={path} className={style.readMore}>
    Read more →
  </Link>

page.js

而最後,在 templates/page.js 底下,我們要修改 graphql,原本的樣板,是使用 path 進行 eq,而我們全部改用 slug 了,所以要改成使用 slug 進行 eq。

另外,要注意,關於這個樣板,post.js 會由兩地方使用,一個是 index.js ( List ),一個是 page.js ( Detail ),而從 index.js 進來的時候,Post path 帶的是 slug,這個時候,會用到 Post path 的只有 h1 的超連結和 Read me。而從 page.js 進來的時候,其實沒用到 Post path,所以這有沒有值,其實無所謂,這也就是為什麼下面的 graphQL 將 path 註解,且又不加上 slug 的原因 ( 其實沒註解應該也沒差 ),另外,其實 tag.js 也會用到 page.js,但他的情境同 List。

export const pageQuery = graphql`
  query($path: String) {
    markdownRemark(fields: { slug: { eq: $path } }) {
      frontmatter {
        title
        date(formatString: "DD MMMM YYYY")
        # path
        author
        excerpt
        tags
        coverImage {
          childImageSharp {
            fluid(maxWidth: 800) {
              ...GatsbyImageSharpFluid
            }
          }
        }
      }
      id
      html
      excerpt
    }
  }
`

到這邊為止,我們就完成了所有的操作了

後記

落落長寫了非常長的一篇記錄,雖然 GatsbyJS 並非如一般的架站軟體一樣好用,但其實我滿喜歡他的強大和彈性,再加上優雅的 React 和 GraphQL 查詢資料系統和 PlugIn,更能為未來的 Blog 提供彈性,也很容易調整成往前相容。當然未來還有很多事情要做就是了 QQ

而 GatsbyJS 轉換 Blog 系列,也暫時告一段落,未來如果有 GatsbyJS 的改版,再看看有沒有機會寫上來吧,我們下次見!

參考資料

Sky & Study4.TW