├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── example ├── next-env.d.ts ├── package.json ├── pages │ ├── [pageId].tsx │ ├── _app.tsx │ └── index.tsx ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── block.tsx ├── components │ ├── asset.tsx │ ├── code.tsx │ ├── page-header.tsx │ └── page-icon.tsx ├── index.tsx ├── renderer.tsx ├── styles.css ├── types.ts └── utils.ts ├── tsconfig.json ├── tsdx.config.js └── yarn.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: borales/actions-yarn@v2.0.0 10 | with: 11 | cmd: install 12 | - uses: borales/actions-yarn@v2.0.0 13 | with: 14 | cmd: build 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .next -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tobias Lins 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![react-notion](https://user-images.githubusercontent.com/1440854/79684011-6c948280-822e-11ea-9e23-1644903796fb.png) 2 | 3 | ![npm version](https://badgen.net/npm/v/react-notion) ![npm version](https://badgen.net/david/dep/splitbee/react-notion) ![minzipped sized](https://badgen.net/bundlephobia/minzip/react-notion) 4 | 5 | A React renderer for Notion pages. 6 | Use Notion as CMS for your blog, documentation or personal site. 7 | 8 | **`react-notion` was developed by Splitbee. Splitbee is a fast, reliable, free, and modern analytics for any team.** 9 | 10 | _This package doesn't handle the communication with the API. Check out [notion-api-worker](https://github.com/splitbee/notion-api-worker) for an easy solution_. 11 | 12 | Created by Timo Lins & Tobias Lins with the help of all contributors ❤️ 13 | 14 | 15 | 16 | ## Features 17 | 18 | ⚡️ **Fast** – Up to 10x faster than Notion\* 19 | 20 | 🎯 **Accurate** – Results are _almost_ identical 21 | 22 | 🔮 **Code Highlighting** – Automatic code highlighting with [prismjs](https://prismjs.com/) 23 | 24 | 🎨 **Custom Styles** – Styles are easily adaptable. Optional styles included 25 | 26 | _\* First Meaningful Paint compared to a [hosted example](http://react-notion-example.now.sh/) on [Vercel](https://vercel.com)._ 27 | 28 | 29 | **react-notion** is best suited as minimal renderer for blogs & content pages. If you're looking for a full-featured solution to render Notion-like pages, check out [react-notion-x](https://github.com/NotionX/react-notion-x). 30 | 31 | 32 | ## Install 33 | 34 | ```bash 35 | npm install react-notion 36 | ``` 37 | 38 | ## How to use 39 | 40 | ### Minimal Example 41 | 42 | We can store the API response in a `.json` file and import it. 43 | 44 | ```js 45 | import "react-notion/src/styles.css"; 46 | import "prismjs/themes/prism-tomorrow.css"; // only needed for code highlighting 47 | import { NotionRenderer } from "react-notion"; 48 | 49 | import response from "./load-page-chunk-response.json"; // https://www.notion.so/api/v3/loadPageChunk 50 | 51 | const blockMap = response.recordMap.block; 52 | 53 | export default () => ( 54 |
55 | 56 |
57 | ); 58 | ``` 59 | 60 | A working example can be found inside the `example` directory. 61 | 62 | ### Next.js Example 63 | 64 | In this example we use [Next.js](https://github.com/zeit/next.js) for SSG. We use [notion-api-worker](https://github.com/splitbee/notion-api-worker) to fetch data from the API. 65 | 66 | `/pages/my-post.jsx` 67 | 68 | ```js 69 | import "react-notion/src/styles.css"; 70 | import "prismjs/themes/prism-tomorrow.css"; 71 | 72 | import { NotionRenderer } from "react-notion"; 73 | 74 | export async function getStaticProps() { 75 | const data = await fetch( 76 | "https://notion-api.splitbee.io/v1/page/" 77 | ).then(res => res.json()); 78 | 79 | return { 80 | props: { 81 | blockMap: data 82 | } 83 | }; 84 | } 85 | 86 | export default ({ blockMap }) => ( 87 |
88 | 89 |
90 | ); 91 | ``` 92 | 93 | ## Sites using react-notion 94 | 95 | List of pages that implement this library. 96 | 97 | - [Splitbee Blog](https://splitbee.io/blog) 98 | - [PS Tunnel](https://pstunnel.com/blog) 99 | - [timo.sh](https://timo.sh) – _[Source](https://github.com/timolins/timo-sh)_ 100 | 101 | ## Supported Blocks 102 | 103 | Most common block types are supported. We happily accept pull requests to add support for the missing blocks. 104 | 105 | | Block Type | Supported | Notes | 106 | | ----------------- | ---------- | ------------------------------------------------------------------------------------- | 107 | | Text | ✅ Yes | | 108 | | Heading | ✅ Yes | | 109 | | Image | ✅ Yes | | 110 | | Image Caption | ✅ Yes | | 111 | | Bulleted List | ✅ Yes | | 112 | | Numbered List | ✅ Yes | | 113 | | Quote | ✅ Yes | | 114 | | Callout | ✅ Yes | | 115 | | Column | ✅ Yes | | 116 | | iframe | ✅ Yes | | 117 | | Video | ✅ Yes | Only embedded videos | 118 | | Divider | ✅ Yes | | 119 | | Link | ✅ Yes | | 120 | | Code | ✅ Yes | | 121 | | Web Bookmark | ✅ Yes | | 122 | | Toggle List | ✅ Yes | | 123 | | Page Links | ✅ Yes | | 124 | | Header | ✅ Yes | Enable with `fullPage` | 125 | | Databases | ❌ Missing | Not planned. Supported by [react-notion-x](https://github.com/NotionX/react-notion-x) | 126 | | Checkbox | ❌ Missing | Supported by [react-notion-x](https://github.com/NotionX/react-notion-x) | 127 | | Table Of Contents | ❌ Missing | Supported by [react-notion-x](https://github.com/NotionX/react-notion-x) | 128 | 129 | ## Block Type Specific Caveats 130 | 131 | When using a code block in your Notion page, `NotionRenderer` will use `prismjs` to detect the language of the code block. 132 | By default in most project, `prismjs` won't include all language packages in the minified build of your project. 133 | This tends to be an issue for those using `react-notion` in a `next.js` project. 134 | To ensure the programming language is correctly highlighted in production builds, one should explicitly imported into the project. 135 | 136 | ```jsx 137 | import 'prismjs/components/prism-{language}'; 138 | ``` 139 | 140 | ## Credits 141 | 142 | - [Tobias Lins](https://tobi.sh) – Idea, Code 143 | - [Timo Lins](https://timo.sh) – Code, Documentation 144 | - [samwightt](https://github.com/samwightt) – Inspiration & API Typings 145 | - [All people that contributed 💕](https://github.com/splitbee/react-notion/graphs/contributors) 146 | -------------------------------------------------------------------------------- /example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-notion-example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Tobias Lins", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "next", 9 | "build": "next build", 10 | "start": "next start" 11 | }, 12 | "dependencies": { 13 | "next": "^9.5.4", 14 | "node-fetch": "^2.6.0", 15 | "react": "^16.13.1", 16 | "react-dom": "^16.13.1", 17 | "react-notion": "^0.9.3" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^13.13.0", 21 | "@types/react": "^16.9.34", 22 | "typescript": "^3.8.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/pages/[pageId].tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NotionRenderer, BlockMapType } from "react-notion"; 3 | import Head from "next/head"; 4 | import Link from "next/link"; 5 | import fetch from "node-fetch"; 6 | 7 | export async function getServerSideProps(context) { 8 | const pageId = context.params?.pageId; 9 | 10 | if (!pageId) { 11 | return; 12 | } 13 | 14 | const data: BlockMapType = await fetch( 15 | `https://notion-api.splitbee.io/v1/page/${pageId}` 16 | ).then(res => res.json()); 17 | 18 | return { 19 | props: { 20 | blockMap: data 21 | } 22 | }; 23 | } 24 | 25 | const NotionPage = ({ blockMap }) => { 26 | if (!blockMap || Object.keys(blockMap).length === 0) { 27 | return ( 28 |
29 |

No data found.

30 |
Make sure the pageId is valid.
31 |
Only public pages are supported in this example.
32 |
33 | ); 34 | } 35 | 36 | const title = 37 | blockMap[Object.keys(blockMap)[0]]?.value.properties.title[0][0]; 38 | 39 | return ( 40 | <> 41 | 42 | {title} 43 | 44 | ( 49 | {renderComponent()} 50 | ) 51 | }} 52 | /> 53 | 62 | 63 | ); 64 | }; 65 | 66 | export default NotionPage; 67 | -------------------------------------------------------------------------------- /example/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "react-notion/src/styles.css"; 2 | import "prismjs/themes/prism-tomorrow.css"; 3 | import React from "react"; 4 | 5 | export default function App({ Component, pageProps }) { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /example/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { NotionRenderer, BlockMapType } from "react-notion"; 2 | import Head from "next/head"; 3 | import Link from "next/link"; 4 | import fetch from "node-fetch"; 5 | 6 | export async function getStaticProps() { 7 | const data: BlockMapType = await fetch( 8 | "https://notion-api.splitbee.io/v1/page/2e22de6b770e4166be301490f6ffd420" 9 | ).then(res => res.json()); 10 | 11 | return { 12 | props: { 13 | blockMap: data 14 | }, 15 | revalidate: 1 16 | }; 17 | } 18 | 19 | const Home = ({ blockMap }) => ( 20 |
21 | 22 | 23 | react-notion example 24 | 25 | ( 31 | {renderComponent()} 32 | ) 33 | }} 34 | /> 35 |
36 | ); 37 | 38 | export default Home; 39 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "exclude": [ 22 | "node_modules" 23 | ], 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.10.0", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test --passWithNoTests", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build" 19 | }, 20 | "author": { 21 | "name": "Tobias Lins", 22 | "email": "me@tobi.sh", 23 | "url": "https://tobi.sh" 24 | }, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "tsdx lint" 28 | } 29 | }, 30 | "types": "./dist/types.d.ts", 31 | "prettier": { 32 | "arrowParens": "avoid", 33 | "trailingComma": "none" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/splitbee/react-notion/issues" 37 | }, 38 | "homepage": "https://github.com/splitbee/react-notion#readme", 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/splitbee/react-notion.git" 42 | }, 43 | "name": "react-notion", 44 | "module": "dist/react-notion.esm.js", 45 | "peerDependencies": { 46 | "react": ">=16" 47 | }, 48 | "devDependencies": { 49 | "@types/prismjs": "^1.16.1", 50 | "@types/react": "^16.9.43", 51 | "@types/react-dom": "^16.9.8", 52 | "husky": "^4.2.5", 53 | "react": "^16.13.1", 54 | "react-dom": "^16.13.1", 55 | "rollup-plugin-copy": "^3.3.0", 56 | "tsdx": "^0.13.2", 57 | "tslib": "^2.0.0", 58 | "typescript": "^3.9.7" 59 | }, 60 | "dependencies": { 61 | "prismjs": "^1.25.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/block.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | DecorationType, 4 | BlockType, 5 | ContentValueType, 6 | BlockMapType, 7 | MapPageUrl, 8 | MapImageUrl, 9 | CustomBlockComponents, 10 | BlockValueProp, 11 | CustomDecoratorComponents, 12 | CustomDecoratorComponentProps 13 | } from "./types"; 14 | import Asset from "./components/asset"; 15 | import Code from "./components/code"; 16 | import PageIcon from "./components/page-icon"; 17 | import PageHeader from "./components/page-header"; 18 | import { classNames, getTextContent, getListNumber } from "./utils"; 19 | 20 | export const createRenderChildText = ( 21 | customDecoratorComponents?: CustomDecoratorComponents 22 | ) => (properties: DecorationType[]) => { 23 | return properties?.map(([text, decorations], i) => { 24 | if (!decorations) { 25 | return {text}; 26 | } 27 | 28 | return decorations.reduceRight((element, decorator) => { 29 | const renderText = () => { 30 | switch (decorator[0]) { 31 | case "h": 32 | return ( 33 | 34 | {element} 35 | 36 | ); 37 | case "c": 38 | return ( 39 | 40 | {element} 41 | 42 | ); 43 | case "b": 44 | return {element}; 45 | case "i": 46 | return {element}; 47 | case "s": 48 | return {element}; 49 | case "a": 50 | return ( 51 | 52 | {element} 53 | 54 | ); 55 | 56 | default: 57 | return {element}; 58 | } 59 | }; 60 | 61 | const CustomComponent = customDecoratorComponents?.[decorator[0]]; 62 | 63 | if (CustomComponent) { 64 | const props = (decorator[1] 65 | ? { 66 | decoratorValue: decorator[1] 67 | } 68 | : {}) as CustomDecoratorComponentProps; 69 | 70 | return ( 71 | 76 | {text} 77 | 78 | ); 79 | } 80 | 81 | return renderText(); 82 | }, <>{text}); 83 | }); 84 | }; 85 | 86 | interface Block { 87 | block: BlockType; 88 | level: number; 89 | blockMap: BlockMapType; 90 | mapPageUrl: MapPageUrl; 91 | mapImageUrl: MapImageUrl; 92 | 93 | fullPage?: boolean; 94 | hideHeader?: boolean; 95 | customBlockComponents?: CustomBlockComponents; 96 | customDecoratorComponents?: CustomDecoratorComponents; 97 | } 98 | 99 | export const Block: React.FC = props => { 100 | const { 101 | block, 102 | children, 103 | level, 104 | fullPage, 105 | hideHeader, 106 | blockMap, 107 | mapPageUrl, 108 | mapImageUrl, 109 | customBlockComponents, 110 | customDecoratorComponents 111 | } = props; 112 | const blockValue = block?.value; 113 | 114 | const renderComponent = () => { 115 | const renderChildText = createRenderChildText(customDecoratorComponents); 116 | 117 | switch (blockValue?.type) { 118 | case "page": 119 | if (level === 0) { 120 | if (fullPage) { 121 | if (!blockValue.properties) { 122 | return null; 123 | } 124 | 125 | const { 126 | page_icon, 127 | page_cover, 128 | page_cover_position, 129 | page_full_width, 130 | page_small_text 131 | } = blockValue.format || {}; 132 | 133 | const coverPosition = (1 - (page_cover_position || 0.5)) * 100; 134 | 135 | return ( 136 |
137 | {!hideHeader && ( 138 | 143 | )} 144 | {page_cover && ( 145 | {getTextContent(blockValue.properties.title)} 153 | )} 154 |
162 | {page_icon && ( 163 | 171 | )} 172 | 173 |
174 | {renderChildText(blockValue.properties.title)} 175 |
176 | 177 | {children} 178 |
179 |
180 | ); 181 | } else { 182 | return
{children}
; 183 | } 184 | } else { 185 | if (!blockValue.properties) return null; 186 | return ( 187 | 188 | {blockValue.format && ( 189 |
190 | 191 |
192 | )} 193 |
194 | {renderChildText(blockValue.properties.title)} 195 |
196 |
197 | ); 198 | } 199 | case "header": 200 | if (!blockValue.properties) return null; 201 | return ( 202 |

203 | {renderChildText(blockValue.properties.title)} 204 |

205 | ); 206 | case "sub_header": 207 | if (!blockValue.properties) return null; 208 | return ( 209 |

210 | {renderChildText(blockValue.properties.title)} 211 |

212 | ); 213 | case "sub_sub_header": 214 | if (!blockValue.properties) return null; 215 | return ( 216 |

217 | {renderChildText(blockValue.properties.title)} 218 |

219 | ); 220 | case "divider": 221 | return
; 222 | case "text": 223 | if (!blockValue.properties) { 224 | return
 
; 225 | } 226 | const blockColor = blockValue.format?.block_color; 227 | return ( 228 |

234 | {renderChildText(blockValue.properties.title)} 235 |

236 | ); 237 | case "bulleted_list": 238 | case "numbered_list": 239 | const wrapList = (content: React.ReactNode, start?: number) => 240 | blockValue.type === "bulleted_list" ? ( 241 |
    {content}
242 | ) : ( 243 |
    244 | {content} 245 |
246 | ); 247 | 248 | let output: JSX.Element | null = null; 249 | 250 | if (blockValue.content) { 251 | output = ( 252 | <> 253 | {blockValue.properties && ( 254 |
  • {renderChildText(blockValue.properties.title)}
  • 255 | )} 256 | {wrapList(children)} 257 | 258 | ); 259 | } else { 260 | output = blockValue.properties ? ( 261 |
  • {renderChildText(blockValue.properties.title)}
  • 262 | ) : null; 263 | } 264 | 265 | const isTopLevel = 266 | block.value.type !== blockMap[block.value.parent_id].value.type; 267 | const start = getListNumber(blockValue.id, blockMap); 268 | 269 | return isTopLevel ? wrapList(output, start) : output; 270 | case "to_do": 271 | /** 272 | * There are only 3 possible cases when no nested to_dos: 273 | * 1. properties: {title: [["test"]], checked: [["No"]]} 274 | * 2. properties: {title: [["test"]], checked: [["Yes"]]} 275 | * 3. properties: {title: [["test"]]} 276 | */ 277 | const checkbox = block.value.properties; 278 | const { id } = block.value; 279 | 280 | // remove other styles in to-do. 281 | const label: string = checkbox?.title 282 | .flat(1) // only flatten the first level 283 | .filter((ele: string | Array) => typeof ele === "string") 284 | .join(""); 285 | 286 | const isChecked = 287 | checkbox?.checked && checkbox?.checked[0][0] === "Yes"; 288 | 289 | return ( 290 |
    291 | 298 | 299 |
    300 | ); 301 | case "image": 302 | case "embed": 303 | case "figma": 304 | case "video": 305 | const value = block.value as ContentValueType; 306 | 307 | return ( 308 |
    316 | 317 | 318 | {value.properties.caption && ( 319 |
    320 | {renderChildText(value.properties.caption)} 321 |
    322 | )} 323 |
    324 | ); 325 | case "code": { 326 | if (blockValue.properties.title) { 327 | const content = blockValue.properties.title[0][0]; 328 | const language = blockValue.properties.language[0][0]; 329 | return ( 330 | 335 | ); 336 | } 337 | break; 338 | } 339 | case "column_list": 340 | return
    {children}
    ; 341 | case "column": 342 | const spacerWith = 46; 343 | const ratio = blockValue.format.column_ratio; 344 | const columns = Number((1 / ratio).toFixed(0)); 345 | const spacerTotalWith = (columns - 1) * spacerWith; 346 | const width = `calc((100% - ${spacerTotalWith}px) * ${ratio})`; 347 | return ( 348 | <> 349 |
    350 | {children} 351 |
    352 |
    353 | 354 | ); 355 | case "quote": 356 | if (!blockValue.properties) return null; 357 | return ( 358 |
    359 | {renderChildText(blockValue.properties.title)} 360 |
    361 | ); 362 | case "collection_view": 363 | if (!block) return null; 364 | 365 | const collectionView = block?.collection?.types[0]; 366 | 367 | return ( 368 |
    369 |

    370 | {renderChildText(block.collection?.title!)} 371 |

    372 | 373 | {collectionView?.type === "table" && ( 374 |
    375 | 376 | 377 | 378 | {collectionView.format?.table_properties 379 | ?.filter(p => p.visible) 380 | .map((gp, index) => ( 381 | 388 | ))} 389 | 390 | 391 | 392 | 393 | {block?.collection?.data.map((row, index) => ( 394 | 395 | {collectionView.format?.table_properties 396 | ?.filter(p => p.visible) 397 | .map((gp, index) => ( 398 | 413 | ))} 414 | 415 | ))} 416 | 417 |
    386 | {block.collection?.schema[gp.property]?.name} 387 |
    405 | { 406 | renderChildText( 407 | row[ 408 | block.collection?.schema[gp.property]?.name! 409 | ] 410 | )! 411 | } 412 |
    418 |
    419 | )} 420 | 421 | {collectionView?.type === "gallery" && ( 422 |
    423 | {block.collection?.data.map((row, i) => ( 424 |
    425 |
    426 | {collectionView.format?.gallery_properties 427 | ?.filter(p => p.visible) 428 | .map((gp, idx) => ( 429 |

    436 | {getTextContent( 437 | row[block.collection?.schema[gp.property].name!] 438 | )} 439 |

    440 | ))} 441 |
    442 |
    443 | ))} 444 |
    445 | )} 446 |
    447 | ); 448 | case "callout": 449 | return ( 450 |
    459 |
    460 | 461 |
    462 |
    463 | {renderChildText(blockValue.properties.title)} 464 |
    465 |
    466 | ); 467 | case "bookmark": 468 | const link = blockValue.properties.link; 469 | const title = blockValue.properties.title ?? link; 470 | const description = blockValue.properties.description; 471 | const block_color = blockValue.format?.block_color; 472 | const bookmark_icon = blockValue.format?.bookmark_icon; 473 | const bookmark_cover = blockValue.format?.bookmark_cover; 474 | 475 | return ( 476 | 510 | ); 511 | case "toggle": 512 | return ( 513 |
    514 | {renderChildText(blockValue.properties.title)} 515 |
    {children}
    516 |
    517 | ); 518 | default: 519 | if (process.env.NODE_ENV !== "production") { 520 | console.log("Unsupported type " + block?.value?.type); 521 | } 522 | return
    ; 523 | } 524 | return null; 525 | }; 526 | 527 | // render a custom component first if passed. 528 | if ( 529 | customBlockComponents && 530 | customBlockComponents[blockValue?.type] && 531 | // Do not use custom component for base page block 532 | level !== 0 533 | ) { 534 | const CustomComponent = customBlockComponents[blockValue?.type]!; 535 | return ( 536 | } 540 | level={level} 541 | > 542 | {children} 543 | 544 | ); 545 | } 546 | 547 | return renderComponent(); 548 | }; 549 | -------------------------------------------------------------------------------- /src/components/asset.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { BlockType, ContentValueType, MapImageUrl } from "../types"; 3 | 4 | const types = ["video", "image", "embed", "figma"]; 5 | 6 | const Asset: React.FC<{ 7 | block: BlockType; 8 | mapImageUrl: MapImageUrl; 9 | }> = ({ block, mapImageUrl }) => { 10 | const value = block.value as ContentValueType; 11 | const type = block.value.type; 12 | 13 | if (!types.includes(type)) { 14 | return null; 15 | } 16 | 17 | const format = value.format; 18 | const { 19 | display_source = undefined, 20 | block_aspect_ratio = undefined, 21 | block_height = 1, 22 | block_width = 1 23 | } = format ?? {}; 24 | 25 | const aspectRatio = block_aspect_ratio || block_height / block_width; 26 | 27 | if (type === "embed" || type === "video" || type === "figma") { 28 | return ( 29 |
    35 |