├── .editorconfig ├── .env.example ├── .eslintrc.json ├── .github ├── funding.yml ├── issue_template.md ├── pull_request_template.md └── workflows │ └── build.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .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 ├── Slide.tsx ├── index.ts └── styles.module.css ├── contributing.md ├── lib ├── acl.ts ├── bootstrap-client.ts ├── config.ts ├── db.ts ├── fonts.ts ├── get-canonical-page-id.ts ├── get-config-value.ts ├── get-page-tweet.ts ├── get-site-map.ts ├── get-social-image-url.ts ├── map-image-url.ts ├── map-page-url.ts ├── notion-api.ts ├── notion.ts ├── oembed.ts ├── preview-images.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-lock.json ├── 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 ├── 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 └── slides.css ├── tsconfig.json └── yarn.lock /.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 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "react", "react-hooks"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:react/recommended", 9 | "plugin:react-hooks/recommended", 10 | "prettier" 11 | ], 12 | "settings": { 13 | "react": { 14 | "version": "detect" 15 | } 16 | }, 17 | "env": { 18 | "browser": true, 19 | "node": true 20 | }, 21 | "rules": { 22 | "@typescript-eslint/no-explicit-any": 0, 23 | "@typescript-eslint/no-non-null-assertion": 0, 24 | "@typescript-eslint/no-unused-vars": 2, 25 | "react/prop-types": 0 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.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: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | Build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: 16 14 | cache: yarn 15 | 16 | - run: yarn install --frozen-lockfile 17 | - name: build 18 | # TODO Enable those lines below if you use a Redis cache, you'll also need to configure GitHub Repository Secrets 19 | # env: 20 | # REDIS_HOST: ${{ secrets.REDIS_HOST }} 21 | # REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} 22 | run: yarn build 23 | -------------------------------------------------------------------------------- /.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 | 8 | .demo/ 9 | .renderer/ 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "bracketSpacing": true, 8 | "bracketSameLine": false, 9 | "arrowParens": "always", 10 | "trailingComma": "none" 11 | } 12 | -------------------------------------------------------------------------------- /.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 | "port": 9229, 12 | "smartStep": true, 13 | "console": "integratedTerminal", 14 | "skipFiles": ["/**"], 15 | "env": { 16 | "NODE_OPTIONS": "--inspect" 17 | } 18 | }, 19 | { 20 | "type": "node", 21 | "request": "attach", 22 | "name": "Next.js App", 23 | "skipFiles": ["/**"], 24 | "port": 9229 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.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 * as React from 'react' 2 | import { PageHead } from './PageHead' 3 | 4 | import styles from './styles.module.css' 5 | 6 | export const ErrorPage: React.FC<{ statusCode: number }> = ({ statusCode }) => { 7 | const title = 'Error' 8 | 9 | return ( 10 | <> 11 | 12 | 13 |
14 |
15 |

Error Loading Page

16 | 17 | {statusCode &&

Error code: {statusCode}

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