Skip to content

Commit

Permalink
feat: App router migration for Next.js 13 (#111)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
dazedbear authored Jun 10, 2024
1 parent 18d9566 commit be80d32
Show file tree
Hide file tree
Showing 46 changed files with 870 additions and 1,617 deletions.
4 changes: 4 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ module.exports = withBundleAnalyzer({

webpack(cfg) {
validateRequiredEnv()

// disable resolving canvas module for react-pdf in Next.js
// https://github.com/wojtekmaj/react-pdf?tab=readme-ov-file#nextjs
cfg.resolve.alias.canvas = false
return cfg
},
images: {
Expand Down
8 changes: 6 additions & 2 deletions site.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const normalizeId = (id) => {

// env-var cannot read `NEXT_PUBLIC_` prefix env variables on client-side
const currentEnv = process.env.NEXT_PUBLIC_APP_ENV || 'production'
module.exports = {
const siteConfig = {
aws: {
s3bucket: env.get('AWS_S3_BUCKET').asString(),
},
Expand All @@ -41,7 +41,6 @@ module.exports = {
url: env.get('CACHE_CLIENT_API_URL').asString(),
},
cdnHost: 'static.dazedbear.pro',
currentEnv,
failsafe: {
// AWS S3 upload limit rate: 3500 per sec, ref: https://docs.aws.amazon.com/zh_tw/AmazonS3/latest/userguide/optimizing-performance.html
// 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/
Expand Down Expand Up @@ -272,3 +271,8 @@ module.exports = {
},
},
}

siteConfig.currentEnv = currentEnv
siteConfig.currentWebsite = siteConfig.website[currentEnv]

module.exports = siteConfig
52 changes: 52 additions & 0 deletions src/app/[pageName]/[pageSlug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import NotionArticleDetailPage from '../../notion/article-detail-page'
import { getNotionContent } from '../../notion/content'
import { PAGE_TYPE_NOTION_ARTICLE_DETAIL_PAGE } from '../../../libs/constant'
import { getPageMeta } from '../../../libs/util'
import { getPageProperty } from '../../../libs/notion'
import log from '../../../libs/server/log'

export async function generateMetadata({ params, searchParams }) {
const { pageName, pageSlug } = params

// hack way to get fetched article property.
// TODO: need to find a way to pass property instead of redundant request.
const { pageContent, pageId } = await getNotionContent({
pageType: PAGE_TYPE_NOTION_ARTICLE_DETAIL_PAGE,
pageName,
pageSlug,
searchParams,
})
const property: any = getPageProperty({ pageId, recordMap: pageContent })
const metaOverride = {
title: property?.PageTitle,
}

return getPageMeta(metaOverride, pageName)
}

const ArticleListPage = async ({ params, searchParams }) => {
const { pageName, pageSlug } = params
const { menuItems, pageContent, pageId, toc } = await getNotionContent({
pageType: PAGE_TYPE_NOTION_ARTICLE_DETAIL_PAGE,
pageName,
pageSlug,
searchParams,
})

log({
category: PAGE_TYPE_NOTION_ARTICLE_DETAIL_PAGE,
message: `dumpaccess to /${pageName}/${pageSlug}`,
level: 'info',
})
return (
<NotionArticleDetailPage
pageId={pageId}
pageName={pageName}
pageContent={pageContent}
menuItems={menuItems}
toc={toc}
/>
)
}

export default ArticleListPage
33 changes: 33 additions & 0 deletions src/app/[pageName]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import NotionArticleListPage from '../notion/article-list-page'
import { getNotionContent } from '../notion/content'
import { PAGE_TYPE_NOTION_ARTICLE_LIST_PAGE } from '../../libs/constant'
import { getPageMeta } from '../../libs/util'
import log from '../../libs/server/log'

export async function generateMetadata({ params: { pageName } }) {
return getPageMeta({}, pageName)
}

const ArticleListPage = async ({ params, searchParams }) => {
const { pageName } = params
const { menuItems, articleStream } = await getNotionContent({
pageType: PAGE_TYPE_NOTION_ARTICLE_LIST_PAGE,
pageName,
searchParams,
})

log({
category: PAGE_TYPE_NOTION_ARTICLE_LIST_PAGE,
message: `dumpaccess to /${pageName}`,
level: 'info',
})
return (
<NotionArticleListPage
pageName={pageName}
menuItems={menuItems}
articleStream={articleStream}
/>
)
}

export default ArticleListPage
28 changes: 28 additions & 0 deletions src/app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getPageMeta } from '../../libs/util'
import NotionSinglePage from '../notion/single-page'
import { getNotionContent } from '../notion/content'
import { PAGE_TYPE_NOTION_SINGLE_PAGE } from '../../libs/constant'
import log from '../../libs/server/log'

const pageName = 'about'

export async function generateMetadata() {
return getPageMeta({}, pageName)
}

const AboutPage = async ({ searchParams }) => {
const { pageContent } = await getNotionContent({
pageType: PAGE_TYPE_NOTION_SINGLE_PAGE,
pageName,
searchParams,
})

log({
category: PAGE_TYPE_NOTION_SINGLE_PAGE,
message: `dumpaccess to /${pageName}`,
level: 'info',
})
return <NotionSinglePage pageName="about" pageContent={pageContent} />
}

export default AboutPage
84 changes: 50 additions & 34 deletions src/pages/api/sitemap.ts → src/app/api/sitemap/route.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,35 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
getCategory,
validateRequest,
setAPICacheHeaders,
} from '../../libs/server/api'
import type { NextRequest } from 'next/server'
import get from 'lodash/get'
import { SitemapStream, streamToPromise } from 'sitemap'
import { Readable } from 'stream'
import pMap from 'p-map'
import * as dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import log from '../../libs/server/log'
import { fetchArticleStream } from '../../libs/server/page'
import log from '../../../libs/server/log'
import { fetchArticleStream } from '../../../libs/server/page'
import {
transformArticleStream,
transformPageUrls,
} from '../../libs/server/transformer'
} from '../../../libs/server/transformer'
import {
currentEnv,
pages,
notion,
cache as cacheConfig,
website,
} from '../../../site.config'
import cacheClient from '../../libs/server/cache'
import { FORCE_CACHE_REFRESH_QUERY } from '../../libs/constant'

const route = '/sitemap'
const methods = ['GET']
} from '../../../../site.config'
import cacheClient from '../../../libs/server/cache'
import { FORCE_CACHE_REFRESH_QUERY } from '../../../libs/constant'

dayjs.extend(utc)
const category = getCategory(route)

const generateSiteMapXml = async (req) => {
const category = 'API route: /api/sitemap'

const generateSiteMapXml = async () => {
// get all enabled static page paths
const pageUrls = Object.values(pages)
.map((item) => item.enabled && item.page)
.filter((path) => path)
.filter((item) => item.enabled)
.map((item) => item.page)

// get all enabled notion list page paths
const currentNotionListUrls: string[][] = await pMap(
Expand All @@ -48,14 +41,12 @@ const generateSiteMapXml = async (req) => {
log({
category,
message: `skip generate urls since this pageName is disabled | pageName: ${pageName}`,
req,
})
return []
}
switch (pageType) {
case 'stream': {
const response = await fetchArticleStream({
req,
pageName,
category,
})
Expand All @@ -80,7 +71,7 @@ const generateSiteMapXml = async (req) => {
)

// all collected urls
const urls = [].concat(pageUrls, notionUrls).map((url) => ({ url }))
const urls = [...pageUrls, ...notionUrls].map((url) => ({ url }))

// generate sitemap xml
const stream = new SitemapStream({
Expand All @@ -95,32 +86,57 @@ const generateSiteMapXml = async (req) => {
return sitemapXml
}

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
validateRequest(req, { route, methods })
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const headers = req.headers

try {
log({
category,
message: 'dumpaccess',
req,
})

// sitemap cache
cacheConfig.forceRefresh = req.query[FORCE_CACHE_REFRESH_QUERY] === '1'
cacheConfig.forceRefresh =
searchParams.get(FORCE_CACHE_REFRESH_QUERY) === '1'

const sitemapXmlKey = `sitemap_${dayjs.utc().format('YYYY-MM-DD')}`
const sitemapXml = await cacheClient.proxy(
sitemapXmlKey,
'/api/sitemap',
generateSiteMapXml.bind(this, req),
generateSiteMapXml.bind(this),
{ ttl: cacheConfig.ttls.sitemap }
)
setAPICacheHeaders(res)
res.setHeader('Content-Type', 'application/xml')
res.status(200).end(sitemapXml)

const newHeaders = {
...headers,
'Content-Type': 'application/xml',
/**
* < s-maxage: data is fresh, serve cache. X-Vercel-Cache HIT
* s-maxage - stale-while-revalidate: data is stale, still serve cache and start background new cache generation. X-Vercel-Cache STALE
* > stale-while-revalidate: data is stale and cache won't be used any more. X-Vercel-Cache MISS
*
* @see https://vercel.com/docs/concepts/edge-network/caching#serverless-functions---lambdas
* @see https://vercel.com/docs/concepts/edge-network/x-vercel-cache
* @see https://web.dev/stale-while-revalidate/
*/
'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=86400',
}

return new Response(sitemapXml, {
status: 200,
headers: newHeaders,
})
} catch (err) {
const statusCode = err.status || 500
res.status(statusCode).send(err.message)

log({
category,
message: err.message,
})

return new Response('Oops, something went wrong.', {
status: statusCode,
})
}
}

export default handler
37 changes: 37 additions & 0 deletions src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client'

import { usePathname } from 'next/navigation'
import Header from '../components/header'
import Footer from '../components/footer'
import {
useCodeSyntaxHighlight,
useResizeHandler,
useInitLogRocket,
} from '../libs/client/hooks'
import wrapper from '../libs/client/store'

// TODO: new progress bar while route change

const App = ({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode
}) => {
useInitLogRocket()
useResizeHandler()
useCodeSyntaxHighlight()

const pathname = usePathname()

return (
<div id="app">
<Header pathname={pathname || '/'} />
<div id="main-content">{children}</div>
<Footer />
</div>
)
}

export default wrapper.withRedux(App)
Loading

0 comments on commit be80d32

Please sign in to comment.