+
+
+
+
+
+
+
+
+
+ {navBarTitle
+ ? (
+
+ {navBarTitle}
+
+ )
+ : (
+
+ {BLOG.title} {' '}
+ {BLOG.title},{' '}
+ {BLOG.description}
+
+ )}
+
+
+
+ >
+ )
+}
+
+export default Header
diff --git a/components/LeftAside.js b/components/LeftAside.js
new file mode 100644
index 00000000..ab2d8822
--- /dev/null
+++ b/components/LeftAside.js
@@ -0,0 +1,70 @@
+import Tags from '@/components/Tags'
+import { useLocale } from '@/lib/locale'
+import Link from 'next/link'
+import BLOG from '@/blog.config'
+import { useState } from 'react'
+import Router, { useRouter } from 'next/router'
+import DarkModeButton from '@/components/DarkModeButton'
+import SocialButton from '@/components/SocialButton'
+import Footer from '@/components/Footer'
+
+const LeftAside = ({ tags, currentTag }) => {
+ const locale = useLocale()
+ const router = useRouter()
+ const [searchValue, setSearchValue] = useState('')
+
+ const handleKeyUp = (e) => {
+ if (e.keyCode === 13) {
+ Router.push({ pathname: '/', query: { s: searchValue } })
+ }
+ }
+ return
+}
+export default LeftAside
diff --git a/components/Pagination.js b/components/Pagination.js
new file mode 100644
index 00000000..78f1c91e
--- /dev/null
+++ b/components/Pagination.js
@@ -0,0 +1,44 @@
+import BLOG from '@/blog.config'
+import { useLocale } from '@/lib/locale'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+const Pagination = ({ page, showNext }) => {
+ const locale = useLocale()
+ const router = useRouter()
+ const currentPage = +page
+ return (
+
+
+
+ ← {locale.PAGINATION.PREV}
+
+
+
+
+ {locale.PAGINATION.NEXT} →
+
+
+
+ )
+}
+
+export default Pagination
diff --git a/components/Progress.js b/components/Progress.js
new file mode 100644
index 00000000..0b08a04c
--- /dev/null
+++ b/components/Progress.js
@@ -0,0 +1,35 @@
+import React, { useEffect, useState } from 'react'
+import throttle from 'lodash.throttle'
+
+/**
+ * 跳转到网页顶部;当屏幕下滑500像素后会出现该控件
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const Progress = ({ targetRef }) => {
+ const [percent, changePercent] = useState(0)
+ useEffect(() => {
+ const scrollListener = throttle(() => {
+ if (targetRef.current) {
+ const fullHeight = targetRef.current.clientHeight
+ const per = parseFloat(((window.scrollY / (fullHeight) * 100)).toFixed(0))
+ changePercent(per)
+ }
+ // console.log('滚动信息', window.scrollY, fullHeight, per)
+ }, 1)
+ document.addEventListener('scroll', scrollListener)
+ return () => document.removeEventListener('scroll', scrollListener)
+ }, [percent])
+
+ return (
+ <>
+ {/* 顶部进度条 */}
+
+ >
+ )
+}
+
+export default Progress
diff --git a/components/RewardButton.js b/components/RewardButton.js
new file mode 100644
index 00000000..5bb9091e
--- /dev/null
+++ b/components/RewardButton.js
@@ -0,0 +1,63 @@
+import React from 'react'
+import { createPopper } from '@popperjs/core'
+
+/**
+ * 赞赏模块
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const RewardButton = () => {
+ const [popoverShow, setPopoverShow] = React.useState(false)
+ const btnRef = React.createRef()
+ const popoverRef = React.createRef()
+
+ const openPopover = () => {
+ createPopper(btnRef.current, popoverRef.current, {
+ placement: 'top'
+ })
+ setPopoverShow(true)
+ }
+ const closePopover = () => {
+ setPopoverShow(false)
+ }
+ return (
+
{
+ openPopover()
+ }}
+ onMouseLeave={() => {
+ closePopover()
+ }}>
+
+
+
+
+
+
+
+
+
+
微信赞赏码或支付宝tlyong@126.com赞助
+
+
+
+
+ )
+}
+export default RewardButton
diff --git a/components/RightAside.js b/components/RightAside.js
new file mode 100644
index 00000000..475630e2
--- /dev/null
+++ b/components/RightAside.js
@@ -0,0 +1,14 @@
+import React from 'react'
+import Toc from '@/components/Toc'
+
+const RightAside = ({ toc }) => {
+ // 无目录就直接返回空
+ if (toc.length < 1) return <>>
+
+ return
+}
+export default RightAside
diff --git a/components/RightWidget.js b/components/RightWidget.js
new file mode 100644
index 00000000..60df35a5
--- /dev/null
+++ b/components/RightWidget.js
@@ -0,0 +1,13 @@
+import React from 'react'
+import TopJumper from '@/components/TopJumper'
+import ShareButton from '@/components/ShareButton'
+
+const RightWidget = ({ post }) => {
+ return
+}
+export default RightWidget
diff --git a/components/ShareBar.js b/components/ShareBar.js
new file mode 100644
index 00000000..e8a73f63
--- /dev/null
+++ b/components/ShareBar.js
@@ -0,0 +1,84 @@
+import BLOG from '@/blog.config'
+import { useRouter } from 'next/router'
+import React from 'react'
+import { createPopper } from '@popperjs/core'
+import copy from 'copy-to-clipboard'
+import QRCode from 'qrcode.react'
+
+const ShareBar = ({ post }) => {
+ const router = useRouter()
+ const shareUrl = BLOG.link + router.asPath
+
+ // 二维码悬浮
+ const [qrCodeShow, setQrCodeShow] = React.useState(false)
+ const btnRef = React.createRef()
+ const popoverRef = React.createRef()
+
+ const openPopover = () => {
+ createPopper(btnRef.current, popoverRef.current, {
+ placement: 'left'
+ })
+ setQrCodeShow(true)
+ }
+ const closePopover = () => {
+ setQrCodeShow(false)
+ }
+
+ const copyUrl = () => {
+ copy(shareUrl)
+ alert('当前链接已复制到剪贴板')
+ }
+
+ return <>
+
+
+ 分享
+
+
+
+
+
+
+
+
+
+
+ >
+}
+export default ShareBar
diff --git a/components/ShareButton.js b/components/ShareButton.js
new file mode 100644
index 00000000..7042a38f
--- /dev/null
+++ b/components/ShareButton.js
@@ -0,0 +1,42 @@
+import React from 'react'
+import ShareBar from '@/components/ShareBar'
+
+/**
+ * 悬浮在屏幕右下角,分享按钮
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const ShareButton = ({ post }) => {
+ const [popoverShow, setPopoverShow] = React.useState(false)
+ const btnRef = React.createRef()
+
+ const openPopover = () => {
+ setPopoverShow(true)
+ }
+ const closePopover = () => {
+ setPopoverShow(false)
+ }
+ return (
+
{ openPopover() }}
+ onMouseLeave={() => { closePopover() }}>
+
+
+ )
+}
+
+export default ShareButton
diff --git a/components/SocialButton.js b/components/SocialButton.js
new file mode 100644
index 00000000..d953ece1
--- /dev/null
+++ b/components/SocialButton.js
@@ -0,0 +1,28 @@
+import React from 'react'
+
+const SocialButton = () => {
+ return <>
+
+
+
+
+
+
+
+
|
+ pv
+
+
+
|
+ uv
+
+
+ >
+}
+export default SocialButton
diff --git a/components/TagItem.js b/components/TagItem.js
new file mode 100644
index 00000000..ebb84be2
--- /dev/null
+++ b/components/TagItem.js
@@ -0,0 +1,14 @@
+import Link from 'next/link'
+
+const TagItem = ({ tag }) => (
+
+
+
+ {tag}
+
+
+
+)
+
+export default TagItem
diff --git a/components/Tags.js b/components/Tags.js
new file mode 100644
index 00000000..ee62c17d
--- /dev/null
+++ b/components/Tags.js
@@ -0,0 +1,27 @@
+import Link from 'next/link'
+
+const Tags = ({ tags, currentTag }) => {
+ if (!tags) return <>>
+ return (
+
+ )
+}
+
+export default Tags
diff --git a/components/Toc.js b/components/Toc.js
new file mode 100644
index 00000000..33cf6493
--- /dev/null
+++ b/components/Toc.js
@@ -0,0 +1,86 @@
+import React from 'react'
+import throttle from 'lodash.throttle'
+import { uuidToId } from 'notion-utils'
+import { cs } from 'react-notion-x'
+
+/**
+ * 目录组件
+ */
+const Toc = ({ toc }) => {
+ // 无目录就直接返回空
+ if (toc.length < 1) return <>>
+
+ // 监听滚动事件
+ React.useEffect(() => {
+ window.addEventListener('scroll', actionSectionScrollSpy)
+ actionSectionScrollSpy()
+ return () => {
+ window.removeEventListener('scroll', actionSectionScrollSpy)
+ }
+ }, [])
+
+ // 同步选中目录事件
+ const [activeSection, setActiveSection] = React.useState(null)
+ const throttleMs = 100
+ const actionSectionScrollSpy = throttle(() => {
+ const sections = document.getElementsByClassName('notion-h')
+ let prevBBox = null
+ let currentSectionId = activeSection
+ for (let i = 0; i < sections.length; ++i) {
+ const section = sections[i]
+ if (!section || !(section instanceof Element)) continue
+ if (!currentSectionId) {
+ currentSectionId = section.getAttribute('data-id')
+ }
+ const bbox = section.getBoundingClientRect()
+ const prevHeight = prevBBox ? bbox.top - prevBBox.bottom : 0
+ const offset = Math.max(150, prevHeight / 4)
+ // GetBoundingClientRect returns values relative to viewport
+ if (bbox.top - offset < 0) {
+ currentSectionId = section.getAttribute('data-id')
+ prevBBox = bbox
+ continue
+ }
+ // No need to continue loop, if last element has been detected
+ break
+ }
+ setActiveSection(currentSectionId)
+ }, throttleMs)
+
+ return (
+ <>
+
+ 文章目录
+
+
+ {toc.map((tocItem) => {
+ const id = uuidToId(tocItem.id)
+ return (
+
+
+ {tocItem.text}
+
+
+ )
+ })}
+
+ >
+ )
+}
+
+export default Toc
diff --git a/components/TopJumper.js b/components/TopJumper.js
new file mode 100644
index 00000000..780ee998
--- /dev/null
+++ b/components/TopJumper.js
@@ -0,0 +1,39 @@
+import React, { useEffect, useState } from 'react'
+import throttle from 'lodash.throttle'
+import { useLocale } from '@/lib/locale'
+
+/**
+ * 跳转到网页顶部;当屏幕下滑500像素后会出现该控件
+ * @returns {JSX.Element}
+ * @constructor
+ */
+const TopJumper = () => {
+ const locale = useLocale()
+
+ const [show, switchShow] = useState(false)
+ useEffect(() => {
+ const scrollListener = throttle(() => {
+ // 处理是否显示回到顶部按钮
+ const shouldShow = window.scrollY > 100
+ if (shouldShow !== show) {
+ switchShow(shouldShow)
+ }
+ }, 500)
+ document.addEventListener('scroll', scrollListener)
+ return () => document.removeEventListener('scroll', scrollListener)
+ }, [show])
+
+ return (
+
+
window.scrollTo({ top: 0, behavior: 'smooth' })}>
+
+
+
+
+ )
+}
+
+export default TopJumper
diff --git a/components/TopNav.js b/components/TopNav.js
new file mode 100644
index 00000000..be58f35f
--- /dev/null
+++ b/components/TopNav.js
@@ -0,0 +1,88 @@
+import Link from 'next/link'
+import BLOG from '@/blog.config'
+import { useEffect, useState } from 'react'
+import { useLocale } from '@/lib/locale'
+import Router, { useRouter } from 'next/router'
+import Tags from '@/components/Tags'
+import localStorage from 'localStorage'
+import { useTheme } from '@/lib/theme'
+import DarkModeButton from '@/components/DarkModeButton'
+import SocialButton from '@/components/SocialButton'
+
+const TopNav = ({ tags, currentTag }) => {
+ const locale = useLocale()
+ const [hiddenMenu, switchHiddenMenu] = useState(!currentTag)
+ // 点击按钮更改菜单状态
+ const handleMenuClick = () => {
+ switchHiddenMenu(!hiddenMenu)
+ }
+ const router = useRouter()
+ const [searchValue, setSearchValue] = useState('')
+ const handleKeyUp = (e) => {
+ if (e.keyCode === 13) {
+ Router.push({ pathname: '/', query: { s: searchValue } })
+ }
+ }
+
+ return (
+
+ {/* 隐藏的顶部菜单 */}
+
+
+ {/* 导航栏 */}
+
+
+
+
+ {/* 搜索框 */}
+
+
+ setSearchValue(e.target.value)}
+ defaultValue={router.query.s ?? ''}
+ />
+
+
+
+
+
+
+
{hiddenMenu ? '' : ''}
+
+
+
+
+
+ )
+}
+
+export default TopNav
diff --git a/components/Utterances.js b/components/Utterances.js
new file mode 100644
index 00000000..bf1d66de
--- /dev/null
+++ b/components/Utterances.js
@@ -0,0 +1,36 @@
+import BLOG from '@/blog.config'
+import { useEffect } from 'react'
+const Utterances = ({ issueTerm, layout }) => {
+ useEffect(() => {
+ const theme =
+ BLOG.appearance === 'auto'
+ ? 'preferred-color-scheme'
+ : BLOG.appearance === 'light'
+ ? 'github-light'
+ : 'github-dark'
+ const script = document.createElement('script')
+ const anchor = document.getElementById('comments')
+ script.setAttribute('src', 'https://utteranc.es/client.js')
+ script.setAttribute('crossorigin', 'anonymous')
+ script.setAttribute('async', true)
+ script.setAttribute('repo', BLOG.comment.utterancesConfig.repo)
+ script.setAttribute('issue-term', issueTerm)
+ script.setAttribute('theme', theme)
+ anchor.appendChild(script)
+ return () => {
+ anchor.innerHTML = ''
+ }
+ })
+ return (
+ <>
+
+ >
+ )
+}
+
+export default Utterances
diff --git a/components/Vercel.js b/components/Vercel.js
new file mode 100644
index 00000000..4bc15531
--- /dev/null
+++ b/components/Vercel.js
@@ -0,0 +1,41 @@
+const Vercel = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default Vercel
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 00000000..54e65f4b
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./*"],
+ "@/components/*": ["components/*"],
+ "@/data/*": ["data/*"],
+ "@/layouts/*": ["layouts/*"],
+ "@/lib/*": ["lib/*"],
+ "@/styles/*": ["styles/*"]
+ }
+ }
+}
diff --git a/layouts/ArticleLayout.js b/layouts/ArticleLayout.js
new file mode 100644
index 00000000..9992bf2d
--- /dev/null
+++ b/layouts/ArticleLayout.js
@@ -0,0 +1,180 @@
+import TagItem from '@/components/TagItem'
+import { NotionRenderer, Equation, Code, CollectionRow, Collection } from 'react-notion-x'
+import BLOG from '@/blog.config'
+import formatDate from '@/lib/formatDate'
+import 'gitalk/dist/gitalk.css'
+import Comment from '@/components/Comment'
+import CommonHead from '@/components/CommonHead'
+import TopNav from '@/components/TopNav'
+import Progress from '@/components/Progress'
+import { useRef } from 'react'
+import Image from 'next/image'
+import Footer from '@/components/Footer'
+import RightAside from '@/components/RightAside'
+import RewardButton from '@/components/RewardButton'
+import RightWidget from '@/components/RightWidget'
+import { useTheme } from '@/lib/theme'
+import LeftAside from '@/components/LeftAside'
+import BlogPostMini from '@/components/BlogPostMini'
+
+const mapPageUrl = id => {
+ return 'https://www.notion.so/' + id.replace(/-/g, '')
+}
+
+const ArticleLayout = ({
+ children,
+ blockMap,
+ frontMatter,
+ emailHash,
+ fullWidth = true,
+ tags,
+ prev,
+ next
+}) => {
+ const meta = {
+ title: frontMatter.title,
+ type: 'article'
+ }
+ const targetRef = useRef(null)
+ const { theme } = useTheme()
+ return (
+
+
+ {/* live2d 看板娘 */}
+
+
+
+
+
+
+
+
+
+
+ {/* 主体区块 */}
+
+ {/* 卡牌水平边距wrapper */}
+ {/* 文章卡牌 */}
+
+
+
+ {/* 封面图 */}
+ {frontMatter.page_cover && frontMatter.page_cover.length > 1 && (
+
+ )}
+
+
+
+ {/* 文章标题 */}
+
+ {frontMatter.title}
+
+
+ {/* 文章信息 */}
+
+
+
+ {frontMatter.tags && (
+
+ {frontMatter.tags.map(tag => (
+
+ ))}
+
+ )}
+
+ {frontMatter.slug !== 'about' && (<>
+
+
+ {BLOG.author}
+
+ >)}
+
+ {frontMatter.type[0] !== 'Page' && (
+
+
+ {formatDate(
+ frontMatter?.date?.start_date || frontMatter.createdTime,
+ BLOG.lang
+ )}
+
+
+ )}
+
+
+ {/* 不蒜子 */}
+
+
+
+ {children}
+
+ {/* Notion文章主体 */}
+ {blockMap && (
+
+
+
+ )}
+
+
+
+
+
+
+
继续阅读
+
+ {/*
*/}
+ {/*
*/}
+ {/* */}
+
+
+ {/*
*/}
+ {/*
*/}
+ {/* */}
+
+
+
+ {/* 分享 */}
+ {/* */}
+ {/* */}
+
+ {/* 评论互动 */}
+
+
+
+
+
+
+ {/* */}
+ {/* */}
+
+
+
+ {/* 右侧内容 */}
+
+
+
+
+
+ )
+}
+
+export default ArticleLayout
diff --git a/layouts/DefaultLayout.js b/layouts/DefaultLayout.js
new file mode 100644
index 00000000..5a4dd72d
--- /dev/null
+++ b/layouts/DefaultLayout.js
@@ -0,0 +1,119 @@
+import BlogPost from '@/components/BlogPost'
+import PropTypes from 'prop-types'
+import Pagination from '@/components/Pagination'
+import BLOG from '@/blog.config'
+import CommonHead from '@/components/CommonHead'
+import { useRouter } from 'next/router'
+import TopNav from '@/components/TopNav'
+import Footer from '@/components/Footer'
+import localStorage from 'localStorage'
+import { useTheme } from '@/lib/theme'
+import { useEffect } from 'react'
+import LeftAside from '@/components/LeftAside'
+
+const DefaultLayout = ({ tags, posts, page, currentTag, ...customMeta }) => {
+ const meta = {
+ title: BLOG.title,
+ type: 'website',
+ ...customMeta
+ }
+ page = page ?? 1
+ let postsToShow = []
+ let filteredBlogPosts = posts ?? []
+ let currentSearch = ''
+ if (posts) {
+ const router = useRouter()
+ if (router.query && router.query.s) {
+ currentSearch = router.query.s
+ filteredBlogPosts = posts.filter(post => {
+ const tagContent = post.tags ? post.tags.join(' ') : ''
+ const searchContent = post.title + post.summary + tagContent + post.slug
+ return searchContent.toLowerCase().includes(currentSearch.toLowerCase())
+ })
+ }
+ }
+ const totalPages = Math.ceil(filteredBlogPosts.length / BLOG.postsPerPage)
+
+ if (posts) {
+ postsToShow = filteredBlogPosts.slice(
+ BLOG.postsPerPage * (page - 1),
+ BLOG.postsPerPage * page
+ )
+ }
+ let showNext = false
+ if (filteredBlogPosts) {
+ const totalPosts = filteredBlogPosts.length
+ showNext = page * BLOG.postsPerPage < totalPosts
+ }
+
+ // 首页隐藏看板娘
+ useEffect(() => {
+ const ref = document.getElementById('waifu')
+ if (ref) {
+ ref.remove()
+ }
+ })
+
+ const { theme } = useTheme()
+
+ return (
+
+
+
+
+
+
+
+
+ {(!page || page === 1) && (
+
+ )
+ }
+ {/* 标签 */}
+ {currentTag && (
+
+ )}
+ {/* 当前搜索 */}
+ {(currentSearch || (page && page !== 1)) && (
+
+
+ {currentSearch && (
+ 搜索关键词: {currentSearch}
+ )}
+ {page && page !== 1 && (
+ 页 {page} / {totalPages}
+ )}
+
+
+
+ )}
+
+ {/* 文章列表 */}
+
+
+ {!postsToShow.length && (
+
No posts found.
+ )}
+ {postsToShow.map(post => (
+
+ ))}
+
+
+
+
+
+
+
+ {/*
*/}
+
+ )
+}
+DefaultLayout.propTypes = {
+ posts: PropTypes.array.isRequired,
+ tags: PropTypes.object.isRequired,
+ currentTag: PropTypes.string
+}
+export default DefaultLayout
diff --git a/lib/cache/cache_manager.js b/lib/cache/cache_manager.js
new file mode 100644
index 00000000..25d76ba0
--- /dev/null
+++ b/lib/cache/cache_manager.js
@@ -0,0 +1,21 @@
+import { getCacheFromFile, setCacheToFile } from '@/lib/cache/local_file_cache'
+import { getCacheFromMemory, setCacheToMemory } from '@/lib/cache/memory_cache'
+import BLOG from '@/blog.config'
+
+export async function getDataFromCache (key) {
+ let dataFromCache
+ if (BLOG.isProd) {
+ dataFromCache = await getCacheFromMemory(key)
+ } else {
+ dataFromCache = await getCacheFromFile(key)
+ }
+ return dataFromCache
+}
+
+export async function setDataToCache (key, data) {
+ if (BLOG.isProd) {
+ await setCacheToMemory(key, data)
+ } else {
+ await setCacheToFile(key, data)
+ }
+}
diff --git a/lib/cache/local_file_cache.js b/lib/cache/local_file_cache.js
new file mode 100644
index 00000000..386eb28d
--- /dev/null
+++ b/lib/cache/local_file_cache.js
@@ -0,0 +1,36 @@
+import fs from 'fs'
+import BLOG from '@/blog.config'
+
+const path = require('path')
+// 文件缓存持续10秒
+const cacheInvalidSeconds = 1000000000 * 1000
+// 文件名
+const jsonFile = path.resolve('./data.json')
+
+export async function getCacheFromFile (key) {
+ const exist = await fs.existsSync(jsonFile)
+ if (!exist) return null
+ const data = await fs.readFileSync(jsonFile)
+ const json = data ? JSON.parse(data) : {}
+ // 缓存超过有效期就作废
+ const cacheValidTime = new Date(parseInt(json[key + '_expire_time']) + cacheInvalidSeconds)
+ const currentTime = new Date()
+ if (!cacheValidTime || cacheValidTime < currentTime) {
+ return null
+ }
+ return json[key]
+}
+
+/**
+ * 并发请求写文件异常; Vercel生产环境不支持写文件。
+ * @param key
+ * @param data
+ * @returns {Promise
}
+ */
+export async function setCacheToFile (key, data) {
+ const exist = await fs.existsSync(jsonFile)
+ const json = exist ? JSON.parse(await fs.readFileSync(jsonFile)) : {}
+ json[key] = data
+ json[key + '_expire_time'] = new Date().getTime()
+ fs.writeFileSync(jsonFile, JSON.stringify(json))
+}
diff --git a/lib/cache/memory_cache.js b/lib/cache/memory_cache.js
new file mode 100644
index 00000000..cc3e8bac
--- /dev/null
+++ b/lib/cache/memory_cache.js
@@ -0,0 +1,9 @@
+import cache from 'memory-cache'
+
+export async function getCacheFromMemory (key, options) { // url为缓存标识
+ return cache.get(key)
+}
+
+export async function setCacheToMemory (key, data) { // url为缓存标识
+ await cache.put(key, data, 60 * 1000)
+}
diff --git a/lib/cjk.js b/lib/cjk.js
new file mode 100644
index 00000000..b024d72b
--- /dev/null
+++ b/lib/cjk.js
@@ -0,0 +1,21 @@
+const BLOG = require('../blog.config')
+
+module.exports = function () {
+ switch (BLOG.lang.toLowerCase()) {
+ case 'zh-cn':
+ case 'zh-sg':
+ return 'SC'
+ case 'zh':
+ case 'zh-hk':
+ case 'zh-tw':
+ return 'TC'
+ case 'ja':
+ case 'ja-jp':
+ return 'JP'
+ case 'ko':
+ case 'ko-kr':
+ return 'KR'
+ default:
+ return null
+ }
+}
diff --git a/lib/formatDate.js b/lib/formatDate.js
new file mode 100644
index 00000000..38e1f542
--- /dev/null
+++ b/lib/formatDate.js
@@ -0,0 +1,8 @@
+export default function formatDate(date, local) {
+ const d = new Date(date)
+ const options = { year: 'numeric', month: 'short', day: 'numeric' }
+ const res = d.toLocaleDateString(local, options)
+ return local.slice(0, 2).toLowerCase() === 'zh'
+ ? res.replace('年', ' 年 ').replace('月', ' 月 ').replace('日', ' 日')
+ : res
+}
diff --git a/lib/gtag.js b/lib/gtag.js
new file mode 100644
index 00000000..a526fb7d
--- /dev/null
+++ b/lib/gtag.js
@@ -0,0 +1,18 @@
+import BLOG from '@/blog.config'
+export const GA_TRACKING_ID = BLOG.analytics.gaConfig.measurementId
+
+// https://developers.google.com/analytics/devguides/collection/gtagjs/pages
+export const pageview = url => {
+ window.gtag('config', GA_TRACKING_ID, {
+ page_path: url
+ })
+}
+
+// https://developers.google.com/analytics/devguides/collection/gtagjs/events
+export const event = ({ action, category, label, value }) => {
+ window.gtag('event', action, {
+ event_category: category,
+ event_label: label,
+ value: value
+ })
+}
diff --git a/lib/lang.js b/lib/lang.js
new file mode 100644
index 00000000..f95f01bd
--- /dev/null
+++ b/lib/lang.js
@@ -0,0 +1,83 @@
+const lang = {
+ en: {
+ NAV: {
+ INDEX: 'Blog',
+ RSS: 'RSS',
+ SEARCH: 'Search',
+ ABOUT: 'About',
+ NAVGATION: 'NAVGATION'
+ },
+ PAGINATION: {
+ PREV: 'Prev',
+ NEXT: 'Next'
+ },
+ SEARCH: {
+ ARTICLES: 'Search Articles',
+ TAGS: 'Search in'
+ },
+ POST: {
+ BACK: 'Back',
+ TOP: 'Top'
+ }
+ },
+ 'zh-CN': {
+ NAV: {
+ INDEX: '首页',
+ RSS: '订阅',
+ SEARCH: '搜索',
+ ABOUT: '关于',
+ NAVGATION: '导航'
+ },
+ PAGINATION: {
+ PREV: '上一页',
+ NEXT: '下一页'
+ },
+ SEARCH: {
+ ARTICLES: '搜索文章',
+ TAGS: '搜索标签'
+ },
+ POST: {
+ BACK: '返回上页',
+ TOP: '回到顶部'
+ }
+ },
+ 'zh-HK': {
+ NAV: {
+ INDEX: '網誌',
+ RSS: '訂閱',
+ SEARCH: '搜尋',
+ ABOUT: '關於',
+ NAVGATION: '導航'
+ },
+ PAGINATION: {
+ PREV: '上一頁',
+ NEXT: '下一頁'
+ },
+ SEARCH: {
+ ARTICLES: '搜尋文章',
+ TAGS: '搜尋標簽'
+ },
+ POST: {
+ BACK: '返回',
+ TOP: '回到頂端'
+ }
+ },
+ 'zh-TW': {
+ NAV: {
+ INDEX: '部落格',
+ RSS: '訂閱',
+ SEARCH: '搜尋',
+ ABOUT: '關於',
+ NAVGATION: '導航'
+ },
+ PAGINATION: {
+ PREV: '上一頁',
+ NEXT: '下一頁'
+ },
+ POST: {
+ BACK: '返回',
+ TOP: '回到頂端'
+ }
+ }
+}
+export default lang
diff --git a/lib/locale.js b/lib/locale.js
new file mode 100644
index 00000000..860f43cb
--- /dev/null
+++ b/lib/locale.js
@@ -0,0 +1,34 @@
+import BLOG from '@/blog.config'
+import lang from './lang'
+import { useContext, createContext } from 'react'
+
+let locale = {}
+if (BLOG.lang.slice(0, 2).toLowerCase() === 'zh') {
+ switch (BLOG.lang.toLowerCase()) {
+ case 'zh-cn':
+ case 'zh-sg':
+ locale = lang['zh-CN']
+ break
+ case 'zh-hk':
+ locale = lang['zh-HK']
+ break
+ case 'zh-tw':
+ locale = lang['zh-TW']
+ break
+ default:
+ locale = lang['zh-TW']
+ break
+ }
+} else {
+ locale = lang.en
+}
+
+const LocaleContext = createContext()
+
+export function LocaleProvider({ children }) {
+ return (
+ {children}
+ )
+}
+
+export const useLocale = () => useContext(LocaleContext)
diff --git a/lib/notion.js b/lib/notion.js
new file mode 100644
index 00000000..51c02180
--- /dev/null
+++ b/lib/notion.js
@@ -0,0 +1,3 @@
+export { getAllPosts } from './notion/getAllPosts'
+export { getAllTags } from './notion/getAllTags'
+export { getPostBlocks } from './notion/getPostBlocks'
diff --git a/lib/notion/getAllPageIds.js b/lib/notion/getAllPageIds.js
new file mode 100644
index 00000000..7673889c
--- /dev/null
+++ b/lib/notion/getAllPageIds.js
@@ -0,0 +1,20 @@
+import { idToUuid } from 'notion-utils'
+
+export default function getAllPageIds (collectionQuery, viewId) {
+ const views = Object.values(collectionQuery)[0]
+ if (!views) {
+ return []
+ }
+ let pageIds = []
+ if (viewId) {
+ const vId = idToUuid(viewId)
+ pageIds = views[vId]?.blockIds
+ } else {
+ const pageSet = new Set()
+ Object.values(views).forEach(view => {
+ view?.blockIds?.forEach(id => pageSet.add(id))
+ })
+ pageIds = [...pageSet]
+ }
+ return pageIds
+}
diff --git a/lib/notion/getAllPosts.js b/lib/notion/getAllPosts.js
new file mode 100644
index 00000000..ae9c0913
--- /dev/null
+++ b/lib/notion/getAllPosts.js
@@ -0,0 +1,101 @@
+import BLOG from '@/blog.config'
+import { idToUuid } from 'notion-utils'
+import getAllPageIds from './getAllPageIds'
+import getPageProperties from './getPageProperties'
+import { defaultMapImageUrl } from 'react-notion-x'
+import { getDataFromCache, setDataToCache } from '@/lib/cache/cache_manager'
+import { getPostBlocks } from '@/lib/notion/getPostBlocks'
+
+export async function getAllPosts () {
+ const data = await getDataFromCache('posts_list')
+ if (data) {
+ return data
+ }
+ let id = BLOG.notionPageId
+ const pageRecordMap = await getPostBlocks(id)
+ if (!pageRecordMap) {
+ return <>获取数据异常>
+ }
+
+ id = idToUuid(id)
+ const collection = Object.values(pageRecordMap.collection)[0]?.value
+ const collectionQuery = pageRecordMap.collection_query
+ const block = pageRecordMap.block
+ const schema = collection?.schema
+
+ const rawMetadata = block[id].value
+
+ // Check Type 兼容Page-Database和Inline-Database
+ if (rawMetadata?.type !== 'collection_view_page' && rawMetadata?.type !== 'collection_view') {
+ console.warn(`pageId "${id}" is not a database`)
+ return null
+ } else {
+ // Construct Data
+ const pageIds = getAllPageIds(collectionQuery)
+ const data = []
+ for (let i = 0; i < pageIds.length; i++) {
+ const id = pageIds[i]
+ const properties = (await getPageProperties(id, block, schema)) || null
+
+ // Add fullwidth, createdtime to properties
+ properties.createdTime = new Date(
+ block[id].value?.created_time
+ ).toString()
+ properties.fullWidth = block[id].value?.format?.page_full_width ?? false
+ properties.page_cover = getPostCover(id, block, pageRecordMap) ?? getContentFirstImage(id, block, pageRecordMap)
+ properties.content = block[id].value?.content ?? []
+ data.push(properties)
+ }
+ // remove all the the items doesn't meet requirements
+ const posts = data.filter(post => {
+ return (
+ post.title &&
+ post.slug &&
+ post?.status?.[0] === 'Published' &&
+ (post?.type?.[0] === 'Post' || post?.type?.[0] === 'Page')
+ )
+ })
+
+ // Sort by date
+ if (BLOG.sortByDate) {
+ posts.sort((a, b) => {
+ const dateA = new Date(a?.date?.start_date || a.createdTime)
+ const dateB = new Date(b?.date?.start_date || b.createdTime)
+ return dateB - dateA
+ })
+ }
+ if (posts) {
+ await setDataToCache('posts_list', posts)
+ }
+ return posts
+ }
+}
+
+// 从Block获取封面图;优先取PageCover,否则取内容图片
+function getPostCover (id, block, pageRecordMap) {
+ const pageCover = block[id].value?.format?.page_cover
+ if (pageCover) {
+ if (pageCover.startsWith('/')) return 'https://www.notion.so' + pageCover
+ if (pageCover.startsWith('http')) return defaultMapImageUrl(pageCover, block[id].value)
+ }
+}
+
+// 取文章的第一个图片内容作为封面
+function getContentFirstImage (id, block, pageRecordMap) {
+ const pageBlock = block[id]?.value
+
+ const contentBlockId = pageBlock?.content?.find((blockId) => {
+ const block = pageRecordMap.block[blockId]?.value
+ if (block?.type === 'image') {
+ return true
+ }
+ })
+
+ if (contentBlockId) {
+ const contentBlock = pageRecordMap.block[contentBlockId]?.value
+ const source = contentBlock.properties?.source?.[0]?.[0] ??
+ contentBlock.format?.display_source
+ return defaultMapImageUrl(source, contentBlock)
+ }
+ return ''
+}
diff --git a/lib/notion/getAllTags.js b/lib/notion/getAllTags.js
new file mode 100644
index 00000000..79704dba
--- /dev/null
+++ b/lib/notion/getAllTags.js
@@ -0,0 +1,23 @@
+import { getAllPosts } from './getAllPosts'
+
+export async function getAllTags (posts) {
+ if (!posts) {
+ const response = await getAllPosts()
+ posts = response.filter(
+ post =>
+ post.status[0] === 'Published' && post.type[0] === 'Post' && post.tags
+ )
+ }
+
+ let tags = posts.map(p => p.tags)
+ tags = [...tags.flat()]
+ const tagObj = {}
+ tags.forEach(tag => {
+ if (tag in tagObj) {
+ tagObj[tag]++
+ } else {
+ tagObj[tag] = 1
+ }
+ })
+ return tagObj
+}
diff --git a/lib/notion/getMetadata.js b/lib/notion/getMetadata.js
new file mode 100644
index 00000000..78bc568c
--- /dev/null
+++ b/lib/notion/getMetadata.js
@@ -0,0 +1,11 @@
+export default function getMetadata(rawMetadata) {
+ const metadata = {
+ locked: rawMetadata?.format?.block_locked,
+ page_full_width: rawMetadata?.format?.page_full_width,
+ page_font: rawMetadata?.format?.page_font,
+ page_small_text: rawMetadata?.format?.page_small_text,
+ created_time: rawMetadata.created_time,
+ last_edited_time: rawMetadata.last_edited_time
+ }
+ return metadata
+}
diff --git a/lib/notion/getPageProperties.js b/lib/notion/getPageProperties.js
new file mode 100644
index 00000000..56ded539
--- /dev/null
+++ b/lib/notion/getPageProperties.js
@@ -0,0 +1,60 @@
+import { getTextContent, getDateValue } from 'notion-utils'
+import { NotionAPI } from 'notion-client'
+
+async function getPageProperties (id, block, schema, authToken) {
+ const rawProperties = Object.entries(block?.[id]?.value?.properties || [])
+ const excludeProperties = ['date', 'select', 'multi_select', 'person']
+ const properties = {}
+ for (let i = 0; i < rawProperties.length; i++) {
+ const [key, val] = rawProperties[i]
+ properties.id = id
+ if (schema[key]?.type && !excludeProperties.includes(schema[key].type)) {
+ properties[schema[key].name] = getTextContent(val)
+ } else {
+ switch (schema[key]?.type) {
+ case 'date': {
+ const dateProperty = getDateValue(val)
+ delete dateProperty.type
+ properties[schema[key].name] = dateProperty
+ break
+ }
+ case 'select':
+ case 'multi_select': {
+ const selects = getTextContent(val)
+ if (selects[0]?.length) {
+ properties[schema[key].name] = selects.split(',')
+ }
+ break
+ }
+ case 'person': {
+ const rawUsers = val.flat()
+ const users = []
+ const api = new NotionAPI({ authToken })
+
+ for (let i = 0; i < rawUsers.length; i++) {
+ if (rawUsers[i][0][1]) {
+ const userId = rawUsers[i][0]
+ const res = await api.getUsers(userId)
+ const resValue =
+ res?.recordMapWithRoles?.notion_user?.[userId[1]]?.value
+ const user = {
+ id: resValue?.id,
+ first_name: resValue?.given_name,
+ last_name: resValue?.family_name,
+ profile_photo: resValue?.profile_photo
+ }
+ users.push(user)
+ }
+ }
+ properties[schema[key].name] = users
+ break
+ }
+ default:
+ break
+ }
+ }
+ }
+ return properties
+}
+
+export { getPageProperties as default }
diff --git a/lib/notion/getPageTableOfContents.js b/lib/notion/getPageTableOfContents.js
new file mode 100644
index 00000000..8538627a
--- /dev/null
+++ b/lib/notion/getPageTableOfContents.js
@@ -0,0 +1,69 @@
+
+const indentLevels = {
+ header: 0,
+ sub_header: 1,
+ sub_sub_header: 2
+}
+
+
+export const getPageTableOfContents = (page,recordMap)=> {
+ // 获取 header sub_header sub_sub_header
+ const toc = (page.content ?? [])
+ .map((blockId) => {
+ const block = recordMap.block[blockId]?.value
+
+ if (block) {
+ const { type } = block
+
+ if (
+ type === 'header' ||
+ type === 'sub_header' ||
+ type === 'sub_sub_header'
+ ) {
+ return {
+ id: blockId,
+ type,
+ indentLevel: indentLevels[type]
+ }
+ }
+ }
+
+ return null
+ })
+ .filter(Boolean)
+
+ const indentLevelStack = [
+ {
+ actual: -1,
+ effective: -1
+ }
+ ]
+
+ // Adjust indent levels to always change smoothly.
+ // This is a little tricky, but the key is that when increasing indent levels,
+ // they should never jump more than one at a time.
+ for (const tocItem of toc) {
+ const { indentLevel } = tocItem
+ const actual = indentLevel
+
+ do {
+ const prevIndent = indentLevelStack[indentLevelStack.length - 1]
+ const { actual: prevActual, effective: prevEffective } = prevIndent
+
+ if (actual > prevActual) {
+ tocItem.indentLevel = prevEffective + 1
+ indentLevelStack.push({
+ actual,
+ effective: tocItem.indentLevel
+ })
+ } else if (actual === prevActual) {
+ tocItem.indentLevel = prevEffective
+ break
+ } else {
+ indentLevelStack.pop()
+ }
+ } while (true)
+ }
+
+ return toc
+}
\ No newline at end of file
diff --git a/lib/notion/getPostBlocks.js b/lib/notion/getPostBlocks.js
new file mode 100644
index 00000000..3f402d54
--- /dev/null
+++ b/lib/notion/getPostBlocks.js
@@ -0,0 +1,17 @@
+import BLOG from '@/blog.config'
+import { NotionAPI } from 'notion-client'
+import { getDataFromCache, setDataToCache } from '@/lib/cache/cache_manager'
+
+export async function getPostBlocks (id) {
+ let pageBlock = await getDataFromCache('page_block_' + id)
+ if (pageBlock) {
+ return pageBlock
+ }
+ const authToken = BLOG.notionAccessToken || null
+ const api = new NotionAPI({ authToken })
+ pageBlock = await api.getPage(id)
+ if (pageBlock) {
+ await setDataToCache('page_block_' + id, pageBlock)
+ }
+ return pageBlock
+}
diff --git a/lib/rss.js b/lib/rss.js
new file mode 100644
index 00000000..4766cd4b
--- /dev/null
+++ b/lib/rss.js
@@ -0,0 +1,30 @@
+import { Feed } from 'feed'
+import BLOG from '@/blog.config'
+
+export function generateRss(posts) {
+ const year = new Date().getFullYear()
+ const feed = new Feed({
+ title: BLOG.title,
+ description: BLOG.description,
+ id: `${BLOG.link}/${BLOG.path}`,
+ link: `${BLOG.link}/${BLOG.path}`,
+ language: BLOG.lang,
+ favicon: `${BLOG.link}/favicon.png`,
+ copyright: `All rights reserved ${year}, ${BLOG.author}`,
+ author: {
+ name: BLOG.author,
+ email: BLOG.email,
+ link: BLOG.link
+ }
+ })
+ posts.forEach(post => {
+ feed.addItem({
+ title: post.title,
+ id: `${BLOG.link}/${post.slug}`,
+ link: `${BLOG.link}/${post.slug}`,
+ description: post.summary,
+ date: new Date(post?.date?.start_date || post.createdTime)
+ })
+ })
+ return feed.rss2()
+}
diff --git a/lib/theme.js b/lib/theme.js
new file mode 100644
index 00000000..64bd2f4a
--- /dev/null
+++ b/lib/theme.js
@@ -0,0 +1,19 @@
+import { useContext, createContext, useState, useEffect } from 'react'
+import localStorage from 'localStorage'
+
+const ThemeContext = createContext()
+
+export function ThemeProvider ({ children }) {
+ // 初始值
+ const defaultTheme = localStorage.getItem('theme')
+ const [theme, changeTheme] = useState()
+ useEffect(() => {
+ changeTheme(defaultTheme)
+ })
+
+ return (
+ {children}
+ )
+}
+
+export const useTheme = () => useContext(ThemeContext)
diff --git a/next-sitemap.config.js b/next-sitemap.config.js
new file mode 100644
index 00000000..eb913eb9
--- /dev/null
+++ b/next-sitemap.config.js
@@ -0,0 +1,11 @@
+const BLOG = require('./blog.config')
+
+module.exports = {
+ siteUrl: BLOG.link,
+ changefreq: 'daily',
+ priority: 0.7,
+ generateRobotsTxt: true,
+ sitemapSize: 7000
+ // ...other options
+ // https://github.com/iamvishnusankar/next-sitemap#configuration-options
+}
diff --git a/next.config.js b/next.config.js
new file mode 100644
index 00000000..bc476a75
--- /dev/null
+++ b/next.config.js
@@ -0,0 +1,32 @@
+module.exports = {
+ future: {
+ webpack5: true
+ },
+ images: {
+ domains: ['gravatar.com']
+ },
+ async headers() {
+ return [
+ {
+ source: '/:path*{/}?',
+ headers: [
+ {
+ key: 'Permissions-Policy',
+ value: 'interest-cohort=()'
+ }
+ ]
+ }
+ ]
+ },
+ webpack: (config, { dev, isServer }) => {
+ // Replace React with Preact only in client production build
+ if (!dev && !isServer) {
+ Object.assign(config.resolve.alias, {
+ react: 'preact/compat',
+ 'react-dom/test-utils': 'preact/test-utils',
+ 'react-dom': 'preact/compat'
+ })
+ }
+ return config
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..3f7460c9
--- /dev/null
+++ b/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "notion-next",
+ "version": "1.0.0",
+ "homepage": "https://github.com/tangly1024/NotionNext.git",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/tangly1024/NotionNext.git"
+ },
+ "author": {
+ "name": "tangly",
+ "email": "tlyong1992@hotmail.com",
+ "url": "http://tangly1024.com"
+ },
+ "scripts": {
+ "dev": "NODE_OPTIONS='--inspect' next dev",
+ "build": "next build",
+ "start": "next start",
+ "postbuild": "next-sitemap --config next-sitemap.config.js"
+ },
+ "dependencies": {
+ "@popperjs/core": "^2.9.3",
+ "animate.css": "^4.1.1",
+ "copy-to-clipboard": "^3.3.1",
+ "feed": "^4.2.2",
+ "font-awesome": "^4.7.0",
+ "gitalk": "^1.7.2",
+ "localStorage": "^1.0.4",
+ "lodash.throttle": "^4.1.1",
+ "memory-cache": "^0.2.0",
+ "next": "10.2.0",
+ "notion-client": "4.8.6",
+ "notion-utils": "4.8.6",
+ "preact": "^10.5.13",
+ "qrcode.react": "^1.0.1",
+ "react": "17.0.2",
+ "react-cusdis": "^2.0.1",
+ "react-dom": "17.0.2",
+ "react-notion-x": "^4.6.5",
+ "use-ackee": "^3.0.0"
+ },
+ "devDependencies": {
+ "autoprefixer": "^10.2.5",
+ "eslint": "^7.26.0",
+ "eslint-config-next": "^11.0.0",
+ "eslint-config-standard": "^16.0.2",
+ "eslint-plugin-import": "^2.23.0",
+ "eslint-plugin-node": "^11.1.0",
+ "eslint-plugin-promise": "^5.1.0",
+ "eslint-plugin-react": "^7.23.2",
+ "next-sitemap": "^1.6.102",
+ "postcss": "^8.2.15",
+ "tailwindcss": "^2.1.2"
+ },
+ "resolutions": {
+ "axios": ">=0.21.1"
+ },
+ "bugs": {
+ "url": "https://github.com/tangly/NotionNext/issues",
+ "email": "tlyong1992@hotmail.com"
+ }
+}
diff --git a/pageid.png b/pageid.png
new file mode 100644
index 00000000..1bb83972
Binary files /dev/null and b/pageid.png differ
diff --git a/pages/404.js b/pages/404.js
new file mode 100644
index 00000000..f0099a99
--- /dev/null
+++ b/pages/404.js
@@ -0,0 +1,31 @@
+/**
+ * 自定义404界面
+ * @returns {JSX.Element}
+ * @constructor
+ */
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
+
+export default function Custom404 () {
+ const route = useRouter()
+ if (route.asPath.indexOf('/article') < 0 && route.asPath.indexOf('/404') < 0) {
+ // article 重定向,处理旧文章链接迁移。
+ const redirectUrl = '/article' + route.asPath
+ route.push(redirectUrl)
+ } else {
+ useEffect(() => {
+ setTimeout(() => {
+ window.location.href = '/'
+ }, 3000)
+ })
+ }
+
+ return
+
+
404
+
+
页面丢失了,3秒后返回首页
+
+
+}
diff --git a/pages/_app.js b/pages/_app.js
new file mode 100644
index 00000000..9c235ba4
--- /dev/null
+++ b/pages/_app.js
@@ -0,0 +1,37 @@
+import '@/styles/notion.css'
+import 'rc-dropdown/assets/index.css'
+import 'katex/dist/katex.min.css'
+import '@/styles/globals.css'
+import 'prismjs'
+import 'prismjs/themes/prism-okaidia.css'
+import 'prismjs/components/prism-markup'
+import 'prismjs/components/prism-python'
+import 'prismjs/components/prism-bash'
+import 'animate.css'
+import 'font-awesome/css/font-awesome.min.css'
+import BLOG from '@/blog.config'
+import dynamic from 'next/dynamic'
+import { LocaleProvider } from '@/lib/locale'
+import { ThemeProvider } from '@/lib/theme'
+
+const Ackee = dynamic(() => import('@/components/Ackee'), { ssr: false })
+const Gtag = dynamic(() => import('@/components/Gtag'), { ssr: false })
+
+function MyApp ({ Component, pageProps }) {
+ return (
+
+
+ {BLOG.isProd && BLOG?.analytics?.provider === 'ackee' && (
+
+ )}
+ {BLOG.isProd && BLOG?.analytics?.provider === 'ga' && }
+
+
+
+ )
+}
+
+export default MyApp
diff --git a/pages/_document.js b/pages/_document.js
new file mode 100644
index 00000000..89486811
--- /dev/null
+++ b/pages/_document.js
@@ -0,0 +1,119 @@
+import Document, { Html, Head, Main, NextScript } from 'next/document'
+import BLOG from '@/blog.config'
+
+class MyDocument extends Document {
+ static async getInitialProps (ctx) {
+ const initialProps = await Document.getInitialProps(ctx)
+ return { ...initialProps }
+ }
+
+ render () {
+ return (
+
+
+
+
+ {/* 字体 */}
+ {/* */}
+ {/* */}
+
+ {/* GoogleAdsense */}
+ {BLOG.isProd && (
+
+ )}
+
+ {/* 统计脚本 */}
+ {BLOG.isProd && BLOG.analytics && BLOG.analytics.provider === 'ackee' && (
+
+ )}
+
+ {BLOG.isProd && BLOG.autoCollapsedNavBar === true && (
+