├── .editorconfig ├── .env.example ├── .eslintrc ├── .github ├── funding.yml ├── issue_template.md └── pull_request_template.md ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── api ├── create-preview-image.ts ├── get-tweet-ast │ └── [tweetId].ts ├── robots.txt.ts ├── search-notion.ts └── sitemap.xml.ts ├── components ├── CustomFont.tsx ├── ErrorPage.tsx ├── Footer.tsx ├── GitHubShareButton.tsx ├── Loading.tsx ├── LoadingIcon.tsx ├── NotionPage.tsx ├── Page404.tsx ├── PageActions.tsx ├── PageHead.tsx ├── PageSocial.module.css ├── PageSocial.tsx ├── ReactUtterances.tsx ├── index.ts └── styles.module.css ├── lib ├── acl.ts ├── bootstrap-client.ts ├── config.ts ├── db.ts ├── get-all-pages.ts ├── get-canonical-page-id.ts ├── get-config-value.ts ├── get-page-description.ts ├── get-page-tweet.ts ├── get-preview-images.ts ├── get-site-for-domain.ts ├── get-site-maps.ts ├── get-sites.ts ├── map-image-url.ts ├── map-page-url.ts ├── notion.ts ├── oembed.ts ├── resolve-notion-page.ts ├── search-notion.ts └── types.ts ├── license ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── 404.tsx ├── [pageId].tsx ├── _app.tsx ├── _document.tsx ├── _error.tsx └── index.tsx ├── public ├── 404.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── error.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── favicon.png ├── manifest.json ├── noflash.js ├── site.webmanifest └── social.jpg ├── readme.md ├── site.config.js ├── styles ├── global.css ├── notion.css └── prism-theme.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 preview image support) 11 | #GOOGLE_APPLICATION_CREDENTIALS= 12 | 13 | # Optional (for preview image support) 14 | #GCLOUD_PROJECT= 15 | 16 | # Optional (for preview image support) 17 | #FIREBASE_COLLECTION_IMAGES= 18 | 19 | # Optional (for fathom analytics) 20 | #NEXT_PUBLIC_FATHOM_ID= 21 | 22 | # Optional (for rendering tweets efficiently) 23 | TWITTER_ACCESS_TOKEN= 24 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "ecmaFeatures": { 6 | "legacyDecorators": true, 7 | "jsx": true 8 | } 9 | }, 10 | "settings": { 11 | "react": { 12 | "version": "detect" 13 | } 14 | }, 15 | "ignorePatterns": ["**/*.js", "**/*.jsx"], 16 | "parser": "@typescript-eslint/parser", 17 | "plugins": ["@typescript-eslint"], 18 | "extends": [ 19 | "plugin:@typescript-eslint/recommended", 20 | "standard", 21 | "standard-react", 22 | "plugin:prettier/recommended" 23 | ], 24 | "env": { "browser": true, "node": true }, 25 | "rules": { 26 | "@typescript-eslint/no-explicit-any": 0, 27 | "@typescript-eslint/explicit-module-boundary-types": 0, 28 | "no-use-before-define": 0, 29 | "@typescript-eslint/no-use-before-define": 0, 30 | "space-before-function-paren": 0, 31 | "react/prop-types": 0, 32 | "react/jsx-handler-names": 0, 33 | "react/jsx-fragments": 0, 34 | "react/no-unused-prop-types": 0, 35 | "import/export": 0, 36 | "standard/no-callback-literal": 0 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [transitive-bullshit] 2 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.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 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.build 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # vercel 36 | .vercel 37 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: yarn 3 | node_js: 4 | - 12 5 | - 14 6 | -------------------------------------------------------------------------------- /.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 | "jira-plugin.workingProject": "" 38 | } 39 | -------------------------------------------------------------------------------- /api/create-preview-image.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | import got from 'got' 4 | import lqip from 'lqip-modern' 5 | 6 | import { isPreviewImageSupportEnabled } from '../lib/config' 7 | import * as types from '../lib/types' 8 | import * as db from '../lib/db' 9 | 10 | export default async ( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ): Promise => { 14 | if (req.method !== 'POST') { 15 | return res.status(405).send({ error: 'method not allowed' }) 16 | } 17 | 18 | if (!isPreviewImageSupportEnabled) { 19 | return res.status(418).send({ 20 | error: 'preview image support has been disabled for this deployment' 21 | }) 22 | } 23 | 24 | const { url, id } = req.body 25 | 26 | const result = await createPreviewImage(url, id) 27 | 28 | res.setHeader( 29 | 'Cache-Control', 30 | result.error 31 | ? 'public, s-maxage=60, max-age=60, stale-while-revalidate=60' 32 | : 'public, immutable, s-maxage=31536000, max-age=31536000, stale-while-revalidate=60' 33 | ) 34 | res.status(200).json(result) 35 | } 36 | 37 | export async function createPreviewImage( 38 | url: string, 39 | id: string 40 | ): Promise { 41 | console.log('createPreviewImage lambda', { url, id }) 42 | const doc = db.images.doc(id) 43 | 44 | try { 45 | const model = await doc.get() 46 | if (model.exists) { 47 | return model.data() as types.PreviewImage 48 | } 49 | 50 | const { body } = await got(url, { responseType: 'buffer' }) 51 | const result = await lqip(body) 52 | console.log('lqip', result.metadata) 53 | 54 | const image = { 55 | url, 56 | originalWidth: result.metadata.originalWidth, 57 | originalHeight: result.metadata.originalHeight, 58 | width: result.metadata.width, 59 | height: result.metadata.height, 60 | type: result.metadata.type, 61 | dataURIBase64: result.metadata.dataURIBase64 62 | } 63 | 64 | await doc.create(image) 65 | return image 66 | } catch (err) { 67 | console.error('lqip error', err) 68 | 69 | try { 70 | const error: any = { 71 | url, 72 | error: err.message || 'unknown error' 73 | } 74 | 75 | if (err?.response?.statusCode) { 76 | error.statusCode = err?.response?.statusCode 77 | } 78 | 79 | await doc.create(error) 80 | return error 81 | } catch (err) { 82 | // ignore errors 83 | console.error(err) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /api/get-tweet-ast/[tweetId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { fetchTweetAst } from 'static-tweets' 3 | 4 | export default async ( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ): Promise => { 8 | if (req.method !== 'GET') { 9 | return res.status(405).send({ error: 'method not allowed' }) 10 | } 11 | 12 | const tweetId = req.query.tweetId as string 13 | 14 | if (!tweetId) { 15 | return res 16 | .status(400) 17 | .send({ error: 'missing required parameter "tweetId"' }) 18 | } 19 | 20 | console.log('getTweetAst', tweetId) 21 | const tweetAst = await fetchTweetAst(tweetId) 22 | console.log('tweetAst', tweetId, tweetAst) 23 | 24 | res.status(200).json(tweetAst) 25 | } 26 | -------------------------------------------------------------------------------- /api/robots.txt.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | import { host } from '../lib/config' 4 | 5 | export default async ( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ): Promise => { 9 | if (req.method !== 'GET') { 10 | return res.status(405).send({ error: 'method not allowed' }) 11 | } 12 | 13 | // cache robots.txt for up to 60 seconds 14 | res.setHeader( 15 | 'Cache-Control', 16 | 'public, s-maxage=60, max-age=60, stale-while-revalidate=60' 17 | ) 18 | res.setHeader('Content-Type', 'text/plain') 19 | res.write(`User-agent: * 20 | Sitemap: ${host}/api/sitemap.xml 21 | `) 22 | res.end() 23 | } 24 | -------------------------------------------------------------------------------- /api/search-notion.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | import * as types from '../lib/types' 4 | import { search } from '../lib/notion' 5 | 6 | export default async (req: NextApiRequest, res: NextApiResponse) => { 7 | if (req.method !== 'POST') { 8 | return res.status(405).send({ error: 'method not allowed' }) 9 | } 10 | 11 | const searchParams: types.SearchParams = req.body 12 | 13 | console.log('lambda search-notion', searchParams) 14 | const results = await search(searchParams) 15 | 16 | res.setHeader( 17 | 'Cache-Control', 18 | 'public, s-maxage=60, max-age=60, stale-while-revalidate=60' 19 | ) 20 | res.status(200).json(results) 21 | } 22 | -------------------------------------------------------------------------------- /api/sitemap.xml.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | import { SiteMap } from '../lib/types' 4 | import { host } from '../lib/config' 5 | import { getSiteMaps } from '../lib/get-site-maps' 6 | 7 | export default async ( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ): Promise => { 11 | if (req.method !== 'GET') { 12 | return res.status(405).send({ error: 'method not allowed' }) 13 | } 14 | 15 | const siteMaps = await getSiteMaps() 16 | 17 | // cache sitemap for up to one hour 18 | res.setHeader( 19 | 'Cache-Control', 20 | 'public, s-maxage=3600, max-age=3600, stale-while-revalidate=3600' 21 | ) 22 | res.setHeader('Content-Type', 'text/xml') 23 | res.write(createSitemap(siteMaps[0])) 24 | res.end() 25 | } 26 | 27 | const createSitemap = ( 28 | siteMap: SiteMap 29 | ) => ` 30 | 31 | 32 | ${host} 33 | 34 | 35 | 36 | ${host}/ 37 | 38 | 39 | ${Object.keys(siteMap.canonicalPageMap) 40 | .map((canonicalPagePath) => 41 | ` 42 | 43 | ${host}/${canonicalPagePath} 44 | 45 | `.trim() 46 | ) 47 | .join('')} 48 | 49 | ` 50 | -------------------------------------------------------------------------------- /components/CustomFont.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import * as React from 'react' 3 | import * as types from '../lib/types' 4 | 5 | export const CustomFont: React.FC<{ site: types.Site }> = ({ site }) => { 6 | if (!site.fontFamily) { 7 | return null 8 | } 9 | 10 | // https://developers.google.com/fonts/docs/css2 11 | const fontFamilies = [site.fontFamily] 12 | const googleFontFamilies = fontFamilies 13 | .map((font) => font.replace(/ /g, '+')) 14 | .map((font) => `family=${font}:ital,wght@0,200..700;1,200..700`) 15 | .join('&') 16 | const googleFontsLink = `https://fonts.googleapis.com/css?${googleFontFamilies}&display=swap` 17 | const cssFontFamilies = fontFamilies.map((font) => `"${font}"`).join(', ') 18 | 19 | return ( 20 | <> 21 | 22 | 23 | 24 | 31 | 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /components/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | import { PageHead } from './PageHead' 4 | 5 | import styles from './styles.module.css' 6 | 7 | export const ErrorPage: React.FC<{ statusCode: number }> = ({ statusCode }) => { 8 | const title = 'Error' 9 | 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | {title} 19 | 20 | 21 |
22 |
23 |

Error Loading Page

24 | 25 | {statusCode &&

Error code: {statusCode}

} 26 | 27 | Error 28 |
29 |
30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FaTwitter, FaGithub, FaLinkedin } from 'react-icons/fa' 3 | import { IoSunnyOutline, IoMoonSharp } from 'react-icons/io5' 4 | import * as config from 'lib/config' 5 | 6 | import styles from './styles.module.css' 7 | 8 | // TODO: merge the data and icons from PageSocial with the social links in Footer 9 | 10 | export const Footer: React.FC<{ 11 | isDarkMode: boolean 12 | toggleDarkMode: () => void 13 | }> = ({ isDarkMode, toggleDarkMode }) => { 14 | const [hasMounted, setHasMounted] = React.useState(false) 15 | const toggleDarkModeCb = React.useCallback( 16 | (e) => { 17 | e.preventDefault() 18 | toggleDarkMode() 19 | }, 20 | [toggleDarkMode] 21 | ) 22 | 23 | React.useEffect(() => { 24 | setHasMounted(true) 25 | }, []) 26 | 27 | return ( 28 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /components/GitHubShareButton.tsx: -------------------------------------------------------------------------------- 1 | import 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 Head from 'next/head' 3 | import Link from 'next/link' 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 useDarkMode from 'use-dark-mode' 10 | import { PageBlock } from 'notion-types' 11 | 12 | import { Tweet, TwitterContextProvider } from 'react-static-tweets' 13 | 14 | // core notion renderer 15 | import { NotionRenderer, Code, Collection, CollectionRow } from 'react-notion-x' 16 | 17 | // utils 18 | import { getBlockTitle } from 'notion-utils' 19 | import { mapPageUrl, getCanonicalPageUrl } from 'lib/map-page-url' 20 | import { mapNotionImageUrl } from 'lib/map-image-url' 21 | import { getPageDescription } from 'lib/get-page-description' 22 | import { getPageTweet } from 'lib/get-page-tweet' 23 | import { searchNotion } from 'lib/search-notion' 24 | import * as types from 'lib/types' 25 | import * as config from 'lib/config' 26 | 27 | // components 28 | import { CustomFont } from './CustomFont' 29 | import { Loading } from './Loading' 30 | import { Page404 } from './Page404' 31 | import { PageHead } from './PageHead' 32 | import { PageActions } from './PageActions' 33 | import { Footer } from './Footer' 34 | import { PageSocial } from './PageSocial' 35 | import { GitHubShareButton } from './GitHubShareButton' 36 | import { ReactUtterances } from './ReactUtterances' 37 | 38 | import styles from './styles.module.css' 39 | 40 | // const Code = dynamic(() => 41 | // import('react-notion-x').then((notion) => notion.Code) 42 | // ) 43 | // 44 | // const Collection = dynamic(() => 45 | // import('react-notion-x').then((notion) => notion.Collection) 46 | // ) 47 | // 48 | // const CollectionRow = dynamic( 49 | // () => import('react-notion-x').then((notion) => notion.CollectionRow), 50 | // { 51 | // ssr: false 52 | // } 53 | // ) 54 | 55 | // TODO: PDF support via "react-pdf" package has numerous troubles building 56 | // with next.js 57 | // const Pdf = dynamic( 58 | // () => import('react-notion-x').then((notion) => notion.Pdf), 59 | // { ssr: false } 60 | // ) 61 | 62 | const Equation = dynamic(() => 63 | import('react-notion-x').then((notion) => notion.Equation) 64 | ) 65 | 66 | // we're now using a much lighter-weight tweet renderer react-static-tweets 67 | // instead of the official iframe-based embed widget from twitter 68 | // const Tweet = dynamic(() => import('react-tweet-embed')) 69 | 70 | const Modal = dynamic( 71 | () => import('react-notion-x').then((notion) => notion.Modal), 72 | { ssr: false } 73 | ) 74 | 75 | export const NotionPage: React.FC = ({ 76 | site, 77 | recordMap, 78 | error, 79 | pageId 80 | }) => { 81 | const router = useRouter() 82 | const lite = useSearchParam('lite') 83 | 84 | const params: any = {} 85 | if (lite) params.lite = lite 86 | 87 | // lite mode is for oembed 88 | const isLiteMode = lite === 'true' 89 | const searchParams = new URLSearchParams(params) 90 | 91 | const darkMode = useDarkMode(false, { classNameDark: 'dark-mode' }) 92 | 93 | if (router.isFallback) { 94 | return 95 | } 96 | 97 | const keys = Object.keys(recordMap?.block || {}) 98 | const block = recordMap?.block?.[keys[0]]?.value 99 | 100 | if (error || !site || !keys.length || !block) { 101 | return 102 | } 103 | 104 | const title = getBlockTitle(block, recordMap) || site.name 105 | 106 | console.log('notion page', { 107 | isDev: config.isDev, 108 | title, 109 | pageId, 110 | rootNotionPageId: site.rootNotionPageId, 111 | recordMap 112 | }) 113 | 114 | if (!config.isServer) { 115 | // add important objects to the window global for easy debugging 116 | const g = window as any 117 | g.pageId = pageId 118 | g.recordMap = recordMap 119 | g.block = block 120 | } 121 | 122 | const siteMapPageUrl = mapPageUrl(site, recordMap, searchParams) 123 | 124 | const canonicalPageUrl = 125 | !config.isDev && getCanonicalPageUrl(site, recordMap)(pageId) 126 | 127 | // const isRootPage = 128 | // parsePageId(block.id) === parsePageId(site.rootNotionPageId) 129 | const isBlogPost = 130 | block.type === 'page' && block.parent_table === 'collection' 131 | const showTableOfContents = !!isBlogPost 132 | const minTableOfContentsItems = 3 133 | 134 | const socialImage = mapNotionImageUrl( 135 | (block as PageBlock).format?.page_cover || config.defaultPageCover, 136 | block 137 | ) 138 | 139 | const socialDescription = 140 | getPageDescription(block, recordMap) ?? config.description 141 | 142 | let comments: React.ReactNode = null 143 | let pageAside: React.ReactChild = null 144 | 145 | // only display comments and page actions on blog post pages 146 | if (isBlogPost) { 147 | if (config.utterancesGitHubRepo) { 148 | comments = ( 149 | 155 | ) 156 | } 157 | 158 | const tweet = getPageTweet(block, recordMap) 159 | if (tweet) { 160 | pageAside = 161 | } 162 | } else { 163 | pageAside = 164 | } 165 | 166 | return ( 167 | 172 | fetch(`/api/get-tweet-ast/${id}`).then((r) => r.json()) 173 | } 174 | }} 175 | > 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | {config.twitter && ( 186 | 187 | )} 188 | 189 | {socialDescription && ( 190 | <> 191 | 192 | 193 | 194 | 195 | )} 196 | 197 | {socialImage ? ( 198 | <> 199 | 200 | 201 | 202 | 203 | ) : ( 204 | 205 | )} 206 | 207 | {canonicalPageUrl && ( 208 | <> 209 | 210 | 211 | 212 | 213 | )} 214 | 215 | {title} 216 | 217 | 218 | 219 | 220 | {isLiteMode && } 221 | 222 | ( 239 | 249 | 250 | 251 | ), 252 | code: Code, 253 | collection: Collection, 254 | collectionRow: CollectionRow, 255 | tweet: Tweet, 256 | modal: Modal, 257 | equation: Equation 258 | }} 259 | recordMap={recordMap} 260 | rootPageId={site.rootNotionPageId} 261 | fullPage={!isLiteMode} 262 | darkMode={darkMode.value} 263 | previewImages={site.previewImages !== false} 264 | showCollectionViewDropdown={false} 265 | showTableOfContents={showTableOfContents} 266 | minTableOfContentsItems={minTableOfContentsItems} 267 | defaultPageIcon={config.defaultPageIcon} 268 | defaultPageCover={config.defaultPageCover} 269 | defaultPageCoverPosition={config.defaultPageCoverPosition} 270 | mapPageUrl={siteMapPageUrl} 271 | mapImageUrl={mapNotionImageUrl} 272 | searchNotion={searchNotion} 273 | pageFooter={comments} 274 | pageAside={pageAside} 275 | footer={ 276 |