├── .editorconfig ├── .env.example ├── .eslintrc.json ├── .github ├── funding.yml ├── issue_template.md ├── pull_request_template.md └── workflows │ └── build.yml ├── .gitignore ├── .prettierignore ├── .vscode ├── launch.json └── settings.json ├── components ├── ErrorPage.tsx ├── Footer.tsx ├── GitHubShareButton.tsx ├── Loading.tsx ├── LoadingIcon.tsx ├── NotionPage.tsx ├── NotionPageHeader.tsx ├── Page404.tsx ├── PageActions.tsx ├── PageAside.tsx ├── PageHead.tsx ├── PageSocial.module.css ├── PageSocial.tsx └── styles.module.css ├── contributing.md ├── eslint.config.js ├── lib ├── acl.ts ├── bootstrap-client.ts ├── config.ts ├── db.ts ├── fonts │ └── inter-semibold.ts ├── get-canonical-page-id.ts ├── get-config-value.ts ├── get-page-tweet.ts ├── get-site-map.ts ├── get-social-image-url.ts ├── get-tweets.ts ├── map-image-url.ts ├── map-page-url.ts ├── notion-api.ts ├── notion.ts ├── oembed.ts ├── preview-images.ts ├── reset.d.ts ├── resolve-notion-page.ts ├── search-notion.ts ├── site-config.ts ├── types.ts └── use-dark-mode.ts ├── license ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── 404.tsx ├── [pageId].tsx ├── _app.tsx ├── _document.tsx ├── _error.tsx ├── api │ ├── search-notion.ts │ └── social-image.tsx ├── feed.tsx ├── index.tsx ├── robots.txt.tsx └── sitemap.xml.tsx ├── pnpm-lock.yaml ├── public ├── 404.png ├── error.png ├── favicon-128x128.png ├── favicon-192x192.png ├── favicon.ico ├── favicon.png └── manifest.json ├── readme.md ├── site.config.ts ├── styles ├── global.css ├── notion.css └── prism-theme.css └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # This is an example .env file. 3 | # 4 | # All of these environment vars must be defined either in your environment or in 5 | # a local .env file in order to run this app. 6 | # 7 | # @see https://github.com/rolodato/dotenv-safe for more details. 8 | # ------------------------------------------------------------------------------ 9 | 10 | # Optional (for fathom analytics) 11 | #NEXT_PUBLIC_FATHOM_ID= 12 | 13 | # Optional (for PostHog analytics) 14 | #NEXT_PUBLIC_POSTHOG_ID= 15 | 16 | # Optional (for rendering tweets more efficiently) 17 | #TWITTER_ACCESS_TOKEN= 18 | 19 | # Optional (for persisting preview images to redis) 20 | # NOTE: if you want to enable redis, only REDIS_HOST and REDIS_PASSWORD are required 21 | # NOTE: don't forget to set isRedisEnabled to true in the site.config.ts file 22 | #REDIS_HOST= 23 | #REDIS_PASSWORD= 24 | #REDIS_USER='default' 25 | #REDIS_NAMESPACE='preview-images' 26 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@fisch0920/eslint-config"], 4 | "rules": { 5 | "react/prop-types": "off", 6 | "unicorn/no-array-reduce": "off", 7 | "unicorn/filename-case": "off", 8 | "no-process-env": "off", 9 | "array-callback-return": "off", 10 | "jsx-a11y/click-events-have-key-events": "off", 11 | "jsx-a11y/no-static-element-interactions": "off", 12 | "jsx-a11y/media-has-caption": "off", 13 | "jsx-a11y/interactive-supports-focus": "off", 14 | "jsx-a11y/anchor-is-valid": "off", 15 | "@typescript-eslint/naming-convention": "off" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [transitive-bullshit] 2 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | #### Description 2 | 3 | 6 | 7 | #### Notion Test Page ID 8 | 9 | 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | #### Description 2 | 3 | 6 | 7 | #### Notion Test Page ID 8 | 9 | 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Test Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | node-version: 14 | - 20 15 | - 22 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: pnpm/action-setup@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'pnpm' 24 | 25 | - run: pnpm install --frozen-lockfile --strict-peer-dependencies 26 | - run: pnpm test 27 | 28 | # TODO Enable those lines below if you use a Redis cache, you'll also need to configure GitHub Repository Secrets 29 | # env: 30 | # REDIS_HOST: ${{ secrets.REDIS_HOST }} 31 | # REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} 32 | # - name: Build 33 | # run: pnpm build 34 | -------------------------------------------------------------------------------- /.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 | *.pem 21 | 22 | # ide 23 | .idea 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env 32 | .env.local 33 | .env.build 34 | .env.development.local 35 | .env.test.local 36 | .env.production.local 37 | 38 | # vercel 39 | .vercel 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .snapshots/ 2 | build/ 3 | dist/ 4 | node_modules/ 5 | .next/ 6 | .vercel/ 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "next dev", 8 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/next", 9 | "runtimeArgs": ["dev"], 10 | "cwd": "${workspaceFolder}", 11 | "smartStep": true, 12 | "console": "integratedTerminal", 13 | "skipFiles": ["/**"], 14 | "env": { 15 | "NODE_OPTIONS": "--inspect" 16 | } 17 | }, 18 | { 19 | "type": "node", 20 | "request": "attach", 21 | "name": "Next.js App", 22 | "skipFiles": ["/**"], 23 | "port": 9229 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "files.exclude": { 4 | "**/logs": true, 5 | "**/*.log": true, 6 | "**/npm-debug.log*": true, 7 | "**/yarn-debug.log*": true, 8 | "**/yarn-error.log*": true, 9 | "**/pids": true, 10 | "**/*.pid": true, 11 | "**/*.seed": true, 12 | "**/*.pid.lock": true, 13 | "**/.dummy": true, 14 | "**/lib-cov": true, 15 | "**/coverage": true, 16 | "**/.nyc_output": true, 17 | "**/.grunt": true, 18 | "**/.snapshots/": true, 19 | "**/bower_components": true, 20 | "**/.lock-wscript": true, 21 | "build/Release": true, 22 | "**/node_modules/": true, 23 | "**/jspm_packages/": true, 24 | "**/typings/": true, 25 | "**/.npm": true, 26 | "**/.eslintcache": true, 27 | "**/.node_repl_history": true, 28 | "**/*.tgz": true, 29 | "**/.yarn-integrity": true, 30 | "**/.next/": true, 31 | "**/dist/": true, 32 | "**/build/": true, 33 | "**/.now/": true, 34 | "**/.vercel/": true, 35 | "**/.google.json": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /components/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import { PageHead } from './PageHead' 2 | import styles from './styles.module.css' 3 | 4 | export function ErrorPage({ statusCode }: { statusCode: number }) { 5 | const title = 'Error' 6 | 7 | return ( 8 | <> 9 | 10 | 11 |
12 |
13 |

Error Loading Page

14 | 15 | {statusCode &&

Error code: {statusCode}

} 16 | 17 | Error 18 |
19 |
20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { FaEnvelopeOpenText } from '@react-icons/all-files/fa/FaEnvelopeOpenText' 2 | import { FaGithub } from '@react-icons/all-files/fa/FaGithub' 3 | import { FaLinkedin } from '@react-icons/all-files/fa/FaLinkedin' 4 | import { FaMastodon } from '@react-icons/all-files/fa/FaMastodon' 5 | import { FaTwitter } from '@react-icons/all-files/fa/FaTwitter' 6 | import { FaYoutube } from '@react-icons/all-files/fa/FaYoutube' 7 | import { FaZhihu } from '@react-icons/all-files/fa/FaZhihu' 8 | import { IoMoonSharp } from '@react-icons/all-files/io5/IoMoonSharp' 9 | import { IoSunnyOutline } from '@react-icons/all-files/io5/IoSunnyOutline' 10 | import * as React from 'react' 11 | 12 | import * as config from '@/lib/config' 13 | import { useDarkMode } from '@/lib/use-dark-mode' 14 | 15 | import styles from './styles.module.css' 16 | 17 | // TODO: merge the data and icons from PageSocial with the social links in Footer 18 | 19 | export function FooterImpl() { 20 | const [hasMounted, setHasMounted] = React.useState(false) 21 | const { isDarkMode, toggleDarkMode } = useDarkMode() 22 | const currentYear = new Date().getFullYear() 23 | 24 | const onToggleDarkMode = React.useCallback( 25 | (e: any) => { 26 | e.preventDefault() 27 | toggleDarkMode() 28 | }, 29 | [toggleDarkMode] 30 | ) 31 | 32 | React.useEffect(() => { 33 | setHasMounted(true) 34 | }, []) 35 | 36 | return ( 37 | 141 | ) 142 | } 143 | 144 | export const Footer = React.memo(FooterImpl) 145 | -------------------------------------------------------------------------------- /components/GitHubShareButton.tsx: -------------------------------------------------------------------------------- 1 | import styles from './styles.module.css' 2 | 3 | export function GitHubShareButton() { 4 | return ( 5 | 12 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingIcon } from './LoadingIcon' 2 | import styles from './styles.module.css' 3 | 4 | export function Loading() { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /components/LoadingIcon.tsx: -------------------------------------------------------------------------------- 1 | import cs from 'classnames' 2 | 3 | import styles from './styles.module.css' 4 | 5 | export function LoadingIcon(props: any) { 6 | const { className, ...rest } = props 7 | return ( 8 | 13 | 14 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 41 | 47 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /components/NotionPage.tsx: -------------------------------------------------------------------------------- 1 | import cs from 'classnames' 2 | import dynamic from 'next/dynamic' 3 | import Image from 'next/legacy/image' 4 | import Link from 'next/link' 5 | import { useRouter } from 'next/router' 6 | import { type PageBlock } from 'notion-types' 7 | import { formatDate, getBlockTitle, getPageProperty } from 'notion-utils' 8 | import * as React from 'react' 9 | import BodyClassName from 'react-body-classname' 10 | import { 11 | type NotionComponents, 12 | NotionRenderer, 13 | useNotionContext 14 | } from 'react-notion-x' 15 | import { EmbeddedTweet, TweetNotFound, TweetSkeleton } from 'react-tweet' 16 | import { useSearchParam } from 'react-use' 17 | 18 | import type * as types from '@/lib/types' 19 | import * as config from '@/lib/config' 20 | import { mapImageUrl } from '@/lib/map-image-url' 21 | import { getCanonicalPageUrl, mapPageUrl } from '@/lib/map-page-url' 22 | import { searchNotion } from '@/lib/search-notion' 23 | import { useDarkMode } from '@/lib/use-dark-mode' 24 | 25 | import { Footer } from './Footer' 26 | import { GitHubShareButton } from './GitHubShareButton' 27 | import { Loading } from './Loading' 28 | import { NotionPageHeader } from './NotionPageHeader' 29 | import { Page404 } from './Page404' 30 | import { PageAside } from './PageAside' 31 | import { PageHead } from './PageHead' 32 | import styles from './styles.module.css' 33 | 34 | // ----------------------------------------------------------------------------- 35 | // dynamic imports for optional components 36 | // ----------------------------------------------------------------------------- 37 | 38 | const Code = dynamic(() => 39 | import('react-notion-x/build/third-party/code').then(async (m) => { 40 | // add / remove any prism syntaxes here 41 | await Promise.allSettled([ 42 | // @ts-expect-error Ignore prisma types 43 | import('prismjs/components/prism-markup-templating.js'), 44 | // @ts-expect-error Ignore prisma types 45 | import('prismjs/components/prism-markup.js'), 46 | // @ts-expect-error Ignore prisma types 47 | import('prismjs/components/prism-bash.js'), 48 | // @ts-expect-error Ignore prisma types 49 | import('prismjs/components/prism-c.js'), 50 | // @ts-expect-error Ignore prisma types 51 | import('prismjs/components/prism-cpp.js'), 52 | // @ts-expect-error Ignore prisma types 53 | import('prismjs/components/prism-csharp.js'), 54 | // @ts-expect-error Ignore prisma types 55 | import('prismjs/components/prism-docker.js'), 56 | // @ts-expect-error Ignore prisma types 57 | import('prismjs/components/prism-java.js'), 58 | // @ts-expect-error Ignore prisma types 59 | import('prismjs/components/prism-js-templates.js'), 60 | // @ts-expect-error Ignore prisma types 61 | import('prismjs/components/prism-coffeescript.js'), 62 | // @ts-expect-error Ignore prisma types 63 | import('prismjs/components/prism-diff.js'), 64 | // @ts-expect-error Ignore prisma types 65 | import('prismjs/components/prism-git.js'), 66 | // @ts-expect-error Ignore prisma types 67 | import('prismjs/components/prism-go.js'), 68 | // @ts-expect-error Ignore prisma types 69 | import('prismjs/components/prism-graphql.js'), 70 | // @ts-expect-error Ignore prisma types 71 | import('prismjs/components/prism-handlebars.js'), 72 | // @ts-expect-error Ignore prisma types 73 | import('prismjs/components/prism-less.js'), 74 | // @ts-expect-error Ignore prisma types 75 | import('prismjs/components/prism-makefile.js'), 76 | // @ts-expect-error Ignore prisma types 77 | import('prismjs/components/prism-markdown.js'), 78 | // @ts-expect-error Ignore prisma types 79 | import('prismjs/components/prism-objectivec.js'), 80 | // @ts-expect-error Ignore prisma types 81 | import('prismjs/components/prism-ocaml.js'), 82 | // @ts-expect-error Ignore prisma types 83 | import('prismjs/components/prism-python.js'), 84 | // @ts-expect-error Ignore prisma types 85 | import('prismjs/components/prism-reason.js'), 86 | // @ts-expect-error Ignore prisma types 87 | import('prismjs/components/prism-rust.js'), 88 | // @ts-expect-error Ignore prisma types 89 | import('prismjs/components/prism-sass.js'), 90 | // @ts-expect-error Ignore prisma types 91 | import('prismjs/components/prism-scss.js'), 92 | // @ts-expect-error Ignore prisma types 93 | import('prismjs/components/prism-solidity.js'), 94 | // @ts-expect-error Ignore prisma types 95 | import('prismjs/components/prism-sql.js'), 96 | // @ts-expect-error Ignore prisma types 97 | import('prismjs/components/prism-stylus.js'), 98 | // @ts-expect-error Ignore prisma types 99 | import('prismjs/components/prism-swift.js'), 100 | // @ts-expect-error Ignore prisma types 101 | import('prismjs/components/prism-wasm.js'), 102 | // @ts-expect-error Ignore prisma types 103 | import('prismjs/components/prism-yaml.js') 104 | ]) 105 | return m.Code 106 | }) 107 | ) 108 | 109 | const Collection = dynamic(() => 110 | import('react-notion-x/build/third-party/collection').then( 111 | (m) => m.Collection 112 | ) 113 | ) 114 | const Equation = dynamic(() => 115 | import('react-notion-x/build/third-party/equation').then((m) => m.Equation) 116 | ) 117 | const Pdf = dynamic( 118 | () => import('react-notion-x/build/third-party/pdf').then((m) => m.Pdf), 119 | { 120 | ssr: false 121 | } 122 | ) 123 | const Modal = dynamic( 124 | () => 125 | import('react-notion-x/build/third-party/modal').then((m) => { 126 | m.Modal.setAppElement('.notion-viewport') 127 | return m.Modal 128 | }), 129 | { 130 | ssr: false 131 | } 132 | ) 133 | 134 | function Tweet({ id }: { id: string }) { 135 | const { recordMap } = useNotionContext() 136 | const tweet = (recordMap as types.ExtendedTweetRecordMap)?.tweets?.[id] 137 | 138 | return ( 139 | }> 140 | {tweet ? : } 141 | 142 | ) 143 | } 144 | 145 | const propertyLastEditedTimeValue = ( 146 | { block, pageHeader }: any, 147 | defaultFn: () => React.ReactNode 148 | ) => { 149 | if (pageHeader && block?.last_edited_time) { 150 | return `Last updated ${formatDate(block?.last_edited_time, { 151 | month: 'long' 152 | })}` 153 | } 154 | 155 | return defaultFn() 156 | } 157 | 158 | const propertyDateValue = ( 159 | { data, schema, pageHeader }: any, 160 | defaultFn: () => React.ReactNode 161 | ) => { 162 | if (pageHeader && schema?.name?.toLowerCase() === 'published') { 163 | const publishDate = data?.[0]?.[1]?.[0]?.[1]?.start_date 164 | 165 | if (publishDate) { 166 | return `${formatDate(publishDate, { 167 | month: 'long' 168 | })}` 169 | } 170 | } 171 | 172 | return defaultFn() 173 | } 174 | 175 | const propertyTextValue = ( 176 | { schema, pageHeader }: any, 177 | defaultFn: () => React.ReactNode 178 | ) => { 179 | if (pageHeader && schema?.name?.toLowerCase() === 'author') { 180 | return {defaultFn()} 181 | } 182 | 183 | return defaultFn() 184 | } 185 | 186 | export function NotionPage({ 187 | site, 188 | recordMap, 189 | error, 190 | pageId 191 | }: types.PageProps) { 192 | const router = useRouter() 193 | const lite = useSearchParam('lite') 194 | 195 | const components = React.useMemo>( 196 | () => ({ 197 | nextLegacyImage: Image, 198 | nextLink: Link, 199 | Code, 200 | Collection, 201 | Equation, 202 | Pdf, 203 | Modal, 204 | Tweet, 205 | Header: NotionPageHeader, 206 | propertyLastEditedTimeValue, 207 | propertyTextValue, 208 | propertyDateValue 209 | }), 210 | [] 211 | ) 212 | 213 | // lite mode is for oembed 214 | const isLiteMode = lite === 'true' 215 | 216 | const { isDarkMode } = useDarkMode() 217 | 218 | const siteMapPageUrl = React.useMemo(() => { 219 | const params: any = {} 220 | if (lite) params.lite = lite 221 | 222 | const searchParams = new URLSearchParams(params) 223 | return site ? mapPageUrl(site, recordMap!, searchParams) : undefined 224 | }, [site, recordMap, lite]) 225 | 226 | const keys = Object.keys(recordMap?.block || {}) 227 | const block = recordMap?.block?.[keys[0]!]?.value 228 | 229 | // const isRootPage = 230 | // parsePageId(block?.id) === parsePageId(site?.rootNotionPageId) 231 | const isBlogPost = 232 | block?.type === 'page' && block?.parent_table === 'collection' 233 | 234 | const showTableOfContents = !!isBlogPost 235 | const minTableOfContentsItems = 3 236 | 237 | const pageAside = React.useMemo( 238 | () => ( 239 | 244 | ), 245 | [block, recordMap, isBlogPost] 246 | ) 247 | 248 | const footer = React.useMemo(() =>