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

/
#nextjs#typescript

はじめに

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

自分

大学研究で cpp を利用しただけの、農学部卒。

作成に当たって

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

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

Example from the Next.js repo

Default starter appの場合

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

undefined
1
# directory
2
- public
3
- favicon.ico, vercel.svg
4
- pages
5
- index.js
6
- package.json
7
- node_modules
8
- README.md
9
- yarn.lock
package.json
1
{
2
"name": "next-portfolio",
3
"version": "0.1.0",
4
"private": true,
5
"scripts": {
6
"dev": "next dev",
7
"build": "next build",
8
"start": "next start"
9
},
10
"dependencies": {
11
"next": "9.3.5",
12
"react": "16.13.1",
13
"react-dom": "16.13.1"
14
}
15
}

Material-UI 導入

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

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

create src/pages/index.jsx

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

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

src/components/Layout.jsx
1
import Link from 'next/link'
2
import { makeStyles, useTheme } from '@material-ui/core/styles'
3
import Hidden from '@material-ui/core/Hidden'
4
import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'
5
import Drawer from '@material-ui/core/Drawer'
6
import DoubleArrowIcon from '@material-ui/icons/DoubleArrow'
7
import { List, ListItem, ListItemIcon, ListItemText, Divider } from '@material-ui/core'
8
import HomeIcon from '@material-ui/icons/Home'
9
10
import { MyDrawerList } from '../components/MyDrawerList'
11
12
const drawerWidth = 250
13
const useStyles = makeStyles((theme) => ({
14
// ...
15
}))
16
17
export function Layout({ children }) {
18
// ...
19
const [state, setState] = React.useState({
20
left: false,
21
})
22
23
// swipeable-drawerの開閉を制御するボタン
24
const toggleDrawer = (anchor, open) => (event) => {
25
if (event && event.type === 'keydown' && (event.key === 'Tab' || event.key === 'Shift')) {
26
return
27
}
28
setState({ ...state, [anchor]: open })
29
}
30
31
const HomeDrawerList = () => {
32
return (
33
<MyDrawerList>
34
<List>
35
<Link href='/'>
36
<ListItem button>
37
<ListItemIcon><HomeIcon /></ListItemIcon>
38
<ListItemText primary='Home' />
39
</ListItem>
40
</Link>
41
// ...
42
</List>
43
</MyDrawerList>
44
)
45
}
46
47
return (
48
<React.Fragment key='left'>
49
<Hidden lgUp>
50
// モバイル端末用
51
// if display-width > 1280px, display: none
52
<SwipeableDrawer anchor='left' open={state['left']}
53
onClose={toggleDrawer('left', false)} onOpen={toggleDrawer('left', true)}
54
>
55
<div className='swipeableList' role='presentation'
56
onClick={toggleDrawer('left', false)} onKeyDown={toggleDrawer('left', false)}
57
>
58
<HomeDrawerList />
59
</div>
60
</SwipeableDrawer>
61
<footer>
62
<button onClick={toggleDrawer('left', true)}>
63
<DoubleArrowIcon color='secondary' style={{ fontSize: 34 }} />
64
</button>
65
</footer>
66
</Hidden>
67
<Hidden mdDown>
68
// 非モバイルディスプレイ用
69
// if device-width < 1280px, display:none
70
<aside>
71
<Drawer className='permanentDrawer' variant='permanent' anchor='left'>
72
<HomeDrawerList />
73
</Drawer>
74
</aside>
75
</Hidden>
76
<main className={classes.contents}>
77
{children}
78
</main>
79
<style jsx>{`
80
// ...
81
`}</style>
82
</React.Fragment>
83
)
84
}
  • pages/index.jsx の作成

まだ React 等に不慣れなので、pages/index.js にサイト 1 ページ目を作りこんで、後から component に分割する方式をとった。

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

src/pages/index.jsx
1
import Head from 'next/head'
2
import { Layout } from '../components/Layout'
3
import { Top, About, History, Works } from '../components/HomeContents'
4
import manifest from '../../public/manifest.json'
5
6
export default function Home() {
7
return (
8
<>
9
<Layout>
10
<Head>
11
<title>{manifest.name}</title>
12
<meta name='title' content={manifest.name} />
13
<meta name='description' content={manifest.description} />
14
<meta property='og:title' content={manifest.name} />
15
<meta property='og:description' content={manifest.description} />
16
<meta property='og:image' content={`${manifest.vercel}/assets/prtsc700.jpg`} />
17
<meta property='og:url' content={`${manifest.vercel}`} />
18
</Head>
19
<Top />
20
<About />
21
<History />
22
<Works />
23
</Layout>
24
<style jsx global>{`
25
// ...
26
`}</style>
27
</>
28
)
29
}
src/components/HomeContents.jsx
1
import Link from 'next/link'
2
import Grid from '@material-ui/core/Grid'
3
4
export function Top() {
5
return <section id='top' className='topContainer' />
6
}
7
8
export function About() {
9
return (
10
<section id='about' className='content'>
11
<h2>About</h2>
12
<Grid container spacing={4}>
13
<Grid item md={12} lg={5}>
14
<picture>
15
...
16
</picture>
17
</Grid>
18
<Grid item md={12} lg={7}>
19
<p>My name is Hoge.</p>
20
</Grid>
21
</Grid>
22
</section>
23
);
24
}
25
26
export function Works() {
27
return <section id='works' className='content' />
28
}
29
30
export function History() {
31
return <section id='history' className='content' />
32
}

_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周辺の作成

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

ディレクトリ構成

undefined
1
- pages (*directory)
2
- index.jsx
3
- posts (*directory)
4
- hoge.jsx
5
- [id].jsx

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

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

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

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

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 を使用した。

マークダウン

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

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

/src/lib/posts.js
1
import fs from 'fs'
2
import path from 'path'
3
import matter from 'gray-matter'
4
import remark from 'remark'
5
import html from 'remark-html'
6
7
export async function getPostData(id) {
8
const fullPath = path.join(postsDirectory, `${id}.md`)
9
const fileContents = fs.readFileSync(fullPath, 'utf8')
10
const matterResult = matter(fileContents)
11
const LowerCaseTags = matterResult.data.tags.map((tag) => (tag.toLowerCase()))
12
const highlight = require('remark-highlight.js')
13
14
const processedContent = await remark()
15
.use(highlight)
16
.use(html)
17
.process(matterResult.content)
18
19
const contentHtml = processedContent.toString()
20
21
return {
22
id,
23
contentHtml,
24
LowerCaseTags,
25
...matterResult.data,
26
}
27
}

meta

Image from Gyazo

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

Image from Gyazo
frontmatter
1
---
2
date: '2020-05-26'
3
author: Kawano Yudai
4
title: 'Qiita: Next.jsでポートフォリオサイトを作成した'
5
tags: [Qiita, React, Next.js]
6
image: '/assets/posts/202003/miyazaki-oss1.jpg'
7
---

SNSシェアボタン

/src/pages/posts/[id].jsx
1
<button className='twitter'>
2
<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`}
3
target='_blank' rel='noopener noreferrer'><TwitterIcon /></a>
4
</button>
5
<button className='hatena'>
6
<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'
7
title={postData.title} target='_blank' rel='noopener noreferrer'><HatenaIcon /></a>
8
</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
1
const withPWA = require("next-pwa");
2
3
module.exports = withPWA({
4
pwa: {
5
dest: "public"
6
}
7
});

TypeScirpt

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

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

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

npm-script

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

undefined
1
# pagesMap.json => sitmap.mxl
2
# pagesMap.json + history.json => rss
undefined
1
n --stable
2
# => 12.18.2
3
n --latest
4
# => 14.5.0
5
n latest
6
node -v
7
=> v14.4.0

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

undefined
1
"scripts": {
2
"dev": "next dev",
3
"build": "next build",
4
"local-build": "next build && node script/genRobots.mjs && node script/genPostsMap.mjs && node script/genSiteMap.mjs && node script/genRss.mjs && node script/genAtom.mjs",
5
"start": "next start",
6
},

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

pages.json

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

作りたいファイル構成

undefined
1
// {
2
// id: '20200526-next-portfolio',
3
// title: 'Qiita: Next.jsでポートフォリオサイトを作成した',
4
// create: '2020-05-26',
5
// update: '2020-06-05',
6
// tags: ['qiita', 'react', 'next.js', 'remark.js', 'vercel'],
7
// },

postsMap generator script

script/genPagesMap.mjs
1
import path from 'path'
2
import fs from 'fs'
3
import matter from 'gray-matter'
4
5
const postsDirectory = path.join(process.cwd(), 'src/docs')
6
const fileNames = fs.readdirSync(postsDirectory)
7
const allPostsData = fileNames.map((fileName) => {
8
const id = fileName.replace(/\.md$/, '')
9
const fullPath = path.join(postsDirectory, fileName)
10
const fileContents = fs.readFileSync(fullPath, 'utf8')
11
const matterResult = matter(fileContents)
12
const LowerCaseTags = matterResult.data.tags.map((tag) => (tag.toLowerCase()))
13
14
const title = matterResult.data.title
15
const create = matterResult.data.create
16
const update = matterResult.data.update || ''
17
const tags = LowerCaseTags || ''
18
return {
19
id,
20
title,
21
create,
22
update,
23
tags
24
}
25
})
26
27
const sortedPostsData = allPostsData.sort((a, b) => {
28
if (a.create < b.create) {
29
return 1
30
} else {
31
return -1
32
}
33
})
34
35
fs.writeFileSync(
36
path.join(process.cwd(), 'gen/postPages.json'),
37
JSON.stringify(sortedPostsData, undefined, 2),
38
'utf-8'
39
)
sitemap.xml

@2020-07-01

サイトマップジェネレータライブラリは上記の通りあるが、xml の構造は簡単そうだったので自作した。

sitemap.xml の基本構成

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

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

script/genSiteMap.mjs
1
import path from 'path'
2
import fs from 'fs'
3
4
const base = 'https://oriverk.dev'
5
const fixed = [
6
{ url: base, update: '2020-06-26' },
7
{ url: '/posts', update: '2020-06-30' },
8
{ url: '/tags', update: '2020-06-26' }
9
]
10
11
const posts = JSON.parse(fs.readFileSync(
12
path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
13
))
14
15
const sitemap = `<?xml version="1.0"?>
16
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
17
xmlns:xhtml="http://www.w3.org/1999/xhtml"
18
xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0"
19
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"
20
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
21
${fixed.map((f) => {
22
return `<url>
23
<loc>${base === f.url ? base : base + f.url}</loc>
24
<lastmod>${f.update}</lastmod>
25
<changefreq>daily</changefreq>
26
<priority>1.0</priority>
27
</url>
28
`}).join("")}
29
${posts.map((post) => { return `<url>
30
<loc>${base}/posts/${post.id}</loc>
31
<lastmod>${post.update || post.create}</lastmod>
32
<changefreq>daily</changefreq>
33
<priority>1.0</priority>
34
</url>
35
`}).join("")}
36
</urlset>`
37
38
fs.writeFileSync(path.join(process.cwd(), "public/sitemap.xml"), sitemap)
RSS & Atom

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

RSS 2.0 フォーマット

rss.xml
1
<?xml version='1.0' encoding='UTF-8'?>
2
<rss version='2.0'>
3
<channel>
4
<title>hogehoge foobar</title>
5
<link>http://example.com/</link>
6
<description>aaaaaaaaaaaaaaaa</description>
7
<item>
8
<title>tegetege mikan</title>
9
<link>http://example.com/post3.html</link>
10
<description> this is description</description>
11
<pubDate>Wed, 11 Jun 2008 15:30:59 +0900</pubDate>
12
</item>
13
</channel>
14
</rss>

Atom 1.0 フォーマット

atom.xml
1
<?xml version='1.0' encoding='UTF-8'?>
2
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='ja'>
3
<id>tag:example.comfeed/</id>
4
<title>example.com update info</title>
5
<updated>2020-06-11T15:30:59Z</updated>
6
<link rel='alternate' type='text/html' href='http://example.com/feed/' />
7
<link rel='self' type='application/atom+xml' href='http://example.com/feed/atom10.xml' />
8
<entry>
9
<id>http://example.com/post1.html#20080609205030</id>
10
<title>foobar</title>
11
<link rel='alternate' type='text/html' href='http://example.com/post1.html' />
12
<updated>2020-06-09T20:50:30Z</updated>
13
<summary>foofoooofooo</summary>
14
</entry>
15
</feed>

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

RSS 2.0 ジェネレータ

script/genRss.mjs
1
import path from 'path'
2
import fs from 'fs'
3
4
const base = {
5
url: 'https://oriverk.dev',
6
title: "Kawano Yudai's site",
7
desc: "This site is for my portfolio and made with React, Next.js"
8
}
9
const posts = JSON.parse(fs.readFileSync(
10
path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
11
))
12
13
const rss = `<?xml version='1.0'?>
14
<rss version='2.0'>
15
<channel>
16
<title>${base.title}</title>
17
<link>${base.url}</link>
18
<description>${base.desc}</description>
19
<language>ja</language>
20
<lastBuildDate>${new Date()}</lastBuildDate>/
21
${posts.map((post) => {
22
return `<item>
23
<title>${post.title}</title>
24
<link>${base.url}/posts/${post.id}</link>
25
<description>${post.tags.join(', ')}</description>
26
<pubDate>${post.create}</pubDate>
27
</item>
28
`}).join('')}
29
</channel>
30
</rss>`
31
fs.writeFileSync(path.join(process.cwd(),'public/rss.xml'), rss)

Atom 1.0 ジェネレーター

script/genRss.mjs
1
import path from 'path'
2
import fs from 'fs-extra'
3
4
const base = {
5
url: 'https://oriverk.dev',
6
title: "Kawano Yudai's site",
7
desc: "This site is for my portfolio and made with React, Next.js"
8
}
9
const posts = JSON.parse(fs.readFileSync(
10
path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
11
))
12
13
const atom = `<?xml version='1.0'?>
14
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='ja'>
15
<id>${base.url}</id>
16
<title>${base.title}</title>
17
<updated>${new Date()}</updated>
18
<link rel='alternate' type='text/html' href='${base.url}' />
19
<link rel='self' type='application/atom+xml' href='${base.url + '/atom.xml'}' />
20
${posts.map((post) => {
21
return `<entry>
22
<id>${post.id}</id>
23
<title>${post.title}</title>
24
<link rel='alternate' type='text/html' href='${base.url + '/posts/' + post.id}' />
25
<updated>${post.update || post.create}</updated>
26
<summary>${post.tags.join(', ')}</summary>
27
</entry>`}).join('')}
28
</feed>`
29
fs.writeFileSync(path.join(process.cwd(), 'public/atom.xml'), atom)

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

To do

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