├── .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 | 
2 |
3 |   
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 |
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 |
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 |
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 | {label}
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 |
386 | {block.collection?.schema[gp.property]?.name}
387 |
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 |
405 | {
406 | renderChildText(
407 | row[
408 | block.collection?.schema[gp.property]?.name!
409 | ]
410 | )!
411 | }
412 |
413 | ))}
414 |
415 | ))}
416 |
417 |
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 |
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 |
41 |
42 | );
43 | }
44 |
45 | if (block.value.type === "image") {
46 | const src = mapImageUrl(value.properties.source[0][0], block);
47 | const caption = value.properties.caption?.[0][0];
48 | const altText = value.properties.alt_text?.[0][0];
49 |
50 | if (block_aspect_ratio) {
51 | return (
52 |
58 |
63 |
64 | );
65 | } else {
66 | return ;
67 | }
68 | }
69 |
70 | return null;
71 | };
72 |
73 | export default Asset;
74 |
--------------------------------------------------------------------------------
/src/components/code.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { highlight, languages } from "prismjs";
3 | import "prismjs/components/prism-jsx";
4 |
5 | const Code: React.FC<{ code: string; language: string }> = ({
6 | code,
7 | language = "javascript"
8 | }) => {
9 | const languageL = language.toLowerCase();
10 | const prismLanguage = languages[languageL] || languages.javascript;
11 |
12 | const langClass = `language-${language.toLowerCase()}`;
13 |
14 | return (
15 |
16 |
22 |
23 | );
24 | };
25 |
26 | export default Code;
27 |
--------------------------------------------------------------------------------
/src/components/page-header.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { BlockMapType, MapPageUrl, MapImageUrl } from "../types";
4 | import PageIcon from "./page-icon";
5 |
6 | interface PageHeaderProps {
7 | blockMap: BlockMapType;
8 | mapPageUrl: MapPageUrl;
9 | mapImageUrl: MapImageUrl;
10 | }
11 |
12 | const PageHeader: React.FC = ({
13 | blockMap,
14 | mapPageUrl,
15 | mapImageUrl
16 | }) => {
17 | const blockIds = Object.keys(blockMap);
18 | const activePageId = blockIds[0];
19 |
20 | if (!activePageId) {
21 | return null;
22 | }
23 |
24 | const breadcrumbs = [];
25 | let currentPageId = activePageId;
26 |
27 | do {
28 | const block = blockMap[currentPageId];
29 | if (!block || !block.value) {
30 | break;
31 | }
32 |
33 | const title = block.value.properties?.title[0][0];
34 | const icon = (block.value as any).format?.page_icon;
35 |
36 | if (!(title || icon)) {
37 | break;
38 | }
39 |
40 | breadcrumbs.push({
41 | block,
42 | active: currentPageId === activePageId,
43 | pageId: currentPageId,
44 | title,
45 | icon
46 | });
47 |
48 | const parentId = block.value.parent_id;
49 |
50 | if (!parentId) {
51 | break;
52 | }
53 |
54 | currentPageId = parentId;
55 | } while (true);
56 |
57 | breadcrumbs.reverse();
58 |
59 | return (
60 |
92 | );
93 | };
94 |
95 | export default PageHeader;
96 |
--------------------------------------------------------------------------------
/src/components/page-icon.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import {
3 | BlockType,
4 | PageValueType,
5 | BlockValueType,
6 | CalloutValueType,
7 | MapImageUrl
8 | } from "../types";
9 | import { getTextContent, classNames } from "../utils";
10 |
11 | const isIconBlock = (
12 | value: BlockValueType
13 | ): value is PageValueType | CalloutValueType => {
14 | return value.type === "page" || value.type === "callout";
15 | };
16 |
17 | interface AssetProps {
18 | block: BlockType;
19 | mapImageUrl: MapImageUrl;
20 | big?: boolean;
21 | className?: string;
22 | }
23 |
24 | const PageIcon: React.FC = ({
25 | block,
26 | className,
27 | big,
28 | mapImageUrl
29 | }) => {
30 | if (!isIconBlock(block.value)) {
31 | return null;
32 | }
33 | const icon = block.value.format?.page_icon;
34 | const title = block.value.properties?.title;
35 |
36 | if (icon?.includes("http")) {
37 | const url = mapImageUrl(icon, block);
38 |
39 | return (
40 |
48 | );
49 | } else {
50 | return (
51 |
60 | {icon}
61 |
62 | );
63 | }
64 | };
65 |
66 | export default PageIcon;
67 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | export { NotionRenderer } from "./renderer";
2 | export * from "./types";
3 | export * from "./utils";
4 | export * from "./block";
5 |
--------------------------------------------------------------------------------
/src/renderer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | BlockMapType,
4 | MapPageUrl,
5 | MapImageUrl,
6 | CustomBlockComponents,
7 | CustomDecoratorComponents
8 | } from "./types";
9 | import { Block } from "./block";
10 | import { defaultMapImageUrl, defaultMapPageUrl } from "./utils";
11 |
12 | export interface NotionRendererProps {
13 | blockMap: BlockMapType;
14 | fullPage?: boolean;
15 | hideHeader?: boolean;
16 | mapPageUrl?: MapPageUrl;
17 | mapImageUrl?: MapImageUrl;
18 |
19 | currentId?: string;
20 | level?: number;
21 | customBlockComponents?: CustomBlockComponents;
22 | customDecoratorComponents?: CustomDecoratorComponents;
23 | }
24 |
25 | export const NotionRenderer: React.FC = ({
26 | level = 0,
27 | currentId,
28 | mapPageUrl = defaultMapPageUrl,
29 | mapImageUrl = defaultMapImageUrl,
30 | ...props
31 | }) => {
32 | const { blockMap } = props;
33 | const id = currentId || Object.keys(blockMap)[0];
34 | const currentBlock = blockMap[id];
35 |
36 | if (!currentBlock) {
37 | if (process.env.NODE_ENV !== "production") {
38 | console.warn("error rendering block", currentId);
39 | }
40 | return null;
41 | }
42 |
43 | return (
44 |
52 | {currentBlock?.value?.content?.map(contentId => (
53 |
61 | ))}
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/src/styles.css:
--------------------------------------------------------------------------------
1 | .notion {
2 | font-size: 16px;
3 | line-height: 1.5;
4 | color: rgb(55, 53, 47);
5 | caret-color: rgb(55, 53, 47);
6 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica,
7 | "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol";
8 | }
9 |
10 | .notion > *,
11 | .notion-page > *,
12 | .notion-column > * {
13 | padding: 3px 0px;
14 | }
15 |
16 | .notion * {
17 | box-sizing: border-box;
18 | margin-block-start: 0px;
19 | margin-block-end: 0px;
20 | }
21 |
22 | .notion-red {
23 | color: rgb(224, 62, 62);
24 | }
25 | .notion-pink {
26 | color: rgb(173, 26, 114);
27 | }
28 | .notion-blue {
29 | color: rgb(11, 110, 153);
30 | }
31 | .notion-purple {
32 | color: rgb(105, 64, 165);
33 | }
34 | .notion-teal {
35 | color: rgb(15, 123, 108);
36 | }
37 | .notion-yellow {
38 | color: rgb(223, 171, 1);
39 | }
40 | .notion-orange {
41 | color: rgb(217, 115, 13);
42 | }
43 | .notion-brown {
44 | color: rgb(100, 71, 58);
45 | }
46 | .notion-gray {
47 | color: rgb(155, 154, 151);
48 | }
49 | .notion-red_background {
50 | background-color: rgb(251, 228, 228);
51 | }
52 | .notion-pink_background {
53 | background-color: rgb(244, 223, 235);
54 | }
55 | .notion-blue_background {
56 | background-color: rgb(221, 235, 241);
57 | }
58 | .notion-purple_background {
59 | background-color: rgb(234, 228, 242);
60 | }
61 | .notion-teal_background {
62 | background-color: rgb(221, 237, 234);
63 | }
64 | .notion-yellow_background {
65 | background-color: rgb(251, 243, 219);
66 | }
67 | .notion-orange_background {
68 | background-color: rgb(250, 235, 221);
69 | }
70 | .notion-brown_background {
71 | background-color: rgb(233, 229, 227);
72 | }
73 | .notion-gray_background {
74 | background-color: rgb(235, 236, 237);
75 | }
76 | .notion-red_background_co {
77 | background-color: rgb(251, 228, 228, 0.3);
78 | }
79 | .notion-pink_background_co {
80 | background-color: rgb(244, 223, 235, 0.3);
81 | }
82 | .notion-blue_background_co {
83 | background-color: rgb(221, 235, 241, 0.3);
84 | }
85 | .notion-purple_background_co {
86 | background-color: rgb(234, 228, 242, 0.3);
87 | }
88 | .notion-teal_background_co {
89 | background-color: rgb(221, 237, 234, 0.3);
90 | }
91 | .notion-yellow_background_co {
92 | background-color: rgb(251, 243, 219, 0.3);
93 | }
94 | .notion-orange_background_co {
95 | background-color: rgb(250, 235, 221, 0.3);
96 | }
97 | .notion-brown_background_co {
98 | background-color: rgb(233, 229, 227, 0.3);
99 | }
100 | .notion-gray_background_co {
101 | background-color: rgb(235, 236, 237, 0.3);
102 | }
103 |
104 | .notion b {
105 | font-weight: 600;
106 | }
107 |
108 | .notion-title {
109 | font-size: 2.5em;
110 | font-weight: 700;
111 | margin-top: 0.75em;
112 | margin-bottom: 0.25em;
113 | }
114 |
115 | .notion-h1,
116 | .notion-h2,
117 | .notion-h3 {
118 | font-weight: 600;
119 | line-height: 1.3;
120 | padding: 3px 2px;
121 | }
122 |
123 | .notion-h1 {
124 | font-size: 1.875em;
125 | margin-top: 1.4em;
126 | }
127 | .notion-h1:first-child {
128 | margin-top: 0;
129 | }
130 | .notion-h2 {
131 | font-size: 1.5em;
132 | margin-top: 1.1em;
133 | }
134 | .notion-h3 {
135 | font-size: 1.25em;
136 | margin-top: 1em;
137 | }
138 | .notion-emoji {
139 | font-family: "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji",
140 | "Segoe UI Symbol";
141 | }
142 | .notion-page-cover {
143 | display: block;
144 | object-fit: cover;
145 | width: 100%;
146 | height: 30vh;
147 | min-height: 30vh;
148 | padding: 0;
149 | }
150 |
151 | .notion-page {
152 | padding: 0;
153 | margin: 0 auto;
154 | max-width: 708px;
155 | width: 100%;
156 | }
157 |
158 | @media only screen and (max-width: 730px) {
159 | .notion-page {
160 | padding: 0 2vw;
161 | }
162 | }
163 |
164 | .notion-page-offset {
165 | margin-top: 96px;
166 | }
167 |
168 | span.notion-page-icon-cover {
169 | height: 78px;
170 | width: 78px;
171 | font-size: 78px;
172 | display: inline-block;
173 | line-height: 1.1;
174 | margin-left: 0px;
175 | }
176 |
177 | span.notion-page-icon-offset {
178 | margin-top: -42px;
179 | }
180 |
181 | img.notion-page-icon-cover {
182 | border-radius: 3px;
183 | width: 124px;
184 | height: 124px;
185 | margin: 8px;
186 | }
187 |
188 | img.notion-page-icon-offset {
189 | margin-top: -80px;
190 | }
191 |
192 | .notion-full-width {
193 | padding: 0 40px;
194 | max-width: 100%;
195 | }
196 |
197 | .notion-small-text {
198 | font-size: 14px;
199 | }
200 | .notion-quote {
201 | white-space: pre-wrap;
202 | word-break: break-word;
203 | border-left: 3px solid currentcolor;
204 | padding: 0.2em 0.9em;
205 | margin: 0;
206 | font-size: 1.2em;
207 | }
208 | .notion-hr {
209 | margin: 6px 0px;
210 | padding: 0;
211 | border-top-width: 1px;
212 | border-bottom-width: 0;
213 | border-color: rgba(55, 53, 47, 0.09);
214 | }
215 | .notion-link {
216 | color: inherit;
217 | word-break: break-word;
218 | text-decoration: underline;
219 | text-decoration-color: inherit;
220 | }
221 | .notion-blank {
222 | min-height: 1rem;
223 | padding: 3px 2px;
224 | margin-top: 1px;
225 | margin-bottom: 1px;
226 | }
227 | .notion-page-link {
228 | display: flex;
229 | color: rgb(55, 53, 47);
230 | text-decoration: none;
231 | height: 30px;
232 | margin: 1px 0px;
233 | transition: background 120ms ease-in 0s;
234 | }
235 | .notion-page-link:hover {
236 | background: rgba(55, 53, 47, 0.08);
237 | }
238 |
239 | .notion-page-icon {
240 | line-height: 1.4;
241 | margin-right: 4px;
242 | margin-left: 2px;
243 | }
244 | img.notion-page-icon {
245 | display: block;
246 | object-fit: cover;
247 | border-radius: 3px;
248 | width: 20px;
249 | height: 20px;
250 | }
251 |
252 | .notion-icon {
253 | display: block;
254 | width: 18px;
255 | height: 18px;
256 | color: rgba(55, 53, 47, 0.4);
257 | }
258 |
259 | .notion-page-text {
260 | white-space: nowrap;
261 | overflow: hidden;
262 | text-overflow: ellipsis;
263 | font-weight: 500;
264 | line-height: 1.3;
265 | border-bottom: 1px solid rgba(55, 53, 47, 0.16);
266 | margin: 1px 0px;
267 | }
268 |
269 | .notion-inline-code {
270 | color: #eb5757;
271 | padding: 0.2em 0.4em;
272 | background: rgba(135, 131, 120, 0.15);
273 | border-radius: 3px;
274 | font-size: 85%;
275 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
276 | monospace;
277 | }
278 |
279 | .notion-list {
280 | margin: 0;
281 | margin-block-start: 0.6em;
282 | margin-block-end: 0.6em;
283 | }
284 |
285 | .notion-list-disc {
286 | list-style-type: disc;
287 | padding-inline-start: 1.6em;
288 | margin-top: 0px;
289 | margin-bottom: 0px;
290 | }
291 | .notion-list-numbered {
292 | list-style-type: decimal;
293 | padding-inline-start: 1.6em;
294 | margin-top: 0px;
295 | margin-bottom: 0px;
296 | }
297 |
298 | .notion-list-disc li {
299 | padding-left: 0.1em;
300 | }
301 |
302 | .notion-list-numbered li {
303 | padding-left: 0.2em;
304 | }
305 |
306 | .notion-list li {
307 | padding: 4px 0px;
308 | white-space: pre-wrap;
309 | }
310 |
311 | .notion-list > .notion-text {
312 | margin-left: -1.6em;
313 | padding-left: 0px;
314 | }
315 |
316 | .notion-checkbox {
317 | accent-color: #eb5757;
318 | }
319 |
320 | .notion-asset-wrapper {
321 | margin: 0.5rem auto 0.5rem;
322 | max-width: 100%;
323 | }
324 |
325 | .notion-asset-wrapper > img {
326 | max-width: 100%;
327 | }
328 |
329 | .notion-asset-wrapper iframe {
330 | border: none;
331 | }
332 |
333 | .notion-text {
334 | white-space: pre-wrap;
335 | caret-color: rgb(55, 53, 47);
336 | padding: 3px 2px;
337 | }
338 | .notion-block {
339 | padding: 3px 2px;
340 | }
341 |
342 | .notion .notion-code {
343 | font-size: 85%;
344 | }
345 |
346 | .notion-code {
347 | padding: 30px 16px 30px 20px;
348 | margin: 4px 0;
349 | border-radius: 3px;
350 | tab-size: 2;
351 | display: block;
352 | box-sizing: border-box;
353 | overflow-x: scroll;
354 | background: rgb(247, 246, 243);
355 | font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier,
356 | monospace;
357 | }
358 |
359 | .notion-column {
360 | padding-top: 12px;
361 | padding-bottom: 12px;
362 | }
363 |
364 | .notion-column > *:first-child {
365 | margin-top: 0;
366 | margin-left: 0;
367 | margin-right: 0;
368 | }
369 |
370 | .notion-column > *:last-child {
371 | margin-left: 0;
372 | margin-right: 0;
373 | margin-bottom: 0;
374 | }
375 |
376 | .notion-row {
377 | display: flex;
378 | overflow: hidden;
379 | }
380 |
381 | .notion-bookmark {
382 | margin: 4px 0;
383 | width: 100%;
384 | box-sizing: border-box;
385 | text-decoration: none;
386 | border: 1px solid rgba(55, 53, 47, 0.16);
387 | border-radius: 3px;
388 | display: flex;
389 | overflow: hidden;
390 | user-select: none;
391 | }
392 |
393 | .notion-bookmark > div:first-child {
394 | flex: 4 1 180px;
395 | padding: 12px 14px 14px;
396 | overflow: hidden;
397 | text-align: left;
398 | color: rgb(55, 53, 47);
399 | }
400 |
401 | .notion-bookmark-title {
402 | font-size: 14px;
403 | line-height: 20px;
404 | white-space: nowrap;
405 | overflow: hidden;
406 | text-overflow: ellipsis;
407 | min-height: 24px;
408 | margin-bottom: 2px;
409 | }
410 |
411 | .notion-bookmark-description {
412 | font-size: 12px;
413 | line-height: 16px;
414 | opacity: 0.6;
415 | height: 32px;
416 | overflow: hidden;
417 | }
418 |
419 | .notion-bookmark-link {
420 | display: flex;
421 | margin-top: 6px;
422 | }
423 |
424 | .notion-bookmark-link > img {
425 | width: 16px;
426 | height: 16px;
427 | min-width: 16px;
428 | margin-right: 6px;
429 | }
430 |
431 | .notion-bookmark-link > div {
432 | font-size: 12px;
433 | line-height: 16px;
434 | color: rgb(55, 53, 47);
435 | white-space: nowrap;
436 | overflow: hidden;
437 | text-overflow: ellipsis;
438 | }
439 |
440 | .notion-bookmark-image {
441 | flex: 1 1 180px;
442 | position: relative;
443 | }
444 |
445 | .notion-bookmark-image img {
446 | object-fit: cover;
447 | width: 100%;
448 | height: 100%;
449 | position: absolute;
450 | }
451 |
452 | .notion-column .notion-bookmark-image {
453 | display: none;
454 | }
455 |
456 | @media (max-width: 640px) {
457 | .notion-bookmark-image {
458 | display: none;
459 | }
460 |
461 | .notion-row {
462 | flex-direction: column;
463 | }
464 |
465 | .notion-row > *,
466 | .notion-column > * {
467 | width: 100% !important;
468 | }
469 | }
470 |
471 | .notion-spacer:last-child {
472 | display: none;
473 | }
474 |
475 | .notion-image-inset {
476 | position: absolute;
477 | left: 0;
478 | top: 0;
479 | right: 0;
480 | bottom: 0;
481 | width: 100%;
482 | height: 100%;
483 | border-radius: 1px;
484 | }
485 |
486 | .notion-image-caption {
487 | padding: 6px 0px;
488 | white-space: pre-wrap;
489 | word-break: break-word;
490 | caret-color: rgb(55, 53, 47);
491 | font-size: 14px;
492 | line-height: 1.4;
493 | color: rgba(55, 53, 47, 0.6);
494 | }
495 |
496 | .notion-callout {
497 | padding: 16px 16px 16px 12px;
498 | display: inline-flex;
499 | width: 100%;
500 | border-radius: 3px;
501 | border-width: 1px;
502 | align-items: center;
503 | box-sizing: border-box;
504 | margin: 4px 0;
505 | }
506 | .notion-callout-text {
507 | margin-left: 8px;
508 | white-space: pre-line;
509 | }
510 |
511 | .notion-toggle {
512 | padding: 3px 2px;
513 | }
514 | .notion-toggle > summary {
515 | cursor: pointer;
516 | outline: none;
517 | }
518 | .notion-toggle > div {
519 | margin-left: 1.1em;
520 | }
521 |
522 | .notion-table,
523 | .notion-th,
524 | .notion-td {
525 | border: 1px solid rgba(55, 53, 47, 0.09);
526 | border-collapse: collapse;
527 | }
528 |
529 | .notion-table {
530 | width: 100%;
531 | border-left: none;
532 | border-right: none;
533 | border-spacing: 0px;
534 | white-space: nowrap;
535 | }
536 |
537 | .notion-th,
538 | .notion-td {
539 | font-weight: normal;
540 | padding: 0.25em 0.5em;
541 | line-height: 1.5;
542 | min-height: 1.5em;
543 | text-align: left;
544 | font-size: 14px;
545 | }
546 |
547 | .notion-td.notion-bold {
548 | font-weight: 500;
549 | }
550 |
551 | .notion-th {
552 | color: rgba(55, 53, 47, 0.6);
553 | font-size: 14px;
554 | }
555 |
556 | .notion-td:first-child,
557 | .notion-th:first-child {
558 | border-left: 0;
559 | }
560 |
561 | .notion-td:last-child,
562 | .notion-th:last-child {
563 | border-right: 0;
564 | }
565 |
566 | .notion-gallery {
567 | display: grid;
568 | position: relative;
569 | grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
570 | grid-auto-rows: 1fr;
571 | gap: 16px;
572 | border-top: 1px solid rgba(55, 53, 47, 0.16);
573 | padding-top: 16px;
574 | padding-bottom: 4px;
575 | }
576 | .notion-gallery-card {
577 | display: block;
578 | color: inherit;
579 | text-decoration: none;
580 | box-shadow: rgba(15, 15, 15, 0.1) 0px 0px 0px 1px,
581 | rgba(15, 15, 15, 0.1) 0px 2px 4px;
582 | border-radius: 3px;
583 | background: white;
584 | overflow: hidden;
585 | transition: background 100ms ease-out 0s;
586 | position: static;
587 | height: 100%;
588 | }
589 |
590 | .notion-gallery-content {
591 | padding: 8px 10px 6px;
592 | font-size: 12px;
593 | white-space: nowrap;
594 | }
595 |
596 | .notion-gallery-data.is-first {
597 | white-space: nowrap;
598 | word-break: break-word;
599 | caret-color: rgb(55, 53, 47);
600 | font-size: 14px;
601 | line-height: 1.5;
602 | min-height: 21px;
603 | font-weight: 500;
604 | overflow: hidden;
605 | text-overflow: ellipsis;
606 | }
607 |
608 | .notion-page-header {
609 | position: sticky;
610 | top: 0;
611 | width: 100%;
612 | max-width: 100vw;
613 | height: 45px;
614 | min-height: 45px;
615 | display: flex;
616 | background: #fff;
617 | flex-direction: row;
618 | box-sizing: border-box;
619 | justify-content: space-between;
620 | align-items: center;
621 | padding: 0 12px;
622 | text-size-adjust: 100%;
623 | line-height: 1.5;
624 | line-height: 1.2;
625 | font-size: 14px;
626 | }
627 |
628 | .notion-nav-breadcrumbs {
629 | display: flex;
630 | flex-direction: row;
631 | align-items: center;
632 | height: 100%;
633 | flex-grow: 0;
634 | min-width: 0;
635 | margin-right: 8px;
636 | }
637 |
638 | .notion-nav-breadcrumb {
639 | display: inline-flex;
640 | flex-direction: row;
641 | align-items: center;
642 | justify-content: center;
643 | white-space: nowrap;
644 |
645 | color: rgb(55, 53, 47);
646 | text-decoration: none;
647 | margin: 1px 0px;
648 | padding: 4px 6px;
649 | border-radius: 3px;
650 | transition: background 120ms ease-in 0s;
651 | user-select: none;
652 | background: transparent;
653 | cursor: pointer;
654 | }
655 |
656 | img.notion-nav-icon {
657 | width: 18px !important;
658 | height: 18px !important;
659 | }
660 |
661 | .notion-nav-icon {
662 | font-size: 18px;
663 | margin-right: 6px;
664 | line-height: 1.1;
665 | color: #000;
666 | }
667 |
668 | .notion-nav-breadcrumb:not(.notion-nav-breadcrumb-active):hover {
669 | background: rgba(55, 53, 47, 0.08);
670 | }
671 |
672 | .notion-nav-breadcrumb:not(.notion-nav-breadcrumb-active):active {
673 | background: rgba(55, 53, 47, 0.16);
674 | }
675 |
676 | .notion-nav-breadcrumb.notion-nav-breadcrumb-active {
677 | cursor: default;
678 | }
679 |
680 | .notion-nav-spacer {
681 | margin: 0 2px;
682 | color: rgba(55, 53, 47, 0.4);
683 | }
684 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * Copyright (c) 2020, Sam Wight
4 | * https://github.com/samwightt/chorale-renderer/blob/1e2c1415166e298c1ce72a1a15db927bebf2b5c8/types/notion.ts
5 | *
6 | * Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
7 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
8 | *
9 | */
10 |
11 | import { FC } from "react";
12 |
13 | /**
14 | * Base properties that all blocks share.
15 | */
16 | interface BaseValueType {
17 | id: string;
18 | version: number;
19 | created_time: number;
20 | last_edited_time: number;
21 | parent_id: string;
22 | parent_table: string;
23 | alive: boolean;
24 | created_by_table: string;
25 | created_by_id: string;
26 | last_edited_by_table: string;
27 | last_edited_by_id: string;
28 | space_id?: string;
29 | properties?: any;
30 | content?: string[];
31 | }
32 |
33 | /**
34 | * Colors and backgrounds a given block can have in Notion.
35 | */
36 | export type ColorType =
37 | | "gray"
38 | | "brown"
39 | | "orange"
40 | | "yellow"
41 | | "teal"
42 | | "blue"
43 | | "purple"
44 | | "pink"
45 | | "red"
46 | | "gray_background"
47 | | "brown_background"
48 | | "orange_background"
49 | | "yellow_background"
50 | | "teal_background"
51 | | "blue_background"
52 | | "purple_background"
53 | | "pink_background"
54 | | "red_background";
55 |
56 | type BoldFormatType = ["b"];
57 | type ItalicFormatType = ["i"];
58 | type StrikeFormatType = ["s"];
59 | type CodeFormatType = ["c"];
60 | type LinkFormatType = ["a", string];
61 | type ColorFormatType = ["h", ColorType];
62 | type DateFormatType = [
63 | "d",
64 | {
65 | type: "date";
66 | start_date: string;
67 | date_format: string;
68 | }
69 | ];
70 | type UserFormatType = ["u", string];
71 | type PageFormatType = ["p", string];
72 | type SubDecorationType =
73 | | BoldFormatType
74 | | ItalicFormatType
75 | | StrikeFormatType
76 | | CodeFormatType
77 | | LinkFormatType
78 | | ColorFormatType
79 | | DateFormatType
80 | | UserFormatType
81 | | PageFormatType;
82 | type BaseDecorationType = [string];
83 | type AdditionalDecorationType = [string, SubDecorationType[]];
84 | export type DecorationType = BaseDecorationType | AdditionalDecorationType;
85 |
86 | export interface PageValueType extends BaseValueType {
87 | type: "page";
88 | properties?: {
89 | title: DecorationType[];
90 | };
91 | format: {
92 | page_full_width?: boolean;
93 | page_small_text?: boolean;
94 | page_cover_position?: number;
95 | block_locked?: boolean;
96 | block_locked_by?: string;
97 | page_cover?: string;
98 | page_icon?: string;
99 | };
100 | permissions: { role: string; type: string }[];
101 | file_ids?: string[];
102 | }
103 |
104 | export interface BaseTextValueType extends BaseValueType {
105 | properties?: {
106 | title: DecorationType[];
107 | };
108 | format?: {
109 | block_color: ColorType;
110 | };
111 | }
112 |
113 | interface BookmarkValueType extends BaseValueType {
114 | type: "bookmark";
115 | properties: {
116 | link: DecorationType[];
117 | title?: DecorationType[];
118 | description?: DecorationType[];
119 | };
120 | format?: {
121 | block_color?: string;
122 | bookmark_icon: string;
123 | bookmark_cover: string;
124 | };
125 | }
126 |
127 | interface TextValueType extends BaseTextValueType {
128 | type: "text";
129 | }
130 |
131 | interface BulletedListValueType extends BaseTextValueType {
132 | type: "bulleted_list";
133 | }
134 |
135 | interface NumberedListValueType extends BaseTextValueType {
136 | type: "numbered_list";
137 | }
138 |
139 | interface HeaderValueType extends BaseTextValueType {
140 | type: "header";
141 | }
142 |
143 | interface SubHeaderValueType extends BaseTextValueType {
144 | type: "sub_header";
145 | }
146 |
147 | interface SubSubHeaderValueType extends BaseTextValueType {
148 | type: "sub_sub_header";
149 | }
150 |
151 | interface QuoteValueType extends BaseTextValueType {
152 | type: "quote";
153 | }
154 |
155 | interface TodoValueType extends BaseTextValueType {
156 | type: "to_do";
157 | properties: {
158 | title: DecorationType[];
159 | checked: (["Yes"] | ["No"])[];
160 | };
161 | }
162 |
163 | interface DividerValueType extends BaseValueType {
164 | type: "divider";
165 | }
166 |
167 | interface ColumnListValueType extends BaseValueType {
168 | type: "column_list";
169 | }
170 |
171 | interface ColumnValueType extends BaseValueType {
172 | type: "column";
173 | format: {
174 | column_ratio: number;
175 | };
176 | }
177 |
178 | export interface CalloutValueType extends BaseValueType {
179 | type: "callout";
180 | format: {
181 | page_icon: string;
182 | block_color: ColorType;
183 | };
184 | properties: {
185 | title: DecorationType[];
186 | };
187 | }
188 |
189 | interface ToggleValueType extends BaseValueType {
190 | type: "toggle";
191 | properties: {
192 | title: DecorationType[];
193 | };
194 | }
195 |
196 | export interface ContentValueType extends BaseValueType {
197 | properties: {
198 | source: string[][];
199 | caption?: DecorationType[];
200 | alt_text?: string[][];
201 | };
202 | format?: {
203 | block_width: number;
204 | block_height: number;
205 | display_source: string;
206 | block_full_width: boolean;
207 | block_page_width: boolean;
208 | block_aspect_ratio: number;
209 | block_preserve_scale: boolean;
210 | };
211 | file_ids?: string[];
212 | }
213 |
214 | interface ImageValueType extends ContentValueType {
215 | type: "image";
216 | }
217 | interface EmbedValueType extends ContentValueType {
218 | type: "embed";
219 | }
220 |
221 | interface FigmaValueType extends ContentValueType {
222 | type: "figma";
223 | }
224 |
225 | interface VideoValueType extends ContentValueType {
226 | type: "video";
227 | }
228 |
229 | interface CodeValueType extends BaseValueType {
230 | type: "code";
231 | properties: {
232 | title: DecorationType[];
233 | language: DecorationType[];
234 | };
235 | }
236 | interface CollectionValueType extends ContentValueType {
237 | type: "collection_view";
238 | }
239 |
240 | interface TableGalleryType extends BaseValueType {
241 | type: "gallery";
242 | format: {
243 | gallery_cover: {
244 | type: "page_cover";
245 | };
246 | gallery_cover_aspect: "cover";
247 | gallery_properties: Array<{ visible: boolean; property: string }>;
248 | };
249 | }
250 | interface TableCollectionType extends BaseValueType {
251 | type: "table";
252 | format: {
253 | table_wrap: boolean;
254 | table_properties: Array<{
255 | visible: boolean;
256 | property: string;
257 | width: number;
258 | }>;
259 | };
260 | }
261 |
262 | export interface TweetType extends BaseValueType {
263 | type: "tweet";
264 | properties: {
265 | source: [string[]];
266 | };
267 | }
268 |
269 | export type CollectionViewType = TableGalleryType | TableCollectionType;
270 |
271 | /**
272 | * The different block values a block can have.
273 | */
274 | export type BlockValueType =
275 | | TextValueType
276 | | PageValueType
277 | | BulletedListValueType
278 | | NumberedListValueType
279 | | HeaderValueType
280 | | SubHeaderValueType
281 | | SubSubHeaderValueType
282 | | TodoValueType
283 | | DividerValueType
284 | | ColumnListValueType
285 | | ColumnValueType
286 | | QuoteValueType
287 | | CodeValueType
288 | | ImageValueType
289 | | VideoValueType
290 | | EmbedValueType
291 | | FigmaValueType
292 | | CalloutValueType
293 | | BookmarkValueType
294 | | ToggleValueType
295 | | CollectionValueType
296 | | TweetType;
297 |
298 | export type BlockValueTypeKeys = BlockValueType["type"];
299 |
300 | export interface BlockType {
301 | role: string;
302 | value: BlockValueType;
303 | collection?: {
304 | title: DecorationType[];
305 | types: CollectionViewType[];
306 | data: Array<{ [key: string]: DecorationType[] }>;
307 | schema: { [key: string]: { name: string; type: string } };
308 | };
309 | }
310 |
311 | export interface NotionUserType {
312 | role: string;
313 | value: {
314 | id: string;
315 | version: number;
316 | email: string;
317 | given_name: string;
318 | family_name: string;
319 | profile_photo: string;
320 | onboarding_completed: boolean;
321 | mobile_onboarding_completed: boolean;
322 | };
323 | }
324 |
325 | export type BlockMapType = {
326 | [key: string]: BlockType;
327 | };
328 |
329 | export interface RecordMapType {
330 | block: BlockMapType;
331 | notion_user: {
332 | [key: string]: NotionUserType;
333 | };
334 | }
335 |
336 | export interface LoadPageChunkData {
337 | recordMap: RecordMapType;
338 | cursor: {
339 | stack: any[];
340 | };
341 | }
342 |
343 | export type MapPageUrl = (pageId: string) => string;
344 | export type MapImageUrl = (image: string, block?: BlockType) => string;
345 |
346 | export type BlockValueProp = Extract;
347 |
348 | export interface CustomBlockComponentProps {
349 | renderComponent: () => JSX.Element | null;
350 | blockMap: BlockMapType;
351 | blockValue: T extends BlockValueTypeKeys ? BlockValueProp : BaseValueType;
352 | level: number;
353 | }
354 |
355 | export type CustomBlockComponents = {
356 | [K in BlockValueTypeKeys]?: FC>;
357 | };
358 |
359 | type SubDecorationSymbol = SubDecorationType[0];
360 | type SubDecorationValue = Extract<
361 | SubDecorationType,
362 | [T, any]
363 | >[1];
364 |
365 | export type CustomDecoratorComponentProps<
366 | T extends SubDecorationSymbol
367 | > = (SubDecorationValue extends never
368 | ? {}
369 | : {
370 | decoratorValue: SubDecorationValue;
371 | }) & {
372 | renderComponent: () => JSX.Element | null;
373 | };
374 |
375 | export type CustomDecoratorComponents = {
376 | [K in SubDecorationSymbol]?: FC>;
377 | };
378 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { DecorationType, BlockMapType, MapImageUrl } from "./types";
2 |
3 | export const classNames = (...classes: Array) =>
4 | classes.filter(a => !!a).join(" ");
5 |
6 | export const getTextContent = (text: DecorationType[]) => {
7 | return text.reduce((prev, current) => prev + current[0], "");
8 | };
9 |
10 | const groupBlockContent = (blockMap: BlockMapType): string[][] => {
11 | const output: string[][] = [];
12 |
13 | let lastType: string | undefined = undefined;
14 | let index = -1;
15 |
16 | Object.keys(blockMap).forEach(id => {
17 | blockMap[id].value?.content?.forEach(blockId => {
18 | const blockType = blockMap[blockId]?.value?.type;
19 |
20 | if (blockType && blockType !== lastType) {
21 | index++;
22 | lastType = blockType;
23 | output[index] = [];
24 | }
25 |
26 | output[index].push(blockId);
27 | });
28 |
29 | lastType = undefined;
30 | });
31 |
32 | return output;
33 | };
34 |
35 | export const getListNumber = (blockId: string, blockMap: BlockMapType) => {
36 | const groups = groupBlockContent(blockMap);
37 | const group = groups.find(g => g.includes(blockId));
38 |
39 | if (!group) {
40 | return;
41 | }
42 |
43 | return group.indexOf(blockId) + 1;
44 | };
45 |
46 | export const defaultMapImageUrl: MapImageUrl = (image = "", block) => {
47 | const url = new URL(
48 | `https://www.notion.so${
49 | image.startsWith("/image") ? image : `/image/${encodeURIComponent(image)}`
50 | }`
51 | );
52 |
53 | if (block && !image.includes("/images/page-cover/")) {
54 | const table =
55 | block.value.parent_table === "space" ? "block" : block.value.parent_table;
56 | url.searchParams.set("table", table);
57 | url.searchParams.set("id", block.value.id);
58 | url.searchParams.set("cache", "v2");
59 | }
60 |
61 | return url.toString();
62 | };
63 |
64 | export const defaultMapPageUrl = (pageId: string = "") => {
65 | pageId = pageId.replace(/-/g, "");
66 |
67 | return `/${pageId}`;
68 | };
69 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src", "types"],
3 | "compilerOptions": {
4 | "module": "esnext",
5 | "lib": ["dom", "esnext"],
6 | "importHelpers": true,
7 | "declaration": true,
8 | "sourceMap": true,
9 | "rootDir": "./src",
10 | "strict": true,
11 | "noUnusedLocals": true,
12 | "noUnusedParameters": true,
13 | "noImplicitReturns": true,
14 | "noFallthroughCasesInSwitch": true,
15 | "moduleResolution": "node",
16 | "baseUrl": "./",
17 | "paths": {
18 | "*": ["src/*", "node_modules/*"]
19 | },
20 | "jsx": "react",
21 | "esModuleInterop": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/tsdx.config.js:
--------------------------------------------------------------------------------
1 | const copy = require("rollup-plugin-copy");
2 |
3 | module.exports = {
4 | rollup(config) {
5 | config.plugins.push(
6 | copy({
7 | targets: [{ src: "src/styles.css", dest: "dist/" }]
8 | })
9 | );
10 | return config;
11 | }
12 | };
13 |
--------------------------------------------------------------------------------