Next.js でポートフォリオサイトを作成した

post on
post cover image

はじめに

Ruby + JekyllによるGihubPagesは既にあるのですが、宮崎版コロナ対策サイトでVueに触れ、勉強がてら実際にJSによるサイト作成をする事にしました。

自分

大学研究でcppを利用しただけの、農学部卒。
ただいま無職、転職活動中(ここ2か月は自粛でstay home

作成に当たって

React とNext.js のtutorial と docs を一通りやりました。

サイト自体の目的

  • 経歴や作成したもののリンクをまとめる
    • GithubPagesやQiita、Gistへの投稿物を一か所にまとめる
    • Markdonwによるページ作成

リンク

技術・要件など

環境

  • vm:virtualbox + vagrant
    • OS: Ubuntu18.04 bionic
  • node -v :v12.16.1
  • yarn -v :1.22.4

実作業

yarn create next-app

yarn create next-app next-portfolio
# =>
# ? Pick a template › - Use arrow-keys. Return to submit.
# ❯  Default starter app
#    Example from the Next.js repo

Example from the Next.js repo

Default starter appの場合

今回はReact Next.jsの勉強も兼ねているので、defaultの方を利用した。

yarn devすると

Hello Next.js
# directory
- public
  - favicon.ico, zeit.svg
- pages
  - index.js
- package.json
- node_modules
- README.md
- yarn.lock
// package.json
{
  "name": "next-portfolio",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "9.3.5",
    "react": "16.13.1",
    "react-dom": "16.13.1"
  }
}

Material-UI 導入

見た目重視でmaterial-uiを導入し、主にサイドバーのpermanent / swipeable drawerとGridに使用。

yarn add @material-ui/core
yarn add @material-ui/icons

create src/pages/index.jsx

  1. srcディレクトリを作成し、下にpagesを収める。
  2. src/components/Layout.jsxの作成

複数ページで共通デザインとなるLayout.jsxを作成する。ここでは省略したが、<aside />の中には、material-uiを利用したpermanent-drawerとモバイル用のswipeable-drawerを実装した。

/src/components/Layout.jsx
// src/components/Layout.jsx

import React from 'react'
import Link from 'next/link'

import { makeStyles, useTheme } from '@material-ui/core/styles'
import Hidden from '@material-ui/core/Hidden'
import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'
import Drawer from '@material-ui/core/Drawer'
import DoubleArrowIcon from '@material-ui/icons/DoubleArrow'
import { List, ListItem, ListItemIcon, ListItemText, Divider } from '@material-ui/core'
import HomeIcon from '@material-ui/icons/Home'
import { MyDrawerList } from '../components/MyDrawerList'

const drawerWidth = 250
const useStyles = makeStyles((theme) => ({
  // ...
}))

export function Layout({ children }) {
  // ...
  const [state, setState] = React.useState({
    left: false,
  })

  // swipeable-drawerの開閉を制御するボタン
  const toggleDrawer = (anchor, open) => (event) => {
    if (event && event.type === 'keydown' && (event.key === 'Tab' || event.key === 'Shift')) {
      return
}
    setState({ ...state, [anchor]: open })
  }

  const HomeDrawerList = () => {
    return (
      <MyDrawerList>
        <List>
          <Link href='/'>
            <ListItem button>
              <ListItemIcon><HomeIcon /></ListItemIcon>
              <ListItemText primary='Home' />
            </ListItem>
          </Link>
          // ...
        </List>
      </MyDrawerList>
    )
  }

  return (
    <React.Fragment key='left'>
      <Hidden lgUp>
        // モバイル端末用
        // if display-width > 1280px, display: none
        <SwipeableDrawer anchor='left' open={state['left']}
          onClose={toggleDrawer('left', false)} onOpen={toggleDrawer('left', true)}
        >
          <div className='swipeableList' role='presentation'
            onClick={toggleDrawer('left', false)} onKeyDown={toggleDrawer('left', false)}
          >
            <HomeDrawerList />
          </div>
        </SwipeableDrawer>
        <footer>
          <button onClick={toggleDrawer('left', true)}>
            <DoubleArrowIcon color='secondary' style={{ fontSize: 34 }} />
          </button>
        </footer>
      </Hidden>
      <Hidden mdDown>
        // 非モバイルディスプレイ用
        // if device-width < 1280px, display:none
        <aside>
          <Drawer className='permanentDrawer' variant='permanent' anchor='left'>
            <HomeDrawerList />
          </Drawer>
        </aside>
      </Hidden>
      <main className={classes.contents}>
        {children}
      </main>
      <style jsx>{`
        // ...
      `}</style>
    </React.Fragment>
  )
}
  1. pages/index.jsxの作成
    まだReact等に不慣れなので、pages/index.jsにサイト1ページ目を作りこんで、後からcomponentに分割する方式をとった。

色々試した結果、サイトトップにあたる pages/index.jsxは下の様になった。また、<head><meta/></head>用のデータは/public/manifest.jsonから持ってくる事にした。

// src/pages/index.jsx

import React from 'react'
import Head from 'next/head'
import { Layout } from '../components/Layout'
import { Top, About, History, Works } from '../components/HomeContents'
const manifest = require('../../public/manifest.json')

export default function Home() {
  return (
    <>
      <Layout>
        <Head>
          <title>{manifest.name}</title>
          <meta name='title' content={manifest.name} />
          <meta name='description' content={manifest.description} />
          <meta property='og:title' content={manifest.name} />
          <meta property='og:description' content={manifest.description} />
          <meta property='og:image' content={`${manifest.vercel}/assets/prtsc700.jpg`} />
          <meta property='og:url' content={`${manifest.vercel}`} />
        </Head>
        <Top />
        <About />
        <History />
        <Works />
      </Layout>
      <style jsx global>{`
        // ...
      `}</style>
    </>
  )
}
/src/components/HomeContetnts.jsx
// src/components/HomeContetnts.jsx

import React from 'react'
import Link from 'next/link'
import Grid from '@material-ui/core/Grid'

export function Top() {
  return ( <section id='top' className='topContainer' />  )
}

export function About() {
  return (
    <section id='about' className='content'>
      <h2>About</h2>
      <Grid container spacing={4}>
        <Grid item md={12} lg={5}>
          <picture>
            ...
          </picture>
        </Grid>
        <Grid item md={12} lg={7}>
          <p>My name is Kawano Yudai.</p>
          <p>I graduated from Miyazaki Universiy as Bachelor of Agriculture.</p>
          <p>I belonged to agricultural engineering lablatory and studied crop row detecting tech by image processing with C++ and OpenCV.</p>
          <p style={{ color: '#F48FB1' }}><em>Now, I'm seeking job as developer. Please contact me from left drawer.</em></p>
        </Grid>
      </Grid>
    </section>
  );
}

export function Works() {
  return ( <section id='works' className='content' /> )
}

export function History() {
  return ( <section id='history' className='content' /> )
}

_app.jsx, _document.jsx, 404.jsx

参照

  • Custom App from Next.js

  • Custom Document from Next.js

  • Custom Error Page from Next.js

  • _app.jsx

    • global cssを追加する場所
  • _document.jsx

    • SSRされる箇所なので、onclickなどイベントハンドラは動かない
    • <Main />の外側にあるコンポーネントはブラウザによる初期化がされないので、Appロジック等はapp.jsxに記述
    • <title><Head />styled-jsxを書いちゃ駄目。

Posts周辺の作成

ダイナミックルーティング

ディレクトリ構成

- pages (*directory)
  - index.jsx
  - posts (*directory)
    - hoge.jsx
    - [id].jsx

また、/pages/posts/[id].jsx

// /pages/posts/[id].jsx
import useRouter from 'next/route'
export default function Post(){
  const router = useRouter()
  const { id } = router.query
  return <p>Post: {id}</p>
}

ファイル名に[]が付いてるので変に見えるが。例えば

  • localhost:3000/posts/hoge/にアクセスするとpages/posts/hoge.jsxが読み込まれる
  • localhost:3000/posts/foobarだと、pages/posts/foobar.jsxが読み込まれ、
dynamic route

dynamic routeLink( next/link )を併用する時は、hrefに合わせてasも使う。

getStaticProps, getStaticPaths

今回はmdファイルを/src/pages/docsに入れる。

  • baseUrl/postsへのアクセス時は、docs下のmdファイルを読込み、posts一覧の出力
  • baseUrl/posts/[id]の場合は、同様にして、post単体の出力
  • baseUrl/tagsの場合は、同様にpostsで使用されている投稿タグ一覧の出力
  • baseUrl/tags/[tag]なら、同タグを使用するposts一覧を出力
  • docs配下に無いmdファイル名にアクセスした場合は、404

ページ出力がsrc/pages/docs/xxx.mdという外部データに依存した静的ページ出力をしたいので、getStaticPropsgetStaticPathsを使用した。

実装は下を参照しながらしました。タグの方は自分で用意しましたが。
Next.jsのチュートリアルのこのページ

posts index

tagsページのスタイルが未だ・・・

tags index

マークダウン

  • 実現したい事
    • QiitaやGist等での投稿を可能な限り手間なく集約したい
      • mdxjs/mdxなら、ファイル中にimportexport等のjsを組み込める。
  • 最終的に利用したもの。

構文木について、しっかり学ばねばと思いました。

/src/lib/posts.jsx
// /src/lib/posts.jsx
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import remark from 'remark'
import html from 'remark-html'
export async function getPostData(id) {  
  const fullPath = path.join(postsDirectory, `${id}.md`)
  const fileContents = fs.readFileSync(fullPath, 'utf8')
  const matterResult = matter(fileContents)
  const LowerCaseTags = matterResult.data.tags.map((tag) => (tag.toLowerCase()))
  const highlight = require('remark-highlight.js')
  
  const processedContent = await remark()
    .use(highlight)
    .use(html)
    .process(matterResult.content)
  
  const contentHtml = processedContent.toString()
  
  return {
    id,
    contentHtml,
    LowerCaseTags,
    ...matterResult.data,
  }
}

meta

next.js syntax-highlight

以前にrubyとjekyllで作ったgithubpagesと比較して、syntax-highlightが粗いので改善が必要

githubpages syntax-highlight
# front-matter
---
date: '2020-05-26'
author: Kawano Yudai
title: 'Qiita: Next.jsでポートフォリオサイトを作成した'
tags: [Qiita, React, Next.js]
image: '/assets/posts/202003/miyazaki-oss1.jpg'
slide: false
---

SNSシェアボタン

// ./src/pages/posts/[id].jsx
<button className='twitter'>
  <a href={`https://twitter.com/share?text=${postData.title}&hashtags=react,nextjs&url=https://next-portfolio-blue.now.sh/posts/${postData.id}&related=not_you_die`}
    target='_blank' rel='noopener noreferrer'><TwitterIcon /></a>
</button>
<button className='hatena'>
  <a href={`https://b.hatena.ne.jp/entry/https://next-portfolio-blue.now.sh/posts/${postData.id}`} className='hatena-bookmark-button' data-hatena-bookmark-layout='touch-counter'
    title={postData.title} target='_blank' rel='noopener noreferrer'><HatenaIcon /></a>
</button>

UPDATE

README.md

@ 2020-05-27

Qiita投稿の公開に当たり、README.mdを充実させた

npm install 禁止

@ 2020-05-27

特に理由はないがnpmの仕様を禁じることにした。

Custom Domain

@ 2020-06-01

  1. google domainで購入
  2. Vercel側でドメインを変更
  3. Google Domain側でdnsをvercel用に変更
  • ns1.vercel-dns.com
  • ns2.vercel-dns.com

Google Analytics

@ 2020-06-05

GoogleAnalytics側でIDを取得し、_app.jsx_document.jsxを上コードに従って修正する。

PWA implimentation

next-offline

@ 2020-06-05

next-offlineを利用した。上リポジトリでも記載してあるが、Vercel( Now )のv1とv2で動作が違う。但し、現在はv2オンリーなので、同レポジトリ内にあるpackages/now2-examplenow.jsonnext.config.jsonに倣えばよい。

PWA

@2020-06-25

最初に使った next-offline は更新が遅く、またexperimentalな部分を利用していた等の理由から、next-pwaに移行した。example から分かるように、非常にシンプルになった。

// next.config.js
const withPWA = require('next-pwa')

module.exports = withPWA({
  pwa: {
    dest: 'public'
  }
})

TypeScirpt

@2020-06-30
Next.jsのTS化は非常に簡単で、最初のうちはNext.js Learn Typesciptなどに従えば良い。

touch tsconfig.json
# If you’re using Yarn
yarn add --dev typescript @types/react @types/node

あとは、Learn 等に従って、ts化していけば、何となく理解できる。また、tsconfig.jsonallowJs:trueにしておけば、もし仮に型が解らんものを含むjsファイルはそのままにしておいて、理解が進んでから完全にts化すればいいのでは。

npm-script

mizchi氏のブログ等を見てて、npm-script や EsModule 等を知った。ちょうど、sitemap.mxlを造る必要があったので、利用することにした。

# pagesMap.json => sitmap.mxl
# pagesMap.json + history.json => rss

ただ、node v12 では ESModule は未だ experimental な機能で、package.json にも node --experimental-modules test.mjs とする必要がある。しかし、v13からはフラグが要らないので、nodejsをアップデートした。

n --stable
# => 12.18.2
n --latest
# => 14.5.0
n latest
node -v
=> v14.4.0

vercel は nodejs の LTS しか対応しないので、package.json 中の npm-script は build 用 と generate script用で分ける必要があった。

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "local-build": "next build && node script/genRobots.mjs && node script/genPostsMap.mjs && node script/genSiteMap.mjs && node script/genRss.mjs && node script/genAtom.mjs",
  "start": "next start",
},

mjsについて未だ良く解らん事、作るのが自分用のファイルジェネレーターであることもあって、コードが汚いので・・・↓

pages.json

@2020-06-30
postの情報を集約した postPages.json を作成した。ファイル更新履歴等はそのうち github から取得できるようにしたい。

作りたいファイル構成

// {
//   id: '20200526-next-portfolio',
//   title: 'Qiita: Next.jsでポートフォリオサイトを作成した',
//   create: '2020-05-26',
//   update: '2020-06-05',
//   tags: ['qiita', 'react', 'next.js', 'remark.js', 'vercel'],
// },
postsMap generator script
// script/genPagesMap.mjs
import path from 'path'
import fs from 'fs'
import matter from 'gray-matter'

const postsDirectory = path.join(process.cwd(), 'src/docs')
const fileNames = fs.readdirSync(postsDirectory)
const allPostsData = fileNames.map((fileName) => {
  const id = fileName.replace(/\.md$/, '')
  const fullPath = path.join(postsDirectory, fileName)
  const fileContents = fs.readFileSync(fullPath, 'utf8')
  const matterResult = matter(fileContents)
  const LowerCaseTags = matterResult.data.tags.map((tag) => (tag.toLowerCase()))
  
  const title = matterResult.data.title
  const create = matterResult.data.create
  const update = matterResult.data.update || ''
  const tags = LowerCaseTags || ''
  return {
    id,
    title,
    create,
    update,
    tags
  }
})

const sortedPostsData = allPostsData.sort((a, b) => {
    if (a.create < b.create) {
      return 1
    } else {
      return -1
    }
})

fs.writeFileSync(
  path.join(process.cwd(), 'gen/postPages.json'),
  JSON.stringify(sortedPostsData, undefined, 2),
  'utf-8'
)
sitemap.xml

@2020-07-01
kuflash / react-router-sitemapIlusionDev / nextjs-sitemap-generator等があるが、next.js なので、react-router を使ってないし、xml の構造は簡単そうだったので自作した。

sitemap.xml の基本構成 

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>http://www.example.com/</loc>
    <lastmod>2005-01-01</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
</urlset> 

xmlはファイル頭に空白行が入ると、<?xml ?>の宣言が無いと言ってエラーを吐く

sitemap.xml generator script
// script/genSiteMap.mjs
import path from 'path'
import fs from 'fs'

const base = 'https://oriverk.dev'
const fixed = [
  { url: base, update: '2020-06-26' },
  { url: '/posts', update: '2020-06-30' },
  { url: '/tags', update: '2020-06-26' }
]

const posts = JSON.parse(fs.readFileSync(
  path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
))

const sitemap = `<?xml version="1.0"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml"
        xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0"
        xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"
        xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${fixed.map((f) => {
  return `<url>
    <loc>${base === f.url ? base : base + f.url}</loc>
    <lastmod>${f.update}</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  `}).join("")}
${posts.map((post) => { return `<url>
    <loc>${base}/posts/${post.id}</loc>
    <lastmod>${post.update || post.create}</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  `}).join("")}
</urlset>`

fs.writeFileSync(path.join(process.cwd(), "public/sitemap.xml"), sitemap)
RSS & Atom

@2020-07-01
RSS 2.0 と Atom 1.0 に対応する。

- RSS 2.0 フォーマット
<?xml version='1.0' encoding='UTF-8'?>
<rss version='2.0'>
	<channel>
		<title>hogehoge foobar</title>
		<link>http://example.com/</link>
		<description>aaaaaaaaaaaaaaaa</description>
		<item>
			<title>tegetege mikan</title>
			<link>http://example.com/post3.html</link>
			<description> this is description</description>
			<pubDate>Wed, 11 Jun 2008 15:30:59 +0900</pubDate>
		</item>
	</channel>
</rss>
- Atom 1.0 フォーマット
<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='ja'>
	<id>tag:example.comfeed/</id>
	<title>example.com update info</title>
	<updated>2020-06-11T15:30:59Z</updated>
	<link rel='alternate' type='text/html' href='http://example.com/feed/' />
	<link rel='self' type='application/atom+xml' href='http://example.com/feed/atom10.xml' />
	<entry>
		<id>http://example.com/post1.html#20080609205030</id>
		<title>foobar</title>
		<link rel='alternate' type='text/html' href='http://example.com/post1.html' />
		<updated>2020-06-09T20:50:30Z</updated>
		<summary>foofoooofooo</summary>
	</entry>
</feed>

RSS と Atom のジェネレーターコードは、基本的に sitemap.xml と同じなので。

RSS 2.0 ジェネレーター
// script/genRss.mjs
import path from 'path'
import fs from 'fs'

const base = {
  url: 'https://oriverk.dev',
  title: "Kawano Yudai's site",
  desc: "This site is for my portfolio and made with React, Next.js"
}
const posts = JSON.parse(fs.readFileSync(
  path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
))

const rss = `<?xml version='1.0'?>
<rss version='2.0'>
  <channel>
    <title>${base.title}</title>
    <link>${base.url}</link>
    <description>${base.desc}</description>
    <language>ja</language>
    <lastBuildDate>${new Date()}</lastBuildDate>/
${posts.map((post) => {
  return `<item>
      <title>${post.title}</title>
      <link>${base.url}/posts/${post.id}</link>
      <description>${post.tags.join(', ')}</description>
      <pubDate>${post.create}</pubDate>
    </item>
  `}).join('')}
  </channel>
</rss>`
fs.writeFileSync(path.join(process.cwd(),'public/rss.xml'), rss)
Atom 1.0 ジェネレーター

Atom の ユニークid をどうしようかと考えましたが、適当に。

// script/genRss.mjs
import path from 'path'
import fs from 'fs-extra'

const base = {
  url: 'https://oriverk.dev',
  title: "Kawano Yudai's site",
  desc: "This site is for my portfolio and made with React, Next.js"
}
const posts = JSON.parse(fs.readFileSync(
  path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
))

const atom = `<?xml version='1.0'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='ja'>
  <id>${base.url}</id>
  <title>${base.title}</title>
  <updated>${new Date()}</updated>
  <link rel='alternate' type='text/html' href='${base.url}' />
  <link rel='self' type='application/atom+xml' href='${base.url + '/atom.xml'}' />
  ${posts.map((post) => {
    return `<entry>
      <id>${post.id}</id>
      <title>${post.title}</title>
      <link rel='alternate' type='text/html' href='${base.url + '/posts/' + post.id}' />
      <updated>${post.update || post.create}</updated>
      <summary>${post.tags.join(', ')}</summary>
    </entry>`}).join('')}
</feed>`
fs.writeFileSync(path.join(process.cwd(), 'public/atom.xml'), atom)

投稿記事の検索に Algolia を利用した。postsMap.json をデータとして投入した。
現在のデータ投入は手動で行っているが、後で postsMap.json生成時に差分があればapiで投入できるようにしたい。

To do

  • CSSの統一
  • AMP対応( 参照:Next.js next/amp
  • コードブロックの言語またはファイル名の出力
  • syntax-highlightの改善
  • postページの目次機能
  • og:image 動的生成コード