├── .github ├── funding.yml ├── issue_template.md └── pull_request_template.md ├── .travis.yml ├── pages ├── 404.tsx ├── _error.tsx ├── index.tsx ├── [pageId].tsx ├── _document.tsx └── _app.tsx ├── public ├── 404.png ├── error.png ├── favicon.ico ├── favicon.png ├── social.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── 00-template_cover_1000x400.png ├── manifest.json ├── noflash.js └── sitemap_index.xml ├── components ├── index.ts ├── Loading.tsx ├── ErrorPage.tsx ├── PageActions.tsx ├── PageHead.tsx ├── CustomFont.tsx ├── Page404.tsx ├── LoadingIcon.tsx ├── GitHubShareButton.tsx ├── PageSocial.module.css ├── Footer.tsx ├── PageSocial.tsx ├── ReactUtterances.tsx ├── styles.module.css └── NotionPage.tsx ├── .prettierignore ├── .editorconfig ├── .prettierrc ├── lib ├── get-sites.ts ├── get-page-tweet.ts ├── get-page-description.ts ├── get-site-for-domain.ts ├── db.ts ├── get-canonical-page-id.ts ├── bootstrap-client.ts ├── get-site-maps.ts ├── search-notion.ts ├── get-config-value.ts ├── acl.ts ├── map-page-url.ts ├── get-all-pages.ts ├── map-image-url.ts ├── get-preview-images.ts ├── types.ts ├── oembed.ts ├── resolve-notion-page.ts ├── notion.ts └── config.ts ├── next-env.d.ts ├── next.config.js ├── .gitignore ├── api ├── robots.txt.ts ├── search-notion.ts ├── get-tweet-ast │ └── [tweetId].ts ├── sitemap.xml.ts └── create-preview-image.ts ├── tsconfig.json ├── .vscode ├── launch.json └── settings.json ├── styles ├── global.css ├── prism-theme.css └── notion.css ├── readme.md ├── .env.example ├── .eslintrc ├── license ├── site.config.js └── package.json /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [transitive-bullshit] 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: yarn 3 | node_js: 4 | - 12 5 | - 14 6 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Page404 } from 'components' 2 | 3 | export default Page404 4 | -------------------------------------------------------------------------------- /pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from 'components' 2 | 3 | export default ErrorPage 4 | -------------------------------------------------------------------------------- /public/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/404.png -------------------------------------------------------------------------------- /public/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/error.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/favicon.png -------------------------------------------------------------------------------- /public/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/social.png -------------------------------------------------------------------------------- /components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './NotionPage' 2 | export * from './Page404' 3 | export * from './ErrorPage' 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .snapshots/ 2 | build/ 3 | dist/ 4 | node_modules/ 5 | .next/ 6 | .vercel/ 7 | 8 | .demo/ 9 | .renderer/ 10 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/00-template_cover_1000x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunabites/designsystemsbrasileiros/HEAD/public/00-template_cover_1000x400.png -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /lib/get-sites.ts: -------------------------------------------------------------------------------- 1 | import { getSiteForDomain } from './get-site-for-domain' 2 | import * as config from './config' 3 | import * as types from './types' 4 | 5 | export async function getSites(): Promise { 6 | return [await getSiteForDomain(config.domain)] 7 | } 8 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/get-page-tweet.ts: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | import { getPageProperty } from 'notion-utils' 3 | 4 | export function getPageTweet( 5 | block: types.Block, 6 | recordMap: types.ExtendedRecordMap 7 | ): string | null { 8 | return getPageProperty('Tweet', block, recordMap) 9 | } 10 | -------------------------------------------------------------------------------- /lib/get-page-description.ts: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | import { getPageProperty } from 'notion-utils' 3 | 4 | export function getPageDescription( 5 | block: types.Block, 6 | recordMap: types.ExtendedRecordMap 7 | ): string | null { 8 | return getPageProperty('Description', block, recordMap) 9 | } 10 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV 2 | 3 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 4 | enabled: process.env.ANALYZE === 'true' 5 | }) 6 | 7 | module.exports = withBundleAnalyzer({ 8 | images: { 9 | domains: ['pbs.twimg.com'] 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /lib/get-site-for-domain.ts: -------------------------------------------------------------------------------- 1 | import * as config from './config' 2 | import * as types from './types' 3 | 4 | export const getSiteForDomain = async ( 5 | domain: string 6 | ): Promise => { 7 | return { 8 | domain, 9 | name: config.name, 10 | rootNotionPageId: config.rootNotionPageId, 11 | rootNotionSpaceId: config.rootNotionSpaceId, 12 | description: config.description 13 | } as types.Site 14 | } 15 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import * as firestore from '@google-cloud/firestore' 2 | import * as config from './config' 3 | 4 | export let db: firestore.Firestore = null 5 | export let images: firestore.CollectionReference = null 6 | 7 | if (config.isPreviewImageSupportEnabled) { 8 | db = new firestore.Firestore({ 9 | projectId: config.googleProjectId, 10 | credentials: config.googleApplicationCredentials 11 | }) 12 | 13 | images = db.collection(config.firebaseCollectionImages) 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "experimentalDecorators": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "baseUrl": ".", 18 | "typeRoots": ["./node_modules/@types"] 19 | }, 20 | "exclude": ["node_modules"], 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { domain } from 'lib/config' 3 | import { resolveNotionPage } from 'lib/resolve-notion-page' 4 | import { NotionPage } from 'components' 5 | 6 | export const getStaticProps = async () => { 7 | try { 8 | const props = await resolveNotionPage(domain) 9 | 10 | return { props, revalidate: 10 } 11 | } catch (err) { 12 | console.error('page error', domain, err) 13 | 14 | // we don't want to publish the error version of this page, so 15 | // let next.js know explicitly that incremental SSG failed 16 | throw err 17 | } 18 | } 19 | 20 | export default function NotionDomainPage(props) { 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/get-canonical-page-id.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedRecordMap } from 'notion-types' 2 | import { 3 | parsePageId, 4 | getCanonicalPageId as getCanonicalPageIdImpl 5 | } from 'notion-utils' 6 | 7 | import { inversePageUrlOverrides } from './config' 8 | 9 | export function getCanonicalPageId( 10 | pageId: string, 11 | recordMap: ExtendedRecordMap, 12 | { uuid = true }: { uuid?: boolean } = {} 13 | ): string | null { 14 | const cleanPageId = parsePageId(pageId, { uuid: false }) 15 | if (!cleanPageId) { 16 | return null 17 | } 18 | 19 | const override = inversePageUrlOverrides[cleanPageId] 20 | if (override) { 21 | return override 22 | } else { 23 | return getCanonicalPageIdImpl(pageId, recordMap, { 24 | uuid 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | a { 6 | color: inherit; 7 | text-decoration: none; 8 | } 9 | 10 | @import url('https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@300;400;500&family=Prompt:wght@200;300;400&display=swap'); 11 | 12 | body, 13 | html, 14 | .notion { 15 | font-family: 'Lexend Deca', sans-serif !important; 16 | } 17 | 18 | body { 19 | overflow-x: hidden; 20 | margin: 0; 21 | } 22 | 23 | .utterances { 24 | max-width: 100% !important; 25 | } 26 | 27 | .static-tweet blockquote { 28 | margin: 0; 29 | margin-block-start: 0; 30 | margin-block-end: 0; 31 | margin-inline-start: 0; 32 | margin-inline-end: 0; 33 | } 34 | 35 | .static-tweet-emoji { 36 | height: 1.2em !important; 37 | width: 1.2em !important; 38 | } 39 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Brazilian Design Systems 2 | A list of Design Systems built in Brazil 🇧🇷 3 | 4 | ## Intro 5 | 6 | This repo is used to power the site [Design Systems Brasileiros](https://designsystemsbrasileiros.com). If you would like to suggest a Design System, please fill in [this form](https://forms.gle/wDTJ14CzUWtJYekG8). 7 | 8 | 9 | ## About the repo / License 10 | It uses Notion as a CMS, fetching content from Notion and then uses [Next.js](https://nextjs.org/) and [react-notion-x](https://github.com/NotionX/react-notion-x) to render everything, based on the [project built by Travis Fischer](https://github.com/transitive-bullshit/nextjs-notion-starter-kit). The site is then deployed to [Vercel](http://vercel.com). 11 | 12 | *License* 13 | 14 | MIT © [Travis Fischer](https://transitivebullsh.it) 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /lib/bootstrap-client.ts: -------------------------------------------------------------------------------- 1 | export function bootstrap() { 2 | console.log(` 3 | 4 | ████████╗██████╗ █████╗ ███╗ ██╗███████╗██╗████████╗██╗██╗ ██╗███████╗ ██████╗ ███████╗ 5 | ╚══██╔══╝██╔══██╗██╔══██╗████╗ ██║██╔════╝██║╚══██╔══╝██║██║ ██║██╔════╝ ██╔══██╗██╔════╝ 6 | ██║ ██████╔╝███████║██╔██╗ ██║███████╗██║ ██║ ██║██║ ██║█████╗ ██████╔╝███████╗ 7 | ██║ ██╔══██╗██╔══██║██║╚██╗██║╚════██║██║ ██║ ██║╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║ 8 | ██║ ██║ ██║██║ ██║██║ ╚████║███████║██║ ██║ ██║ ╚████╔╝ ███████╗ ██████╔╝███████║ 9 | ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚══════╝ 10 | 11 | This site is built using Notion, Next.js, and https://github.com/NotionX/react-notion-x. 12 | `) 13 | } 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/get-site-maps.ts: -------------------------------------------------------------------------------- 1 | import pMap from 'p-map' 2 | 3 | import { getAllPages } from './get-all-pages' 4 | import { getSites } from './get-sites' 5 | import * as types from './types' 6 | 7 | export async function getSiteMaps(): Promise { 8 | const sites = await getSites() 9 | 10 | const siteMaps = await pMap( 11 | sites, 12 | async (site, index) => { 13 | try { 14 | console.log( 15 | 'getSiteMap', 16 | `${index + 1}/${sites.length}`, 17 | `(${(((index + 1) / sites.length) * 100) | 0}%)`, 18 | site 19 | ) 20 | 21 | return { 22 | site, 23 | ...(await getAllPages(site.rootNotionPageId, site.rootNotionSpaceId)) 24 | } as types.SiteMap 25 | } catch (err) { 26 | console.warn('site build error', index, site, err) 27 | } 28 | }, 29 | { 30 | concurrency: 4 31 | } 32 | ) 33 | 34 | return siteMaps.filter(Boolean) 35 | } 36 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Design Systems Brasileiros", 3 | "short_name": "Design Systems Brasileiros", 4 | "icons": [ 5 | { 6 | "src": "/favicon-16x16.png", 7 | "type": "image/png", 8 | "sizes": "16x16" 9 | }, 10 | { 11 | "src": "/favicon-32x32.png", 12 | "type": "image/png", 13 | "sizes": "32x32" 14 | }, 15 | { 16 | "src": "/favicon-96x96.png", 17 | "type": "image/png", 18 | "sizes": "96x96" 19 | }, 20 | { 21 | "src": "/apple-touch-icon.png", 22 | "type": "image/png", 23 | "sizes": "180x180" 24 | }, 25 | { 26 | "src": "/android-chrome-192x192.png", 27 | "sizes": "192x192", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "/android-chrome-512x512.png", 32 | "sizes": "512x512", 33 | "type": "image/png" 34 | } 35 | ], 36 | "theme_color": "#F898B9", 37 | "background_color": "#ffffff", 38 | "display": "standalone" 39 | } 40 | -------------------------------------------------------------------------------- /lib/search-notion.ts: -------------------------------------------------------------------------------- 1 | // import ky from 'ky' 2 | import fetch from 'isomorphic-unfetch' 3 | import pMemoize from 'p-memoize' 4 | 5 | import { api } from './config' 6 | import * as types from './types' 7 | 8 | export const searchNotion = pMemoize(searchNotionImpl, { maxAge: 10000 }) 9 | 10 | async function searchNotionImpl( 11 | params: types.SearchParams 12 | ): Promise { 13 | return fetch(api.searchNotion, { 14 | method: 'POST', 15 | body: JSON.stringify(params), 16 | headers: { 17 | 'content-type': 'application/json' 18 | } 19 | }) 20 | .then((res) => { 21 | console.log(res) 22 | 23 | if (res.ok) { 24 | return res 25 | } 26 | 27 | // convert non-2xx HTTP responses into errors 28 | const error: any = new Error(res.statusText) 29 | error.response = res 30 | return Promise.reject(error) 31 | }) 32 | .then((res) => res.json()) 33 | 34 | // return ky 35 | // .post(api.searchNotion, { 36 | // json: params 37 | // }) 38 | // .json() 39 | } 40 | -------------------------------------------------------------------------------- /components/PageActions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IoHeartOutline } from 'react-icons/io5' 3 | import { AiOutlineRetweet } from 'react-icons/ai' 4 | 5 | import styles from './styles.module.css' 6 | 7 | /** 8 | * @see https://developer.twitter.com/en/docs/twitter-for-websites/web-intents/overview 9 | */ 10 | export const PageActions: React.FC<{ tweet: string }> = ({ tweet }) => { 11 | return ( 12 |
13 | 20 | 21 | 22 | 23 | 30 | 31 | 32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Travis Fischer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/PageHead.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import * as React from 'react' 3 | import * as types from 'lib/types' 4 | 5 | // TODO: remove duplication between PageHead and NotionPage Head 6 | 7 | export const PageHead: React.FC = ({ site }) => { 8 | return ( 9 | 10 | 11 | 12 | 16 | 17 | {site?.description && ( 18 | <> 19 | 20 | 21 | 22 | )} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /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/Page404.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import * as React from 'react' 3 | import * as types from 'lib/types' 4 | import { PageHead } from './PageHead' 5 | 6 | import styles from './styles.module.css' 7 | 8 | export const Page404: React.FC = ({ site, pageId, error }) => { 9 | const title = site?.name || 'Notion Page Not Found' 10 | 11 | return ( 12 | <> 13 | 14 | 15 | 16 | 17 | 18 | 19 | {title} 20 | 21 | 22 |
23 |
24 |

Notion Page Not Found

25 | 26 | {error ? ( 27 |

{error.message}

28 | ) : ( 29 | pageId && ( 30 |

31 | Make sure that Notion page "{pageId}" is publicly accessible. 32 |

33 | ) 34 | )} 35 | 36 | 404 Not Found 41 |
42 |
43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /lib/get-config-value.ts: -------------------------------------------------------------------------------- 1 | import rawSiteConfig from '../site.config' 2 | 3 | if (!rawSiteConfig) { 4 | throw new Error(`Config error: invalid site.config.js`) 5 | } 6 | 7 | // TODO: allow environment variables to override site.config.js 8 | let siteConfigOverrides 9 | 10 | try { 11 | if (process.env.NEXT_PUBLIC_SITE_CONFIG) { 12 | siteConfigOverrides = JSON.parse(process.env.NEXT_PUBLIC_SITE_CONFIG) 13 | } 14 | } catch (err) { 15 | console.error('Invalid config "NEXT_PUBLIC_SITE_CONFIG" failed to parse') 16 | throw err 17 | } 18 | 19 | const siteConfig = { 20 | ...rawSiteConfig, 21 | ...siteConfigOverrides 22 | } 23 | 24 | export function getSiteConfig(key: string, defaultValue?: T): T { 25 | const value = siteConfig[key] 26 | 27 | if (value !== undefined) { 28 | return value 29 | } 30 | 31 | if (defaultValue !== undefined) { 32 | return defaultValue 33 | } 34 | 35 | throw new Error(`Config error: missing required site config value "${key}"`) 36 | } 37 | 38 | export function getEnv( 39 | key: string, 40 | defaultValue?: string, 41 | env = process.env 42 | ): string { 43 | const value = env[key] 44 | 45 | if (value !== undefined) { 46 | return value 47 | } 48 | 49 | if (defaultValue !== undefined) { 50 | return defaultValue 51 | } 52 | 53 | throw new Error(`Config error: missing required env variable "${key}"`) 54 | } 55 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/acl.ts: -------------------------------------------------------------------------------- 1 | import { PageProps } from './types' 2 | 3 | export async function pageAcl({ 4 | site, 5 | recordMap, 6 | pageId 7 | }: PageProps): Promise { 8 | if (!site) { 9 | return { 10 | error: { 11 | statusCode: 404, 12 | message: 'Unable to resolve notion site' 13 | } 14 | } 15 | } 16 | 17 | if (!recordMap) { 18 | return { 19 | error: { 20 | statusCode: 404, 21 | message: `Unable to resolve page for domain "${site.domain}". Notion page "${pageId}" not found.` 22 | } 23 | } 24 | } 25 | 26 | const keys = Object.keys(recordMap.block) 27 | const rootKey = keys[0] 28 | 29 | if (!rootKey) { 30 | return { 31 | error: { 32 | statusCode: 404, 33 | message: `Unable to resolve page for domain "${site.domain}". Notion page "${pageId}" invalid data.` 34 | } 35 | } 36 | } 37 | 38 | const rootValue = recordMap.block[rootKey]?.value 39 | const rootSpaceId = rootValue?.space_id 40 | 41 | if ( 42 | rootSpaceId && 43 | site.rootNotionSpaceId && 44 | rootSpaceId !== site.rootNotionSpaceId 45 | ) { 46 | if (process.env.NODE_ENV) { 47 | return { 48 | error: { 49 | statusCode: 404, 50 | message: `Notion page "${pageId}" doesn't belong to the Notion workspace owned by "${site.domain}".` 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/map-page-url.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedRecordMap } from 'notion-types' 2 | import { uuidToId, parsePageId } from 'notion-utils' 3 | 4 | import { Site } from './types' 5 | import { includeNotionIdInUrls } from './config' 6 | import { getCanonicalPageId } from './get-canonical-page-id' 7 | 8 | // include UUIDs in page URLs during local development but not in production 9 | // (they're nice for debugging and speed up local dev) 10 | const uuid = !!includeNotionIdInUrls 11 | 12 | export const mapPageUrl = 13 | (site: Site, recordMap: ExtendedRecordMap, searchParams: URLSearchParams) => 14 | (pageId = '') => { 15 | if (uuidToId(pageId) === site.rootNotionPageId) { 16 | return createUrl('/', searchParams) 17 | } else { 18 | return createUrl( 19 | `/${getCanonicalPageId(pageId, recordMap, { uuid })}`, 20 | searchParams 21 | ) 22 | } 23 | } 24 | 25 | export const getCanonicalPageUrl = 26 | (site: Site, recordMap: ExtendedRecordMap) => 27 | (pageId = '') => { 28 | const pageUuid = parsePageId(pageId, { uuid: true }) 29 | 30 | if (uuidToId(pageId) === site.rootNotionPageId) { 31 | return `https://${site.domain}` 32 | } else { 33 | return `https://${site.domain}/${getCanonicalPageId(pageUuid, recordMap, { 34 | uuid 35 | })}` 36 | } 37 | } 38 | 39 | function createUrl(path: string, searchParams: URLSearchParams) { 40 | return [path, searchParams.toString()].filter(Boolean).join('?') 41 | } -------------------------------------------------------------------------------- /lib/get-all-pages.ts: -------------------------------------------------------------------------------- 1 | import pMemoize from 'p-memoize' 2 | import { getAllPagesInSpace } from 'notion-utils' 3 | 4 | import * as types from './types' 5 | import { includeNotionIdInUrls } from './config' 6 | import { notion } from './notion' 7 | import { getCanonicalPageId } from './get-canonical-page-id' 8 | 9 | const uuid = !!includeNotionIdInUrls 10 | 11 | export const getAllPages = pMemoize(getAllPagesImpl, { maxAge: 60000 * 5 }) 12 | 13 | export async function getAllPagesImpl( 14 | rootNotionPageId: string, 15 | rootNotionSpaceId: string 16 | ): Promise> { 17 | const pageMap = await getAllPagesInSpace( 18 | rootNotionPageId, 19 | rootNotionSpaceId, 20 | notion.getPage.bind(notion) 21 | ) 22 | 23 | const canonicalPageMap = Object.keys(pageMap).reduce( 24 | (map, pageId: string) => { 25 | const recordMap = pageMap[pageId] 26 | if (!recordMap) { 27 | throw new Error(`Error loading page "${pageId}"`) 28 | } 29 | 30 | const canonicalPageId = getCanonicalPageId(pageId, recordMap, { 31 | uuid 32 | }) 33 | 34 | if (map[canonicalPageId]) { 35 | console.error( 36 | 'error duplicate canonical page id', 37 | canonicalPageId, 38 | pageId, 39 | map[canonicalPageId] 40 | ) 41 | 42 | return map 43 | } else { 44 | return { 45 | ...map, 46 | [canonicalPageId]: pageId 47 | } 48 | } 49 | }, 50 | {} 51 | ) 52 | 53 | return { 54 | pageMap, 55 | canonicalPageMap 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pages/[pageId].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { isDev, domain } from 'lib/config' 3 | import { getSiteMaps } from 'lib/get-site-maps' 4 | import { resolveNotionPage } from 'lib/resolve-notion-page' 5 | import { NotionPage } from 'components' 6 | 7 | export const getStaticProps = async (context) => { 8 | const rawPageId = context.params.pageId as string 9 | 10 | try { 11 | if (rawPageId === 'sitemap.xml' || rawPageId === 'robots.txt') { 12 | return { 13 | redirect: { 14 | destination: `/api/${rawPageId}` 15 | } 16 | } 17 | } 18 | 19 | const props = await resolveNotionPage(domain, rawPageId) 20 | 21 | return { props, revalidate: 10 } 22 | } catch (err) { 23 | console.error('page error', domain, rawPageId, err) 24 | 25 | // we don't want to publish the error version of this page, so 26 | // let next.js know explicitly that incremental SSG failed 27 | throw err 28 | } 29 | } 30 | 31 | export async function getStaticPaths() { 32 | if (isDev) { 33 | return { 34 | paths: [], 35 | fallback: true 36 | } 37 | } 38 | 39 | const siteMaps = await getSiteMaps() 40 | 41 | const ret = { 42 | paths: siteMaps.flatMap((siteMap) => 43 | Object.keys(siteMap.canonicalPageMap).map((pageId) => ({ 44 | params: { 45 | pageId 46 | } 47 | })) 48 | ), 49 | // paths: [], 50 | fallback: true 51 | } 52 | 53 | console.log(ret.paths) 54 | return ret 55 | } 56 | 57 | export default function NotionDomainDynamicPage(props) { 58 | return 59 | } 60 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Document, { Html, Head, Main, NextScript } from 'next/document' 3 | import { IconContext } from 'react-icons' 4 | 5 | export default class MyDocument extends Document { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 18 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 41 |