Skip to content

Commit be80d32

Browse files
authored
feat: App router migration for Next.js 13 (#111)
* feat: migrate Homepage to app router * feat: migrate /maintain to app router * feat: migrate 404 page to app router * feat: migrate /about to app router * feat: migrate error page to app router * style: rename files to align the naming convention * feat: migrate notion article list page to app router * feat: migrate notion article detail page to app router * style: rename namespace to fit the new page types * refactor: remove unused redux slices and page types * feat: migrate /api/sitemap to app router * refactor: remove unused page routers * fix: use next.js notFound instead of throwing Error * fix: fix page metadata title * fix: return 404 for external notion uuid * chore: only log 1 dumpaccess once * test: fix broken meta tag e2e testing * test: increase testing timeout * fix: fix broken build
1 parent 18d9566 commit be80d32

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+870
-1617
lines changed

next.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ module.exports = withBundleAnalyzer({
4444

4545
webpack(cfg) {
4646
validateRequiredEnv()
47+
48+
// disable resolving canvas module for react-pdf in Next.js
49+
// https://github.com/wojtekmaj/react-pdf?tab=readme-ov-file#nextjs
50+
cfg.resolve.alias.canvas = false
4751
return cfg
4852
},
4953
images: {

site.config.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const normalizeId = (id) => {
1717

1818
// env-var cannot read `NEXT_PUBLIC_` prefix env variables on client-side
1919
const currentEnv = process.env.NEXT_PUBLIC_APP_ENV || 'production'
20-
module.exports = {
20+
const siteConfig = {
2121
aws: {
2222
s3bucket: env.get('AWS_S3_BUCKET').asString(),
2323
},
@@ -41,7 +41,6 @@ module.exports = {
4141
url: env.get('CACHE_CLIENT_API_URL').asString(),
4242
},
4343
cdnHost: 'static.dazedbear.pro',
44-
currentEnv,
4544
failsafe: {
4645
// AWS S3 upload limit rate: 3500 per sec, ref: https://docs.aws.amazon.com/zh_tw/AmazonS3/latest/userguide/optimizing-performance.html
4746
// concurrency limit to 30 since redis max connection is fixed to 30 based on the basic plan, ref: https://redis.com/redis-enterprise-cloud/pricing/
@@ -272,3 +271,8 @@ module.exports = {
272271
},
273272
},
274273
}
274+
275+
siteConfig.currentEnv = currentEnv
276+
siteConfig.currentWebsite = siteConfig.website[currentEnv]
277+
278+
module.exports = siteConfig
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import NotionArticleDetailPage from '../../notion/article-detail-page'
2+
import { getNotionContent } from '../../notion/content'
3+
import { PAGE_TYPE_NOTION_ARTICLE_DETAIL_PAGE } from '../../../libs/constant'
4+
import { getPageMeta } from '../../../libs/util'
5+
import { getPageProperty } from '../../../libs/notion'
6+
import log from '../../../libs/server/log'
7+
8+
export async function generateMetadata({ params, searchParams }) {
9+
const { pageName, pageSlug } = params
10+
11+
// hack way to get fetched article property.
12+
// TODO: need to find a way to pass property instead of redundant request.
13+
const { pageContent, pageId } = await getNotionContent({
14+
pageType: PAGE_TYPE_NOTION_ARTICLE_DETAIL_PAGE,
15+
pageName,
16+
pageSlug,
17+
searchParams,
18+
})
19+
const property: any = getPageProperty({ pageId, recordMap: pageContent })
20+
const metaOverride = {
21+
title: property?.PageTitle,
22+
}
23+
24+
return getPageMeta(metaOverride, pageName)
25+
}
26+
27+
const ArticleListPage = async ({ params, searchParams }) => {
28+
const { pageName, pageSlug } = params
29+
const { menuItems, pageContent, pageId, toc } = await getNotionContent({
30+
pageType: PAGE_TYPE_NOTION_ARTICLE_DETAIL_PAGE,
31+
pageName,
32+
pageSlug,
33+
searchParams,
34+
})
35+
36+
log({
37+
category: PAGE_TYPE_NOTION_ARTICLE_DETAIL_PAGE,
38+
message: `dumpaccess to /${pageName}/${pageSlug}`,
39+
level: 'info',
40+
})
41+
return (
42+
<NotionArticleDetailPage
43+
pageId={pageId}
44+
pageName={pageName}
45+
pageContent={pageContent}
46+
menuItems={menuItems}
47+
toc={toc}
48+
/>
49+
)
50+
}
51+
52+
export default ArticleListPage

src/app/[pageName]/page.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import NotionArticleListPage from '../notion/article-list-page'
2+
import { getNotionContent } from '../notion/content'
3+
import { PAGE_TYPE_NOTION_ARTICLE_LIST_PAGE } from '../../libs/constant'
4+
import { getPageMeta } from '../../libs/util'
5+
import log from '../../libs/server/log'
6+
7+
export async function generateMetadata({ params: { pageName } }) {
8+
return getPageMeta({}, pageName)
9+
}
10+
11+
const ArticleListPage = async ({ params, searchParams }) => {
12+
const { pageName } = params
13+
const { menuItems, articleStream } = await getNotionContent({
14+
pageType: PAGE_TYPE_NOTION_ARTICLE_LIST_PAGE,
15+
pageName,
16+
searchParams,
17+
})
18+
19+
log({
20+
category: PAGE_TYPE_NOTION_ARTICLE_LIST_PAGE,
21+
message: `dumpaccess to /${pageName}`,
22+
level: 'info',
23+
})
24+
return (
25+
<NotionArticleListPage
26+
pageName={pageName}
27+
menuItems={menuItems}
28+
articleStream={articleStream}
29+
/>
30+
)
31+
}
32+
33+
export default ArticleListPage

src/app/about/page.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { getPageMeta } from '../../libs/util'
2+
import NotionSinglePage from '../notion/single-page'
3+
import { getNotionContent } from '../notion/content'
4+
import { PAGE_TYPE_NOTION_SINGLE_PAGE } from '../../libs/constant'
5+
import log from '../../libs/server/log'
6+
7+
const pageName = 'about'
8+
9+
export async function generateMetadata() {
10+
return getPageMeta({}, pageName)
11+
}
12+
13+
const AboutPage = async ({ searchParams }) => {
14+
const { pageContent } = await getNotionContent({
15+
pageType: PAGE_TYPE_NOTION_SINGLE_PAGE,
16+
pageName,
17+
searchParams,
18+
})
19+
20+
log({
21+
category: PAGE_TYPE_NOTION_SINGLE_PAGE,
22+
message: `dumpaccess to /${pageName}`,
23+
level: 'info',
24+
})
25+
return <NotionSinglePage pageName="about" pageContent={pageContent} />
26+
}
27+
28+
export default AboutPage

src/pages/api/sitemap.ts renamed to src/app/api/sitemap/route.ts

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,35 @@
1-
import { NextApiRequest, NextApiResponse } from 'next'
2-
import {
3-
getCategory,
4-
validateRequest,
5-
setAPICacheHeaders,
6-
} from '../../libs/server/api'
1+
import type { NextRequest } from 'next/server'
72
import get from 'lodash/get'
83
import { SitemapStream, streamToPromise } from 'sitemap'
94
import { Readable } from 'stream'
105
import pMap from 'p-map'
116
import * as dayjs from 'dayjs'
127
import utc from 'dayjs/plugin/utc'
13-
import log from '../../libs/server/log'
14-
import { fetchArticleStream } from '../../libs/server/page'
8+
import log from '../../../libs/server/log'
9+
import { fetchArticleStream } from '../../../libs/server/page'
1510
import {
1611
transformArticleStream,
1712
transformPageUrls,
18-
} from '../../libs/server/transformer'
13+
} from '../../../libs/server/transformer'
1914
import {
2015
currentEnv,
2116
pages,
2217
notion,
2318
cache as cacheConfig,
2419
website,
25-
} from '../../../site.config'
26-
import cacheClient from '../../libs/server/cache'
27-
import { FORCE_CACHE_REFRESH_QUERY } from '../../libs/constant'
28-
29-
const route = '/sitemap'
30-
const methods = ['GET']
20+
} from '../../../../site.config'
21+
import cacheClient from '../../../libs/server/cache'
22+
import { FORCE_CACHE_REFRESH_QUERY } from '../../../libs/constant'
3123

3224
dayjs.extend(utc)
33-
const category = getCategory(route)
3425

35-
const generateSiteMapXml = async (req) => {
26+
const category = 'API route: /api/sitemap'
27+
28+
const generateSiteMapXml = async () => {
3629
// get all enabled static page paths
3730
const pageUrls = Object.values(pages)
38-
.map((item) => item.enabled && item.page)
39-
.filter((path) => path)
31+
.filter((item) => item.enabled)
32+
.map((item) => item.page)
4033

4134
// get all enabled notion list page paths
4235
const currentNotionListUrls: string[][] = await pMap(
@@ -48,14 +41,12 @@ const generateSiteMapXml = async (req) => {
4841
log({
4942
category,
5043
message: `skip generate urls since this pageName is disabled | pageName: ${pageName}`,
51-
req,
5244
})
5345
return []
5446
}
5547
switch (pageType) {
5648
case 'stream': {
5749
const response = await fetchArticleStream({
58-
req,
5950
pageName,
6051
category,
6152
})
@@ -80,7 +71,7 @@ const generateSiteMapXml = async (req) => {
8071
)
8172

8273
// all collected urls
83-
const urls = [].concat(pageUrls, notionUrls).map((url) => ({ url }))
74+
const urls = [...pageUrls, ...notionUrls].map((url) => ({ url }))
8475

8576
// generate sitemap xml
8677
const stream = new SitemapStream({
@@ -95,32 +86,57 @@ const generateSiteMapXml = async (req) => {
9586
return sitemapXml
9687
}
9788

98-
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
99-
try {
100-
validateRequest(req, { route, methods })
89+
export async function GET(req: NextRequest) {
90+
const searchParams = req.nextUrl.searchParams
91+
const headers = req.headers
10192

93+
try {
10294
log({
10395
category,
10496
message: 'dumpaccess',
105-
req,
10697
})
10798

10899
// sitemap cache
109-
cacheConfig.forceRefresh = req.query[FORCE_CACHE_REFRESH_QUERY] === '1'
100+
cacheConfig.forceRefresh =
101+
searchParams.get(FORCE_CACHE_REFRESH_QUERY) === '1'
102+
110103
const sitemapXmlKey = `sitemap_${dayjs.utc().format('YYYY-MM-DD')}`
111104
const sitemapXml = await cacheClient.proxy(
112105
sitemapXmlKey,
113106
'/api/sitemap',
114-
generateSiteMapXml.bind(this, req),
107+
generateSiteMapXml.bind(this),
115108
{ ttl: cacheConfig.ttls.sitemap }
116109
)
117-
setAPICacheHeaders(res)
118-
res.setHeader('Content-Type', 'application/xml')
119-
res.status(200).end(sitemapXml)
110+
111+
const newHeaders = {
112+
...headers,
113+
'Content-Type': 'application/xml',
114+
/**
115+
* < s-maxage: data is fresh, serve cache. X-Vercel-Cache HIT
116+
* s-maxage - stale-while-revalidate: data is stale, still serve cache and start background new cache generation. X-Vercel-Cache STALE
117+
* > stale-while-revalidate: data is stale and cache won't be used any more. X-Vercel-Cache MISS
118+
*
119+
* @see https://vercel.com/docs/concepts/edge-network/caching#serverless-functions---lambdas
120+
* @see https://vercel.com/docs/concepts/edge-network/x-vercel-cache
121+
* @see https://web.dev/stale-while-revalidate/
122+
*/
123+
'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=86400',
124+
}
125+
126+
return new Response(sitemapXml, {
127+
status: 200,
128+
headers: newHeaders,
129+
})
120130
} catch (err) {
121131
const statusCode = err.status || 500
122-
res.status(statusCode).send(err.message)
132+
133+
log({
134+
category,
135+
message: err.message,
136+
})
137+
138+
return new Response('Oops, something went wrong.', {
139+
status: statusCode,
140+
})
123141
}
124142
}
125-
126-
export default handler

src/app/app.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client'
2+
3+
import { usePathname } from 'next/navigation'
4+
import Header from '../components/header'
5+
import Footer from '../components/footer'
6+
import {
7+
useCodeSyntaxHighlight,
8+
useResizeHandler,
9+
useInitLogRocket,
10+
} from '../libs/client/hooks'
11+
import wrapper from '../libs/client/store'
12+
13+
// TODO: new progress bar while route change
14+
15+
const App = ({
16+
// Layouts must accept a children prop.
17+
// This will be populated with nested layouts or pages
18+
children,
19+
}: {
20+
children: React.ReactNode
21+
}) => {
22+
useInitLogRocket()
23+
useResizeHandler()
24+
useCodeSyntaxHighlight()
25+
26+
const pathname = usePathname()
27+
28+
return (
29+
<div id="app">
30+
<Header pathname={pathname || '/'} />
31+
<div id="main-content">{children}</div>
32+
<Footer />
33+
</div>
34+
)
35+
}
36+
37+
export default wrapper.withRedux(App)

0 commit comments

Comments
 (0)