├── .eslintrc.json
├── .github
├── dependabot.yml
└── workflows
│ ├── test-n-build.yml
│ └── update-packages.yml
├── .gitignore
├── LICENSE
├── README.md
├── __mocks__
├── fileMock.js
└── styleMock.js
├── __tests__
├── __snapshots__
│ ├── index.test.js.snap
│ └── posts_slug.test.js.snap
├── index.test.js
└── posts_slug.test.js
├── components
├── Layout.js
├── Markdown.js
├── Pager.js
└── __tests__
│ ├── Layout.test.js
│ ├── Markdown.test.js
│ ├── Pager.test.js
│ └── __snapshots__
│ ├── Layout.test.js.snap
│ ├── Markdown.test.js.snap
│ └── Pager.test.js.snap
├── content
└── posts
│ ├── code-highlight.md
│ ├── hello-again-2.md
│ ├── hello-again.md
│ ├── hello.md
│ ├── html.md
│ ├── markdown.md
│ └── with-image.md
├── jest.config.js
├── jest.setup.js
├── lib
├── __tests__
│ └── date.test.js
├── content-loader.js
├── date.js
└── gtag.js
├── package-lock.json
├── package.json
├── pages
├── _app.js
├── _document.js
├── about.js
├── archive
│ └── [page].js
├── index.js
└── posts
│ └── [slug].js
└── public
├── favicon.ico
└── images
└── huper-by-joshua-earle-lWYUA42UmL8-unsplash.jpg
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: /
5 | schedule:
6 | interval: weekly
7 | target-branch: main
8 |
--------------------------------------------------------------------------------
/.github/workflows/test-n-build.yml:
--------------------------------------------------------------------------------
1 | name: "テストとビルド"
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | env:
10 | NODE_VERSION: '18'
11 |
12 | jobs:
13 | jest:
14 | name: Run tests with jest
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v3
19 | - name: Use Node.js
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: ${{ env.NODE_VERSION }}
23 | cache: 'npm'
24 | - name: Install packages
25 | run: npm ci
26 | - name: Run tests
27 | run: npm test
28 | - name: Run Linter
29 | run: npm run lint
30 | - name: Build
31 | run: |
32 | npm run build
33 | npm run export
34 | - name: Archive artifacts
35 | uses: actions/upload-artifact@v3
36 | with:
37 | name: out
38 | path: out
39 |
--------------------------------------------------------------------------------
/.github/workflows/update-packages.yml:
--------------------------------------------------------------------------------
1 | name: "NPM パッケージの自動アップデート"
2 |
3 | on:
4 | schedule:
5 | # 05:20 in UTC is 14:20 in JST.
6 | - cron: '20 5 5 * *'
7 | workflow_dispatch:
8 |
9 | env:
10 | NODE_VERSION: '18'
11 |
12 | jobs:
13 | update:
14 | name: Create Pull Request to update packages
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v3
19 | - name: Use Node.js
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: ${{ env.NODE_VERSION }}
23 | cache: 'npm'
24 | - name: Install packages
25 | run: npm ci
26 | - name: Show outdated packages in summary
27 | uses: actions/github-script@v6
28 | with:
29 | script: |
30 | const { stdout } = await exec.getExecOutput(`npm outdated --json`, [], {
31 | ignoreReturnCode: true,
32 | })
33 | let report
34 | try {
35 | report = JSON.parse(stdout)
36 | } catch (err) {
37 | console.error(err)
38 | return
39 | }
40 |
41 | const header = [
42 | {data: "Name", header: true},
43 | {data: "Current", header: true},
44 | {data: "Latest", header: true},
45 | ]
46 | const rows = Object.entries(report).map(([name, data]) => {
47 | return [name, data['current'], data['latest']]
48 | })
49 | core.summary
50 | .addHeading('Outdated packages :broom:')
51 | .addTable([header, ...rows])
52 | .write()
53 | - name: Update packages
54 | run: npm update
55 | - name: Create Pull Request
56 | uses: peter-evans/create-pull-request@v4
57 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 |
21 | # debug
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | # local env files
27 | .env.local
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 GOTO Hayato
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 2023/04/10 積極的にメンテナンスできなくなったためアーカイブしました。 Next.js は引き続き人気ですが、「 Next.js でブログを作る」という使い方をする人はもう少なくなったのではないかという気がします。
2 |
3 | [](https://github.com/gh640/nextjs-blog-example-ja/actions/workflows/test-n-build.yml)
4 |
5 | # `nextjs-blog-example-ja`
6 |
7 | React ベースのフレームワーク [Next.js](https://nextjs.org/) でシンプルなブログを作るサンプルです。
8 |
9 | 次のページにこのリポジトリのコードを使ったチュートリアルがあります:
10 |
11 | - [Next.js を使った JAMstack なブログの始め方 | gotohayato.com](https://gotohayato.com/content/517/)
12 |
13 | ## 動作環境
14 |
15 | - Node v18
16 |
17 | ## ブランチ構成
18 |
19 | - `main`: そのときどきの最新版
20 |
21 | ## 使用ライブラリ
22 |
23 | 以下の NPM パッケージを使用しています。
24 |
25 | - `next`
26 | - `react`
27 | - `react-dom`
28 | - `react-markdown`
29 | - `react-syntax-highlighter`
30 | - `rehype-raw`
31 | - `remark-gfm`
32 | - `gray-matter`
33 |
34 | Next.js を利用するために必要な `next` `react` `react-dom` の 3 つと、 frontmatter 付きの Markdown の HTML への変換に有用な `react-markdown` `rehype-raw` `remark-gfm` `gray-matter` を使っています。
35 | 追加で、シンタックスハイライトに Prism.js を利用するための `react-syntax-highlighter` を使っています。
36 |
37 | 自動テストには以下のパッケージを使用しています。
38 |
39 | - `@babel/core`
40 | - `@babel/preset-env`
41 | - `@babel/preset-react`
42 | - `babel-jest`
43 | - `identity-obj-proxy`
44 | - `jest`
45 | - `react-test-renderer`
46 |
47 | ## 使い方
48 |
49 | ### GitHub Codespaces でとりあえず動かしてみる
50 |
51 | Codespaces でこのリポジトリを開きます。
52 |
53 | Codespaces の Visual Studio Code が開いたら「 Terminal 」のところから NPM パッケージをインストールします( Codespaces が自動的にインストールすることもあります)。
54 |
55 | ```bash
56 | npm install
57 | ```
58 |
59 | 開発用プレビューを起動します。
60 |
61 | ```bash
62 | npm run dev
63 | ```
64 |
65 | ポート `3000` が開いてポートフォワーディングが行われるのでその URL を開きます。
66 |
67 | 試し終わったら Codespaces のスペースを破棄します。
68 |
69 | ### 開発環境で動かす
70 |
71 | ```bash
72 | # リポジトリをチェックアウト
73 | git checkout https://github.com/gh640/nextjs-blog-example-ja.git
74 |
75 | # プロジェクトルートに移動
76 | cd nextjs-blog-example-ja/
77 |
78 | # npm の依存パッケージをインストール
79 | npm install
80 |
81 | # 開発サーバーを走らせる
82 | # 開発サーバーを止めたい場合は ctrl + c 等で
83 | npm run dev
84 | ```
85 |
86 | ### 公開用に静的サイトを生成する
87 |
88 | ```bash
89 | # ビルド
90 | npm run build
91 |
92 | # 静的サイトを生成して `out/` ディレクトリに出力する
93 | npm run export
94 | ```
95 |
96 | ### Google Analytics のトラッキングコードを入れる
97 |
98 | 環境変数 `GA_TRACKING_ID` でトラッキング ID をセットすれば、 Google Analytics のトラッキングコードをページに埋め込むことができます。
99 |
100 | 環境変数はターミナルでセットする方法と `.env.local` ファイルを使用する方法が用意されているので、どちらでもやりやすい方を選んでください。
101 |
102 | ターミナル:
103 |
104 | ```bash
105 | export GA_TRACKING_ID=UA-XXX-XX
106 | ```
107 |
108 | `.env.local` ファイル:
109 |
110 | ```text
111 | GA_TRACKING_ID=UA-XXX-XX
112 | ```
113 |
114 | 具体的にどのような形でトラッキングコードが埋め込まれているか知りたい場合は、プロジェクト内の `GA_TRACKING_ID` の使用箇所を探してみてください。
115 |
116 | ```bash
117 | rg GA_TRACKING_ID
118 | ```
119 |
120 | 参考:
121 |
122 | - [Basic Features: Environment Variables | Next.js](https://nextjs.org/docs/basic-features/environment-variables)
123 |
124 | ## 自動テストを走らせる
125 |
126 | ```bash
127 | npm test
128 | # または
129 | npm run test
130 | ```
131 |
--------------------------------------------------------------------------------
/__mocks__/fileMock.js:
--------------------------------------------------------------------------------
1 | (module.exports = "test-file-stub")
2 |
--------------------------------------------------------------------------------
/__mocks__/styleMock.js:
--------------------------------------------------------------------------------
1 | module.exports = {};
2 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/index.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Home renders correctly with posts and archive 1`] = `
4 | Array [
5 |
8 |
17 |
20 |
23 | 2020/08/27
24 |
25 |
26 |
,
27 |
30 |
39 |
42 |
45 | 2018/05/27
46 |
47 |
48 |
,
49 | ,
58 | ]
59 | `;
60 |
61 | exports[`Home renders correctly without posts nor archive 1`] = `null`;
62 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/posts_slug.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Post renders correctly with markdown 1`] = `
4 |
5 |
6 | はじめての投稿
7 |
8 |
11 |
12 | 2030/10/20
13 |
14 |
15 |
18 |
19 | 見出し 2
20 |
21 |
22 |
23 |
24 | 昔々あるところに。
25 |
26 |
27 |
28 | `;
29 |
30 | exports[`Post renders correctly with raw html 1`] = `
31 |
32 |
33 | はじめての投稿
34 |
35 |
38 |
39 | 2030/10/20
40 |
41 |
42 |
45 |
46 | テーブルサンプル
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | カラム 1
58 | |
59 |
60 | カラム 2
61 | |
62 |
63 |
64 |
65 |
66 |
67 | 1.1
68 | |
69 |
70 | 1.2
71 | |
72 |
73 |
74 |
75 | 2.1
76 | |
77 |
78 | 2.2
79 | |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | `;
89 |
--------------------------------------------------------------------------------
/__tests__/index.test.js:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 | import renderer from "react-test-renderer"
3 |
4 | import Layout from "../components/Layout"
5 | import Home from "../pages/index.js"
6 |
7 | jest.mock("next/link", () => {
8 | return ({ children, ...attrs }) => {
9 | return {children}
10 | }
11 | })
12 | jest.mock("../components/Layout")
13 |
14 | describe("Home", () => {
15 | Layout.mockImplementation(({ children }) => children)
16 |
17 | it("renders correctly without posts nor archive", () => {
18 | const tree = renderer
19 | .create()
20 | .toJSON()
21 | expect(tree).toMatchSnapshot()
22 | })
23 |
24 | it("renders correctly with posts and archive", () => {
25 | const posts = [
26 | {
27 | title: "かきくけこ",
28 | slug: "kakikukeko",
29 | published: "2020/08/27",
30 | },
31 | {
32 | title: "あいうえお",
33 | slug: "aiueo",
34 | published: "2018/05/27",
35 | },
36 | ]
37 | const tree = renderer
38 | .create()
39 | .toJSON()
40 | expect(tree).toMatchSnapshot()
41 | })
42 | })
43 |
--------------------------------------------------------------------------------
/__tests__/posts_slug.test.js:
--------------------------------------------------------------------------------
1 | import { cloneElement } from "react"
2 | import renderer from "react-test-renderer"
3 |
4 | import Layout from "../components/Layout"
5 | import Post from "../pages/posts/[slug].js"
6 |
7 | jest.mock("next/link", () => {
8 | return ({ children, ...attrs }) => {
9 | return {children}
10 | }
11 | })
12 | jest.mock("../components/Layout")
13 |
14 | describe("Post", () => {
15 | Layout.mockImplementation(({ title, children }) => (
16 |
17 | {title}
18 | {children}
19 |
20 | ))
21 |
22 | it("renders correctly with markdown", () => {
23 | const content = [
24 | "## 見出し 2",
25 | "",
26 | "昔々あるところに。",
27 | ].join("\n")
28 | const tree = renderer
29 | .create()
34 | .toJSON()
35 | expect(tree).toMatchSnapshot()
36 | })
37 |
38 | it("renders correctly with raw html", () => {
39 | const content = [
40 | "テーブルサンプル
",
41 | "",
42 | "
",
43 | " ",
44 | " ",
45 | " カラム 1 | ",
46 | " カラム 2 | ",
47 | "
",
48 | " ",
49 | " ",
50 | " ",
51 | " 1.1 | ",
52 | " 1.2 | ",
53 | "
",
54 | " ",
55 | " 2.1 | ",
56 | " 2.2 | ",
57 | "
",
58 | " ",
59 | "
",
60 | "
",
61 | ].join("\n")
62 | const tree = renderer
63 | .create()
68 | .toJSON()
69 | expect(tree).toMatchSnapshot()
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/components/Layout.js:
--------------------------------------------------------------------------------
1 | import Head from "next/head"
2 | import Link from "next/link"
3 |
4 | const Layout = (props) => {
5 | const { title, children } = props
6 | const siteTitle = "サンプルブログ"
7 |
8 | return (
9 |
10 |
11 |
{title ? `${title} | ${siteTitle}` : siteTitle}
12 |
13 |
14 |
15 |
16 |
17 | ` ではスタイルをつけられないのでインラインで設定する
19 | color: 'inherit',
20 | textDecoration: 'none',
21 | }}>
22 | {siteTitle}
23 |
24 |
25 |
26 |
27 |
28 | {title ? {title}
: null}
29 |
30 | {children}
31 |
32 |
33 |
34 |
37 |
38 |
62 |
63 | )
64 | }
65 |
66 | export default Layout
67 |
--------------------------------------------------------------------------------
/components/Markdown.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 共通のマークダウンレンダラー
3 | */
4 | import * as React from "react"
5 | import ReactMarkdown from "react-markdown"
6 | import rehypeRaw from "rehype-raw"
7 | import remarkGfm from "remark-gfm"
8 |
9 | import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'
10 |
11 | const Markdown = ({ children }) => {
12 | // Markdown 中の HTML を許容するために `rehypeRaw` を利用する
13 | return (
14 |
26 | {String(children).replace(/\n$/, '')}
27 |
28 | ) : (
29 |
30 | {children}
31 |
32 | )
33 | }
34 | }}
35 | >
36 | {children}
37 |
38 | )
39 | }
40 |
41 | export default Markdown
42 |
--------------------------------------------------------------------------------
/components/Pager.js:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | const Pager = (props) => {
4 | const { total, page, perPage, hrefCallback } = props
5 |
6 | const prevPage = page > 1 ? page - 1 : null
7 | let nextPage = null
8 | if (page < Math.ceil(total / perPage)) {
9 | nextPage = page + 1
10 | }
11 |
12 | return (
13 |
14 |
15 | {prevPage ? (
16 | {prevPage}
17 | ) : null}
18 |
19 | {page}
20 |
21 | {nextPage ? (
22 | {nextPage}
23 | ) : null}
24 |
25 |
26 |
38 |
39 | )
40 | }
41 |
42 | export default Pager
43 |
--------------------------------------------------------------------------------
/components/__tests__/Layout.test.js:
--------------------------------------------------------------------------------
1 | import Head from "next/head"
2 | // import Link from "next/link"
3 | import renderer from "react-test-renderer"
4 |
5 | import Layout from "../Layout.js"
6 |
7 | jest.mock("next/head")
8 | jest.mock("next/link", () => {
9 | const MockedLink = ({ children, ...rest }) => {children}
10 | return MockedLink
11 | })
12 |
13 | describe("Layout", () => {
14 | Head.mockImplementation(({ children }) => children)
15 |
16 | it("renders collectly with title", () => {
17 | const tree = renderer
18 | .create()
19 | .toJSON()
20 | expect(tree).toMatchSnapshot()
21 | })
22 |
23 | it("renders collectly with children", () => {
24 | const tree = renderer
25 | .create(
26 |
27 | Hello World
28 |
29 | )
30 | .toJSON()
31 | expect(tree).toMatchSnapshot()
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/components/__tests__/Markdown.test.js:
--------------------------------------------------------------------------------
1 | import renderer from "react-test-renderer"
2 |
3 | import Markdown from "../Markdown.js"
4 |
5 | jest.mock("next/link", () => {
6 | const MockedLink = ({ children, ...rest }) => {children}
7 | return MockedLink
8 | })
9 |
10 | describe("Markdown", () => {
11 | it("renders collectly with simple markdown", () => {
12 | const tree = renderer
13 | .create({`
14 | # 見出し 1
15 |
16 | # 見出し 2
17 |
18 | - これは
19 | - リスト
20 | - です
21 |
22 | テーブル:
23 |
24 | | 西 | 東 |
25 | | --- | --- |
26 | | 西山 | 東山 |
27 |
28 | `})
29 | .toJSON()
30 | expect(tree).toMatchSnapshot()
31 | })
32 |
33 | it("renders collectly with html", () => {
34 | const tree = renderer
35 | .create({`
36 | テーブルサンプル
37 |
38 |
39 |
40 |
41 | カラム 1 |
42 | カラム 2 |
43 |
44 |
45 |
46 |
47 | 1.1 |
48 | 1.2 |
49 |
50 |
51 | 2.1 |
52 | 2.2 |
53 |
54 |
55 |
56 |
57 | `})
58 | .toJSON()
59 | expect(tree).toMatchSnapshot()
60 | })
61 |
62 | })
63 |
--------------------------------------------------------------------------------
/components/__tests__/Pager.test.js:
--------------------------------------------------------------------------------
1 | import renderer from "react-test-renderer"
2 |
3 | import Pager from "../Pager.js"
4 |
5 | jest.mock("next/link", () => {
6 | const MockedLink = ({ children, ...rest }) => {children}
7 | return MockedLink
8 | })
9 |
10 | describe("Pager", () => {
11 | it("renders collectly with first page", () => {
12 | const tree = renderer
13 | .create( `/archive/${page}`}
18 | />)
19 | .toJSON()
20 | expect(tree).toMatchSnapshot()
21 | })
22 |
23 | it("renders collectly with second page", () => {
24 | const tree = renderer
25 | .create( `/archive/${page}`}
30 | />)
31 | .toJSON()
32 | expect(tree).toMatchSnapshot()
33 | })
34 |
35 | it("renders collectly with last page", () => {
36 | const tree = renderer
37 | .create( `/archive/${page}`}
42 | />)
43 | .toJSON()
44 | expect(tree).toMatchSnapshot()
45 | })
46 | })
47 |
--------------------------------------------------------------------------------
/components/__tests__/__snapshots__/Layout.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Layout renders collectly with children 1`] = `
4 |
7 |
10 | サンプルブログ
11 |
12 |
17 |
40 |
43 |
46 |
47 |
48 | Hello World
49 |
50 |
51 |
52 |
53 |
59 |
60 | `;
61 |
62 | exports[`Layout renders collectly with title 1`] = `
63 |
66 |
69 | ページタイトル | サンプルブログ
70 |
71 |
76 |
99 |
102 |
105 | ページタイトル
106 |
107 |
110 |
111 |
117 |
118 | `;
119 |
--------------------------------------------------------------------------------
/components/__tests__/__snapshots__/Markdown.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Markdown renders collectly with html 1`] = `
4 | Array [
5 |
6 | テーブルサンプル
7 |
,
8 | "
9 | ",
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | カラム 1
18 | |
19 |
20 | カラム 2
21 | |
22 |
23 |
24 |
25 |
26 |
27 | 1.1
28 | |
29 |
30 | 1.2
31 | |
32 |
33 |
34 |
35 | 2.1
36 | |
37 |
38 | 2.2
39 | |
40 |
41 |
42 |
43 |
44 |
45 |
,
46 | ]
47 | `;
48 |
49 | exports[`Markdown renders collectly with simple markdown 1`] = `
50 | Array [
51 |
52 | 見出し 1
53 |
,
54 | "
55 | ",
56 |
57 | 見出し 2
58 |
,
59 | "
60 | ",
61 |
62 |
63 |
64 | -
65 | これは
66 |
67 |
68 |
69 | -
70 | リスト
71 |
72 |
73 |
74 | -
75 | です
76 |
77 |
78 |
79 |
,
80 | "
81 | ",
82 |
83 | テーブル:
84 |
,
85 | "
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | ",
100 |
101 |
102 |
103 |
104 | 西
105 | |
106 |
107 | 東
108 | |
109 |
110 |
111 |
112 |
113 |
114 | 西山
115 | |
116 |
117 | 東山
118 | |
119 |
120 |
121 |
,
122 | ]
123 | `;
124 |
--------------------------------------------------------------------------------
/components/__tests__/__snapshots__/Pager.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Pager renders collectly with first page 1`] = `
4 |
25 | `;
26 |
27 | exports[`Pager renders collectly with last page 1`] = `
28 |
49 | `;
50 |
51 | exports[`Pager renders collectly with second page 1`] = `
52 |
79 | `;
80 |
--------------------------------------------------------------------------------
/content/posts/code-highlight.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: コードをきれいに見えるようにしました
3 | published: 2038-03-21
4 | ---
5 |
6 | サンプルです。
7 |
8 | `[slug].js`:
9 |
10 | ```js
11 | /**
12 | * ページコンポーネントで使用する値を用意する
13 | */
14 | export async function getStaticProps({ params }) {
15 | const content = await readContentFile({ fs, slug: params.slug })
16 |
17 | return {
18 | props: {
19 | ...content
20 | }
21 | }
22 | }
23 | ```
24 |
--------------------------------------------------------------------------------
/content/posts/hello-again-2.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: ブログをまた再開します
3 | published: 2028-03-21
4 | ---
5 |
6 | バタバタしており久しぶりの投稿になってしまいました。
7 |
--------------------------------------------------------------------------------
/content/posts/hello-again.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: ブログを再開します
3 | published: 2024-05-27
4 | ---
5 |
6 | 開始したとたんに間が空いてしまいましたが、再開します。
7 |
8 | - 閑さや
9 | - 岩にしみ入る
10 | - 蝉の声
11 |
--------------------------------------------------------------------------------
/content/posts/hello.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: ブログを始めます
3 | published: 2020-07-12
4 | ---
5 |
6 | これからブログを始めます。
7 |
--------------------------------------------------------------------------------
/content/posts/html.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: HTML を試す投稿
3 | published: 2020-07-23
4 | ---
5 |
6 |
10 |
11 | 最初の見出し
12 |
13 | ふたつめの見出し
14 |
15 |
16 | 絶対に開けて見てはいけません
17 | 見ないでと言ったのに見てしまいましたね〜
18 |
19 |
--------------------------------------------------------------------------------
/content/posts/markdown.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Markdown を試す投稿
3 | published: 2020-07-15
4 | ---
5 |
6 | 見出し:
7 |
8 | # h1
9 |
10 | ## h2
11 |
12 | ### h3
13 |
14 | #### h4
15 |
16 | ##### h5
17 |
18 | ###### h6
19 |
20 | テーブル:
21 |
22 | | 西 | 東 |
23 | | --- | --- |
24 | | 西山 | 東山 |
25 |
26 | 順序付きリスト:
27 |
28 | 1. うんだもこら
29 | 2. いけなもんや
30 | 3. あたいがどんの
31 | 4. ちゃわんなんだ
32 | 5. ひにひにさんども
33 | 6. あろもんせば
34 | 7. きれいなもんごわんさ
35 |
--------------------------------------------------------------------------------
/content/posts/with-image.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 画像付きの投稿
3 | published: 2020-07-13
4 | ---
5 |
6 | 
7 |
8 | Photo by Huper by Joshua Earle on Unsplash
9 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /*
2 | * For a detailed explanation regarding each configuration property, visit:
3 | * https://jestjs.io/docs/configuration
4 | */
5 |
6 | module.exports = {
7 | // Automatically clear mock calls and instances between every test
8 | clearMocks: true,
9 | // Indicates whether the coverage information should be collected while executing the test
10 | collectCoverage: true,
11 | // An array of glob patterns indicating a set of files for which coverage information should be collected
12 | collectCoverageFrom: [
13 | '**/*.{js,jsx,ts,tsx}',
14 | '!**/*.d.ts',
15 | '!**/node_modules/**',
16 | '!/out/**',
17 | '!/.next/**',
18 | '!/*.config.js',
19 | '!/coverage/**',
20 | ],
21 | // The directory where Jest should output its coverage files
22 | coverageDirectory: "coverage",
23 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
24 | moduleNameMapper: {
25 | // Handle CSS imports (with CSS modules)
26 | // https://jestjs.io/docs/webpack#mocking-css-modules
27 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
28 |
29 | // Handle CSS imports (without CSS modules)
30 | '^.+\\.(css|sass|scss)$': '/__mocks__/styleMock.js',
31 |
32 | // Handle image imports
33 | // https://jestjs.io/docs/webpack#handling-static-assets
34 | '^.+\\.(jpg|jpeg|png|gif|webp|svg)$': `/__mocks__/fileMock.js`,
35 | },
36 |
37 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
38 | setupFilesAfterEnv: ['/jest.setup.js'],
39 | testPathIgnorePatterns: ['/node_modules/', '/.next/'],
40 | // The test environment that will be used for testing
41 | testEnvironment: "jsdom",
42 | // A map from regular expressions to paths to transformers
43 | transform: {
44 | // // Use babel-jest to transpile tests with the next/babel preset
45 | // // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object
46 | '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }],
47 | },
48 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
49 | transformIgnorePatterns: [
50 | // `export` がシンタックスエラーになるので `node_modules` を一時的に除外する
51 | // "/node_modules/",
52 | '^.+\\.module\\.(css|sass|scss)$',
53 | ],
54 | };
55 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect'
2 |
--------------------------------------------------------------------------------
/lib/__tests__/date.test.js:
--------------------------------------------------------------------------------
1 | import { formatDate } from "../date.js"
2 |
3 | describe("formatDate", () => {
4 | it("returns empty string for data other than Date", () => {
5 | expect(formatDate({})).toBe("")
6 | })
7 |
8 | it("returns collect format with single-digit month", () => {
9 | expect(formatDate(new Date(2018, 4, 7))).toBe("2018/05/07")
10 | })
11 |
12 | it("returns collect format with double-digit-month", () => {
13 | expect(formatDate(new Date(2018, 11, 27))).toBe("2018/12/27")
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/lib/content-loader.js:
--------------------------------------------------------------------------------
1 | import path from "path"
2 |
3 | import matter from "gray-matter"
4 |
5 | import { formatDate } from "./date"
6 |
7 | const DIR = path.join(process.cwd(), "content/posts")
8 | const EXTENSION = ".md"
9 |
10 | /**
11 | * Markdown のファイル一覧を取得する
12 | */
13 | const listContentFiles = ({ fs }) => {
14 | const filenames = fs.readdirSync(DIR)
15 | return filenames
16 | .filter((filename) => path.parse(filename).ext === EXTENSION)
17 | }
18 |
19 | /**
20 | * Markdown のファイルの中身を全件パースして取得する
21 | */
22 | const readContentFiles = async ({ fs }) => {
23 | const promisses = listContentFiles({ fs })
24 | .map((filename) => readContentFile({ fs, filename }))
25 |
26 | const contents = await Promise.all(promisses)
27 |
28 | return contents.sort(sortWithProp('published', true))
29 | }
30 |
31 | /**
32 | * Markdown のファイルの中身をパースして取得する
33 | */
34 | const readContentFile = async ({ fs, slug, filename }) => {
35 | if (slug === undefined) {
36 | slug = path.parse(filename).name
37 | }
38 | const raw = fs.readFileSync(path.join(DIR, `${slug}${EXTENSION}`), 'utf8')
39 | const matterResult = matter(raw)
40 |
41 | const { title, published: rawPublished } = matterResult.data
42 |
43 | return {
44 | title,
45 | published: formatDate(rawPublished),
46 | content: matterResult.content,
47 | slug,
48 | }
49 | }
50 |
51 | /**
52 | * Markdown の投稿をソートするためのヘルパー
53 | */
54 | const sortWithProp = (name, reversed) => (a, b) => {
55 | if (reversed) {
56 | return a[name] < b[name] ? 1 : -1
57 | } else {
58 | return a[name] < b[name] ? -1 : 1
59 | }
60 | }
61 |
62 | /**
63 | * 指定された node attributes を追加する remark プロセッサ
64 | */
65 | const genAttrsAdder = (type, attrs) => {
66 | return transformer
67 |
68 | function transformer(node) {
69 | if (node.type === type) {
70 | node.data = node.data || {}
71 | node.data.hProperties = Object.assign({}, node.data.hProperties, attrs)
72 | }
73 |
74 | if (node.children) {
75 | node.children.map((child) => transformer(child))
76 | }
77 | }
78 | }
79 |
80 | export { listContentFiles, readContentFiles, readContentFile }
81 |
--------------------------------------------------------------------------------
/lib/date.js:
--------------------------------------------------------------------------------
1 | const formatDate = (date) => {
2 | if (!(date instanceof Date)) {
3 | return ''
4 | }
5 |
6 | let year = date.getFullYear()
7 | let month = (date.getMonth() < 9 ? '0' : '') + (date.getMonth() + 1)
8 | let day = (date.getDate() < 10 ? '0' : '') + date.getDate()
9 |
10 | return `${year}/${month}/${day}`
11 | }
12 |
13 | export { formatDate }
14 |
--------------------------------------------------------------------------------
/lib/gtag.js:
--------------------------------------------------------------------------------
1 | // See: https://github.com/zeit/next.js/blob/v9.3.1/examples/with-google-analytics/lib/gtag.js
2 | const GA_TRACKING_ID = process.env.GA_TRACKING_ID
3 |
4 | // https://developers.google.com/analytics/devguides/collection/gtagjs/pages
5 | const pageview = (url) => {
6 | window.gtag('config', GA_TRACKING_ID, {
7 | page_path: url,
8 | })
9 | }
10 |
11 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events
12 | const event = ({ action, category, label, value }) => {
13 | window.gtag('event', action, {
14 | event_category: category,
15 | event_label: label,
16 | value: value,
17 | })
18 | }
19 |
20 | export { GA_TRACKING_ID, pageview, event }
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-blog-example-ja",
3 | "version": "13.0.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "export": "next export",
11 | "test": "jest"
12 | },
13 | "dependencies": {
14 | "@next/font": "^13.1.6",
15 | "gray-matter": "^4.0.2",
16 | "next": "^13.1.6",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "react-markdown": "^8.0.5",
20 | "react-syntax-highlighter": "^15.5.0",
21 | "rehype-raw": "^6.1.0",
22 | "remark-gfm": "^3.0.1"
23 | },
24 | "devDependencies": {
25 | "@babel/core": "^7.14.5",
26 | "@babel/preset-env": "^7.14.5",
27 | "@babel/preset-react": "^7.14.5",
28 | "@testing-library/jest-dom": "^5.14.1",
29 | "@testing-library/react": "^13.4.0",
30 | "babel-jest": "^28.1.3",
31 | "eslint": "8.33.0",
32 | "eslint-config-next": "13.1.6",
33 | "identity-obj-proxy": "^3.0.0",
34 | "jest": "^28.1.3",
35 | "jest-environment-jsdom": "^28.1.3",
36 | "react-test-renderer": "^18.2.0"
37 | },
38 | "engines": {
39 | "node": ">=18.0.0"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import Router from 'next/router'
2 | import { Montserrat, Noto_Sans_JP } from '@next/font/google'
3 | import { GA_TRACKING_ID, pageview } from '../lib/gtag'
4 |
5 | /*
6 | * Google Fonts を利用する
7 | *
8 | * - Montserrat
9 | * - Noto Sans JP
10 | */
11 | const montserrat = Montserrat({ subsets: ['latin'] })
12 | const noto_sans_jp = Noto_Sans_JP({
13 | weight: ['400', '700'],
14 | subsets: ['latin'],
15 | fallback: ['-apple-system', 'Segoe UI', 'Helvetica Neue'],
16 | })
17 |
18 | if (GA_TRACKING_ID) {
19 | Router.events.on('routeChangeComplete', url => pageview(url))
20 | }
21 |
22 | // This default export is required in a new `pages/_app.js` file.
23 | export default function MyApp({ Component, pageProps }) {
24 | return (
25 | <>
26 |
64 |
65 | >
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from 'next/document'
2 |
3 | import { GA_TRACKING_ID } from '../lib/gtag'
4 |
5 | class MyDocument extends Document {
6 | render() {
7 | return (
8 |
9 |
10 | { /* gtag / Google Analytics を利用する */}
11 | {
12 | GA_TRACKING_ID &&
16 | }
17 | {
18 | GA_TRACKING_ID &&
31 | }
32 |
33 |
34 |
35 |
36 |
37 |
38 | )
39 | }
40 | }
41 |
42 | export default MyDocument
43 |
--------------------------------------------------------------------------------
/pages/about.js:
--------------------------------------------------------------------------------
1 | const About = () => {
2 | return ブログを書いています。
3 | }
4 |
5 | export default About
6 |
--------------------------------------------------------------------------------
/pages/archive/[page].js:
--------------------------------------------------------------------------------
1 | import fs from "fs"
2 |
3 | import Link from "next/link"
4 |
5 | import Layout from "../../components/Layout"
6 | import Pager from "../../components/Pager"
7 | import { listContentFiles, readContentFiles } from "../../lib/content-loader"
8 |
9 | const COUNT_PER_PAGE = 10
10 |
11 | export default function Archive(props) {
12 | const { posts, page, total, perPage } = props
13 | return (
14 |
15 | {posts.map((post) =>
19 |
{post.title}
20 |
{post.published}
21 |
)}
22 |
23 | `/archive/${page}`}
26 | />
27 |
28 |
37 |
38 | );
39 | }
40 |
41 | /**
42 | * ページコンポーネントで使用する値を用意する
43 | */
44 | export async function getStaticProps({ params }) {
45 | const page = parseInt(params.page, 10)
46 | const end = COUNT_PER_PAGE * page
47 | const start = end - COUNT_PER_PAGE
48 | const posts = await readContentFiles({ fs })
49 |
50 | return {
51 | props: {
52 | posts: posts.slice(start, end),
53 | page,
54 | total: posts.length,
55 | perPage: COUNT_PER_PAGE,
56 | }
57 | }
58 | }
59 |
60 | /**
61 | * 有効な URL パラメータを全件返す
62 | */
63 | export async function getStaticPaths() {
64 | const posts = await listContentFiles({ fs })
65 | const pages = range(Math.ceil(posts.length / COUNT_PER_PAGE))
66 | const paths = pages.map((page) => ({
67 | params: { page: `${page}` }
68 | }))
69 |
70 | return { paths: paths, fallback: false }
71 | }
72 |
73 | /**
74 | * ユーティリティ: 1 から指定された整数までを格納した Array を返す
75 | */
76 | function range(stop) {
77 | if (typeof stop !== 'number') {
78 | throw `Invalid type: ${stop}.`
79 | }
80 |
81 | if (stop < 1) {
82 | throw `Invalid value: ${stop}.`
83 | }
84 |
85 | return Array.from({ length: stop }, (_, i) => i + 1)
86 | }
87 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import fs from "fs"
2 |
3 | import Link from "next/link"
4 |
5 | import Layout from "../components/Layout"
6 | import { readContentFiles } from "../lib/content-loader"
7 |
8 | export default function Home(props) {
9 | const { posts, hasArchive } = props
10 | return (
11 |
12 | {posts.map((post) =>
16 |
{post.title}
17 |
{post.published}
18 |
)}
19 |
20 | {hasArchive ? (
21 |
22 | アーカイブ
23 |
24 | ) : null}
25 |
26 |
42 |
43 | );
44 | }
45 |
46 | /**
47 | * ページコンポーネントで使用する値を用意する
48 | */
49 | export async function getStaticProps({ params }) {
50 | const MAX_COUNT = 5
51 | const posts = await readContentFiles({ fs })
52 | const hasArchive = posts.length > MAX_COUNT
53 |
54 | return {
55 | props: {
56 | posts: posts.slice(0, MAX_COUNT),
57 | hasArchive,
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/pages/posts/[slug].js:
--------------------------------------------------------------------------------
1 | import fs from "fs"
2 | import path from "path"
3 |
4 | import Layout from "../../components/Layout"
5 | import Markdown from "../../components/Markdown"
6 | import { listContentFiles, readContentFile } from "../../lib/content-loader"
7 |
8 | export default function Post(params) {
9 | return (
10 |
11 |
12 | {params.published}
13 |
14 |
15 | {params.content}
16 |
17 |
18 | )
19 | }
20 |
21 | /**
22 | * ページコンポーネントで使用する値を用意する
23 | */
24 | export async function getStaticProps({ params }) {
25 | const content = await readContentFile({ fs, slug: params.slug })
26 |
27 | return {
28 | props: {
29 | ...content
30 | }
31 | }
32 | }
33 |
34 | /**
35 | * 有効な URL パラメータを全件返す
36 | */
37 | export async function getStaticPaths() {
38 | const paths = listContentFiles({ fs })
39 | .map((filename) => ({
40 | params: {
41 | slug: path.parse(filename).name,
42 | }
43 | }))
44 |
45 | return { paths, fallback: false }
46 | }
47 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gh640/nextjs-blog-example-ja/daf988adba0592af438a53e7056eabf9f61d6bfa/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/huper-by-joshua-earle-lWYUA42UmL8-unsplash.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gh640/nextjs-blog-example-ja/daf988adba0592af438a53e7056eabf9f61d6bfa/public/images/huper-by-joshua-earle-lWYUA42UmL8-unsplash.jpg
--------------------------------------------------------------------------------