├── .DS_Store ├── .gitignore ├── .npmignore ├── README.md ├── bin └── index.ts ├── index.ts ├── package-lock.json ├── package.json ├── src ├── components │ ├── Block.tsx │ ├── Code.tsx │ ├── NotionPageBody.tsx │ ├── RichText.tsx │ └── Table.tsx ├── downloadMedia.ts ├── generateTypes.ts ├── getFromNotion.ts ├── scaffoldAppDirectory.ts ├── setup.ts └── utils.ts ├── styles.css ├── templates ├── javascript │ ├── Card.jsx │ ├── [slug] │ │ └── page.jsx │ ├── get.js │ └── page.jsx └── typescript │ ├── Card.tsx │ ├── [slug] │ └── page.tsx │ ├── get.ts │ └── page.tsx ├── tsconfig.json └── types ├── types.js └── types.ts /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/williamlmao/notion-on-next/d06ec0c51e079182ac7046c345b8d34793382223/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | 3 | - [About](#about) 4 | - [Features](#features) 5 | - [Who is notion-on-next for?](#who-is-notion-on-next-for) 6 | - [Installation](#installation) 7 | - [Usage](#usage) 8 | - [Fetching Data](#fetching-data) 9 | - [Working with Media](#working-with-media) 10 | - [Commands](#commands) 11 | - [Supported Blocks](#supported-blocks) 12 | - [Reference](#reference) 13 | - [Data fetchers](#data-fetchers) 14 | - [Components](#components) 15 | - [Why is this library only compatible Next? Why not make it a broader React library?](#why-is-this-library-only-compatible-next-why-not-make-it-a-broader-react-library) 16 | - [Contributing](#contributing) 17 | 18 | # About 19 | 20 | Notion-on-next makes it really easy to build a Nextjs app that uses Notion as a CMS. 21 | 22 | > **WARNING** 23 | > This repo uses experimental Next 13 features in /components. Use at your own risk. Type generation, data fetchers, and downloading media are all compatible with Next 12/React. 24 | 25 | ## Features 26 | 27 | - Automatically generates types that match your database properties 28 | - Provides components to render your Notion Pages 29 | - Provides data fetching functions that add some utility to the [notion-sdk](https://github.com/makenotion/notion-sdk-js) 30 | - Downloads all of the media from your database into your public folder, [to get around Notion API's 1 hr media expiration](https://developers.notion.com/docs/working-with-files-and-media#retrieving-files-and-media-via-the-notion-api) 31 | - Scaffolds out all of the necessary components for /app/[yourdatabase]. You can get a working app up and running in 5 minutes! 32 | - Uses the official Notion API. When using unofficial APIs, everything within your database/site have to be public. Using the official Notion API you are able to choose what pages are visible. 33 | 34 | ## Who is notion-on-next for? 35 | 36 | It's for Next.js developers who want to use Notion as a CMS. You should have an understanding of the Next 13 app directory and data fetching patterns. This library gives you a solid foundation to build on top of, but if you are looking for an out-of-the-box solution, I recommend checking out [nextjs-notion-starter-kit](https://github.com/transitive-bullshit/nextjs-notion-starter-kit). 37 | 38 | # Installation 39 | 40 | 1. Create a fresh Next app with `npx create-next-app@latest --experimental-app` 41 | 2. `cd your-app-name` 42 | 3. Install notion-on next: `npm i notion-on-next` 43 | 4. [Create a Notion integration](https://developers.notion.com/docs/create-a-notion-integration) and share your database with your newly created integration. 44 | 5. [Get your internal integration token](https://www.notion.so/my-integrations), and add it to a .env file in the root directory as `NOTION_KEY=yourtoken` 45 | 6. Run `npx non setup`. You can either add your personal databases, or if you’d like to start from a template go ahead and [copy this page](https://liuwill.notion.site/notion-on-next-Template-Databases-3b6292c8a6fe4dbaa12f9af26cffe674) which contains two sample databases. Make sure to hit yes when prompted to download media and scaffold the app directory. Typescript is recommended. 46 | 7. You’re ready to go! Run npm run dev and then visit http://localhost:3000/yourdatabasename to see your content. 47 | 8. You might notice that your app is slow in development mode. This is because it needs to refetch your data from Notion whenever you refresh or go to a new page. To try out the production build, run npm run build and then npm run start. 48 | 49 | For a more detailed walkthrough of customizing your app, check out [this blog post](https://www.willliu.com/blog/How-to-build-a-Notion-powered-Next-js-App-in-5-minutes-with-notion-on-next). 50 | 51 | # Usage 52 | 53 | ## Fetching Data 54 | 55 | Use `getParsedPages` to retrieve all pages in a database. If you're using typescript, this function also accepts a generic type, which was generated for you inside of notion-on-next.types.ts during setup. This type uses the title of your database in Notion. If your database was titled "Blog", you would use `getParsedPages(databaseId)`. 56 | 57 | ``` 58 | // Next 13 - /app/blog 59 | export default async function Blog() { 60 | const pages = await getParsedPages( 61 | databaseId 62 | ); 63 | return ( 64 |
65 |
66 |

Blog Posts

67 |
68 | {pages.map((page) => ( 69 | 74 | ))} 75 |
76 |
77 |
78 | ); 79 | } 80 | ``` 81 | 82 | Then, create a route to load single pages. Notion API refers to data such as the title, cover photos, and properties as a page. To get a page's contents, use `getBlocks(pageId)`. To get the page id in Next 13, you will need to fetch all of your pages and then filter by the slug, as that is the only param you can access if your route is `[slug]`. If you are okay with a pageId in the URL, then you can skip fetching all of the pages and swap `[slug]` for `[pageId]`. 83 | 84 | You can either write your own components to display the data, or you can use ` to render the contents of a page. 85 | 86 | If you are using ``, you can `import "notion-on-next/styles.css"` for styling in either in your `layout.tsx` or `page.tsx` depending on which version of Next you are using. Alternatively, you can [copy the file](https://github.com/williamlmao/notion-on-next/blob/main/styles.css) and change the styling to suit your preferences. 87 | 88 | > The data fetchers in this library are compatible with Next 12, but NotionPageBody is a server component that is only compatible with Next 13. 89 | 90 | ``` 91 | // /app/blog/[slug] 92 | export default async function BlogPage({ 93 | params, 94 | }: { 95 | params: PageProps; 96 | }): Promise { 97 | 98 | const { slug } = params; 99 | 100 | // The reason why we have to fetch all of the pages and then filter 101 | // is because the Notion API can only search for pages 102 | // via page id and not slug. As of now, there is no way to pass params 103 | // other than what is contained in the route to a Page component. 104 | 105 | const pages = await getParsedPages( 106 | databaseId 107 | ); 108 | 109 | const page = pages.find((page) => page.slug === slug); 110 | 111 | if (!page) { 112 | notFound(); 113 | } 114 | 115 | const blocks = await getBlocks(page.id); 116 | 117 | return ( 118 |
119 | 125 |
126 | ); 127 | } 128 | 129 | export async function generateStaticParams() { 130 | // This generates routes using the slugs created from getParsedPages 131 | const pages = await getParsedPages( 132 | databaseId 133 | ); 134 | return pages.map((page) => ({ 135 | slug: page.slug, 136 | })); 137 | } 138 | 139 | ``` 140 | 141 | Since we are generating the sites statically, it's not that big of a deal to call `getPages` in multiple places because those calls will only be made at build time. However, if you have a ton of pages and are worried about the volume of requests you are making to the Notion API, you can [use per-request caching](https://beta.nextjs.org/docs/data-fetching/caching#per-request-caching). 142 | 143 | To take advantage of per-request caching, import this function in any file you are calling `getParsedPages` and use `cachedGetParsedPages` instead. 144 | 145 | ``` 146 | //utils/cached-data-fetchers.ts 147 | import { getParsedPages } from "notion-on-next"; 148 | import { cache } from "react"; 149 | 150 | export const cachedGetParsedPages = cache( 151 | async (pageId: string): Promise => { 152 | const pages: Type[] = await getParsedPages(pageId); 153 | return pages; 154 | } 155 | ); 156 | ``` 157 | 158 | ## Working with Media 159 | 160 | > **INFO** 161 | > [Media from the official Notion API expires every hour](https://developers.notion.com/docs/working-with-files-and-media). 162 | 163 | To get around this problem, notion-on-next downloads all of the media in your database into `/public/notion-media/databaseId/pageId/blockId`. If your page has a cover photo, it will save that as `cover` inside of the pages folder. 164 | 165 | Since we don't know what file extension each image/video will be, there is a file called `media-map.json` that is generated, which contains the URL. 166 | 167 | In any component where you are trying to display an image, you can import the mediaMap and then reference the URL like so: 168 | 169 | ``` 170 | import mediaMap from "../../public/notion-media/media-map.json"; 171 | 172 | // For type safety, you should check in the parent component that the block is of type image 173 | export const ImageCard = ({databaseId, pageId, blockId}) => { 174 | return 175 | } 176 | ``` 177 | 178 | ## Commands 179 | 180 | - `npx non setup` 181 | - Generates a `notion-on-next.config.js` file in the root of your project. 182 | - Generates `notion-on-next.types.ts` that includes all of your database types in whichever folder you specify. 183 | - Downloads all media in specified databases into `/public/notion-media`. 184 | - `npx non media` 185 | - Downloads media from every database specified in `notion-on-next.config.js` into `/public/notion-media. 186 | - Run this command again if you added new media or if you see broken images on your site. 187 | - `npx non types` 188 | - Generates your types based on the databases specified in `notion-on-next.config.js`. If you want to change the file path. 189 | - Run this command again if you update any database properties. 190 | 191 | ## Supported Blocks 192 | 193 | You can see all of the supported blocks [here](https://notion-on-next-starter.vercel.app/programming/Notion-on-next-Supported-Blocks-Examples). Please submit an issue if there is a block that you would like to see supported. 194 | 195 | # Reference 196 | 197 | ## Data fetchers 198 | 199 | | Name | Description | 200 | | -------------- | ------------------------------------------------------------------------------------------------------ | 201 | | getDatabase | Fetches a database, raw API | 202 | | getPages | Fetches a page, raw API | 203 | | getParsedPages | Fetches a page but exposes title, coverImage, and slug. Allows you to pass in a database-specific type | 204 | | getBlocks | Fetches all blocks in a page, raw | 205 | 206 | ## Components 207 | 208 | | Name | Description | 209 | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | 210 | | NotionPageHeader | There is no component called NotionPageHeader exported. It is recommended to write your own, as every database will have different properties. | 211 | | NotionPageBody | A container for all of a page's blocks. | 212 | | Block | Renders a Notion block and child blocks. | 213 | | RichText | Renders rich text. | 214 | 215 | # Why is this library only compatible Next? Why not make it a broader React library? 216 | 217 | The honest answer is because this started out with me wanting to play with Next 13 and React experimental features. I used a lot of those features and patterns in this library. However, this could be refactored to work for vanilla React. If you're interested in that, let me know. With enough interest I may re-write the library. 218 | 219 | # Contributing 220 | 221 | This is one of my first npm packages, so I am very open to any contributions or feedback! Please feel free to open an issue or PR. 222 | -------------------------------------------------------------------------------- /bin/index.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import { setup } from "../src/setup"; 3 | import { generateTypes } from "../src/generateTypes"; 4 | import { downloadMedia } from "../src/downloadMedia"; 5 | import { checkNextVersionNumber } from "../src/utils"; 6 | 7 | const command = process.argv[2]; 8 | 9 | switch (command) { 10 | case "setup": 11 | // Check if user is using next 13 or greater from package.json 12 | const nextVersionCompatible = checkNextVersionNumber(13); 13 | if (!nextVersionCompatible) { 14 | console.log( 15 | "Next.js version must be 13 or greater. Please upgrade your Next.js version." 16 | ); 17 | break; 18 | } 19 | setup(); 20 | break; 21 | case "media": 22 | downloadMedia(); 23 | break; 24 | case "types": 25 | generateTypes(); 26 | break; 27 | default: { 28 | console.log( 29 | "Please use one of the following commands: setup, media, types. Example: npx-non setup" 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getDatabase, 3 | getBlocks, 4 | getPages, 5 | parsePages, 6 | getParsedPages, 7 | } from "./src/getFromNotion"; 8 | import { NotionPageBody } from "./src/components/NotionPageBody"; 9 | import { mediaMapInterface } from "./types/types"; 10 | 11 | export { 12 | getDatabase, 13 | getBlocks, 14 | getPages, 15 | parsePages, 16 | getParsedPages, 17 | NotionPageBody, 18 | }; 19 | 20 | export type { mediaMapInterface }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-on-next", 3 | "version": "1.0.20", 4 | "description": "A framework that makes it easy to develop a Next.js App using Notion as a CMS. Automatically generates types and provides components to render your Notion pages.", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "non": "dist/bin/index.js" 8 | }, 9 | "types": "dist/index.d.ts", 10 | "repository": "https://github.com/williamlmao/notion-on-next", 11 | "keywords": [ 12 | "notion", 13 | "next", 14 | "cms", 15 | "blog", 16 | "portfolio", 17 | "website" 18 | ], 19 | "scripts": { 20 | "test": "echo \"Error: no test specified\" && exit 1" 21 | }, 22 | "prepublish": "tsc", 23 | "author": "williamlmao", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@types/prompt": "^1.1.4", 27 | "@types/react": "^18.0.26", 28 | "@types/react-syntax-highlighter": "^15.5.5", 29 | "@types/request": "^2.48.8", 30 | "ts-node": "^10.9.1", 31 | "typescript": "^4.9.3" 32 | }, 33 | "dependencies": { 34 | "@notionhq/client": "^2.2.2", 35 | "@types/react": "18.0.25", 36 | "dotenv": "^16.0.3", 37 | "eslint-config-next": "^13.0.6", 38 | "next": "^13.0.6", 39 | "prompt": "^1.3.0", 40 | "react": "^18.2.0", 41 | "react-dom": "^18.2.0", 42 | "react-syntax-highlighter": "^15.5.0", 43 | "request": "^2.88.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Block.tsx: -------------------------------------------------------------------------------- 1 | import { asyncComponent, mediaMapInterface } from "../../types/types"; 2 | import { isFullBlock } from "@notionhq/client"; 3 | import { 4 | BlockObjectResponse, 5 | PartialBlockObjectResponse, 6 | } from "@notionhq/client/build/src/api-endpoints"; 7 | import Image from "next/image"; 8 | import { RichText } from "./RichText"; 9 | import { getBlocks } from "../../index"; 10 | import React from "react"; 11 | import { Code } from "./Code"; 12 | import { Table } from "./Table"; 13 | 14 | /** 15 | * A recursive component that renders a Notion block and child blocks. 16 | * @param mediaMap is an object that notion-on-next generates for you. Find it in public/notion-media/media-map.json. 17 | */ 18 | export const Block = asyncComponent( 19 | async ({ 20 | block, 21 | blocks, 22 | mediaMap, 23 | databaseId, 24 | pageId, 25 | }: { 26 | block: BlockObjectResponse | PartialBlockObjectResponse; 27 | blocks: (BlockObjectResponse | PartialBlockObjectResponse)[]; 28 | mediaMap?: mediaMapInterface; 29 | databaseId?: string; 30 | pageId?: string; 31 | }) => { 32 | if (!isFullBlock(block)) { 33 | return <>; 34 | } 35 | 36 | let children: React.ReactNode[] | undefined; 37 | // Table blocks are handled a bit differently. See Table.tsx 38 | if (block.has_children && block.type !== "table") { 39 | const childBlocks = await getBlocks(block.id); 40 | children = childBlocks?.map( 41 | (child: BlockObjectResponse | PartialBlockObjectResponse) => { 42 | if (child) { 43 | return ( 44 | 51 | ); 52 | } else { 53 | // Prevents undefined block error 54 | return <>; 55 | } 56 | } 57 | ); 58 | } 59 | // Add support for any block type here. You can add custom styling wherever you'd like. 60 | switch (block.type) { 61 | case "heading_1": 62 | //@ts-ignore Notion types are incorrect 63 | if (block.heading_1.is_toggleable) { 64 | return ( 65 |
66 | 67 | 68 | 69 | {children} 70 |
71 | ); 72 | } 73 | return ( 74 |

75 | 76 |

77 | ); 78 | case "heading_2": 79 | //@ts-ignore Notion types are incorrect 80 | if (block.heading_2.is_toggleable) { 81 | return ( 82 | <> 83 |
84 | 85 | 86 | 87 | {children} 88 |
89 | 90 | ); 91 | } 92 | return ( 93 |

94 | 95 |

96 | ); 97 | case "heading_3": 98 | //@ts-ignore Notion types are incorrect 99 | if (block.heading_3.is_toggleable) { 100 | return ( 101 | <> 102 |
103 | 104 | 105 | 106 | {children} 107 |
108 | 109 | ); 110 | } 111 | return ( 112 |

113 | 114 |

115 | ); 116 | case "paragraph": 117 | return ( 118 |

119 | 120 |

121 | ); 122 | case "image": 123 | // If Media map does not exist, use the external url or file url from Notion. Be aware that these links expire after 1 hr. https://developers.notion.com/docs/working-with-files-and-media 124 | const imageUrl: string = 125 | databaseId && pageId && mediaMap 126 | ? (mediaMap[databaseId][pageId][block.id] as string) 127 | : block.image.type == "external" 128 | ? block.image.external.url 129 | : block.image.file.url; 130 | return ( 131 |
132 | {"Notion 139 | 140 | {block.image.caption && ( 141 | 142 | )} 143 | 144 |
145 | ); 146 | case "video": 147 | // If Media map does not exist, use the external url or file url from Notion. Be aware that these links expire after 1 hr. https://developers.notion.com/docs/working-with-files-and-media 148 | const videoUrl: string = 149 | databaseId && pageId && mediaMap 150 | ? (mediaMap[databaseId][pageId][block.id] as string) 151 | : block.video.type == "external" 152 | ? block.video.external.url 153 | : block.video.file.url; 154 | if (videoUrl) { 155 | return ( 156 |
157 |
164 | ); 165 | } else { 166 | return
Video URL not found
; 167 | } 168 | 169 | case "bulleted_list_item": 170 | return ( 171 |
    172 |
  • 173 | 174 |
  • 175 | {children} 176 |
177 | ); 178 | case "numbered_list_item": 179 | const itemPosition = blocks.findIndex( 180 | (blocksBlock) => block.id === blocksBlock.id 181 | ); 182 | // Count backwards to find the number of numbered_list_item blocks before hitting a non-numbered_list_item block 183 | // Notions API does not give any information about the position of the block in the list so we need to calculate it 184 | let listNumber = 0; 185 | for (let i = itemPosition; i >= 0; i--) { 186 | let blocksBlock = blocks[i] as BlockObjectResponse; 187 | if (blocksBlock.type === "numbered_list_item") { 188 | listNumber++; 189 | } else { 190 | break; 191 | } 192 | } 193 | return ( 194 |
    195 |
  1. 196 | 197 |
  2. 198 | {children} 199 |
200 | ); 201 | case "code": 202 | return ( 203 |
204 | 208 | {block.code.caption && ( 209 | 210 | 211 | 212 | )} 213 |
214 | ); 215 | case "callout": 216 | return ( 217 |
218 |
219 | {block.callout.icon?.type === "emoji" 220 | ? block.callout.icon.emoji 221 | : ""} 222 |
223 |
224 | 225 |
226 |
227 | ); 228 | case "column_list": 229 | return
{children}
; 230 | case "column": 231 | return
{children}
; 232 | case "quote": 233 | return ( 234 |
235 | 236 |
237 | ); 238 | case "divider": 239 | return
; 240 | case "to_do": 241 | return ( 242 |
243 | 249 | 250 |
251 | ); 252 | case "toggle": 253 | return ( 254 |
255 | 256 | 257 | 258 | {children} 259 |
260 | ); 261 | case "table": 262 | return ; 263 | 264 | default: 265 | return
Block {block.type} not supported
; 266 | } 267 | } 268 | ); 269 | -------------------------------------------------------------------------------- /src/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import { atomOneDark } from "react-syntax-highlighter/dist/cjs/styles/hljs"; 2 | import React from "react"; 3 | import SyntaxHighlighter from "react-syntax-highlighter/dist/cjs/default-highlight"; 4 | 5 | export const Code = ({ 6 | text, 7 | language, 8 | }: { 9 | text: string; 10 | language: string; 11 | }) => { 12 | return ( 13 | 14 | {text} 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/NotionPageBody.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockObjectResponse, 3 | PartialBlockObjectResponse, 4 | } from "@notionhq/client/build/src/api-endpoints"; 5 | import React from "react"; 6 | import { asyncComponent, mediaMapInterface } from "../../types/types"; 7 | import { Block } from "./Block"; 8 | 9 | /** 10 | * Renders your notion page content. It does not include the page title or other page properties. 11 | * * @param mediaMap is an object that notion-on-next generates for you. Find it in public/notion-media/media-map.json. 12 | */ 13 | export const NotionPageBody = asyncComponent( 14 | async ({ 15 | blocks, 16 | databaseId, 17 | pageId, 18 | mediaMap, 19 | }: { 20 | blocks: (PartialBlockObjectResponse | BlockObjectResponse)[]; 21 | databaseId: string; 22 | pageId: string; 23 | mediaMap?: mediaMapInterface; 24 | }) => { 25 | return ( 26 |
27 | {blocks.map((block) => { 28 | if (block) { 29 | return ( 30 | 38 | ); 39 | } else { 40 | // Prevents undefined block error 41 | return <>; 42 | } 43 | })} 44 |
45 | ); 46 | } 47 | ); 48 | -------------------------------------------------------------------------------- /src/components/RichText.tsx: -------------------------------------------------------------------------------- 1 | import { RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints"; 2 | import React from "react"; 3 | 4 | export const RichText = ({ 5 | rich_text, 6 | }: { 7 | rich_text: RichTextItemResponse[]; 8 | }) => { 9 | return ( 10 | <> 11 | {rich_text.map((rich_text_item, index) => { 12 | if (!rich_text_item) return <>; 13 | const { bold, italic, strikethrough, underline, code } = 14 | rich_text_item.annotations; 15 | const color = rich_text_item.annotations.color.includes("background") 16 | ? { backgroundColor: rich_text_item.annotations.color.split("_")[0] } 17 | : { color: rich_text_item.annotations.color }; 18 | let text = {rich_text_item.plain_text}; 19 | if (bold) { 20 | text = {text}; 21 | } 22 | if (italic) { 23 | text = {text}; 24 | } 25 | if (strikethrough) { 26 | text = {text}; 27 | } 28 | if (underline) { 29 | text = {text}; 30 | } 31 | if (code) { 32 | // Remove "`" from text 33 | text = {text}; 34 | } 35 | if (rich_text_item.href) { 36 | text = ( 37 | 43 | {text} 44 | 45 | ); 46 | } 47 | return ( 48 | 49 | {text} 50 | 51 | ); 52 | })} 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/Table.tsx: -------------------------------------------------------------------------------- 1 | import { TableBlockObjectResponse } from "@notionhq/client/build/src/api-endpoints"; 2 | import React from "react"; 3 | import { getBlocks } from "../getFromNotion"; 4 | import { asyncComponent } from "../../types/types"; 5 | import { RichText } from "./RichText"; 6 | export const Table = asyncComponent( 7 | async ({ block }: { block: TableBlockObjectResponse }) => { 8 | const children = await getBlocks(block.id); 9 | const has_column_header = block.table.has_column_header; 10 | const has_row_header = block.table.has_row_header; 11 | return ( 12 |
13 | 14 | {children?.map((row, i) => { 15 | if (row) { 16 | return ( 17 | 23 | {/* @ts-ignore */} 24 | {row.table_row.cells.map((cell, j) => { 25 | if (cell) { 26 | return ( 27 | 37 | ); 38 | } else { 39 | return <>; 40 | } 41 | })} 42 | 43 | ); 44 | } else { 45 | return <>; 46 | } 47 | })} 48 | 49 |
35 | 36 |
50 | ); 51 | } 52 | ); 53 | -------------------------------------------------------------------------------- /src/downloadMedia.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import { isFullBlock } from "@notionhq/client"; 3 | import fs from "fs"; 4 | import request from "request"; 5 | import { getBlocks, getPages, parsePages } from "../index"; 6 | import { 7 | configInterface, 8 | mediaMapInterface, 9 | NotionOnNextPageObjectResponse, 10 | } from "../types/types"; 11 | import { createFolderIfDoesNotExist, getFileExtension } from "./utils"; 12 | 13 | export const downloadMedia = async () => { 14 | const configPath = "./notion-on-next.config.json"; 15 | const config = JSON.parse( 16 | fs.readFileSync(configPath, "utf8") 17 | ) as configInterface; 18 | 19 | for (const databaseId in config.databases) { 20 | await fetchImages(databaseId); 21 | } 22 | }; 23 | 24 | export async function fetchImages( 25 | databaseId: string, 26 | pages?: NotionOnNextPageObjectResponse[], 27 | update?: boolean 28 | ) { 29 | console.log("🎾 Fetching media for: ", databaseId, "\n"); 30 | // Read media map 31 | const mediaMapPath = "./public/notion-media/media-map.json"; 32 | // Check if media map exists 33 | let mediaMap = {} as mediaMapInterface; 34 | if (fs.existsSync(mediaMapPath)) { 35 | mediaMap = JSON.parse( 36 | fs.readFileSync(mediaMapPath, "utf8") 37 | ) as mediaMapInterface; 38 | } 39 | const basePath = `./public/notion-media`; 40 | await createFolderIfDoesNotExist(`${basePath}`); 41 | const databasePath = `${basePath}/${databaseId}`; 42 | if (!mediaMap[databaseId]) { 43 | mediaMap[databaseId] = {}; 44 | } 45 | await createFolderIfDoesNotExist(`${databasePath}`); 46 | if (!pages) { 47 | const unparsedPages = await getPages(databaseId); 48 | pages = await parsePages(unparsedPages); 49 | } 50 | for (const page of pages) { 51 | const mediaMapDb = mediaMap[databaseId]; 52 | const pageId = page.id; 53 | if (!mediaMapDb[pageId]) { 54 | mediaMapDb[pageId] = {}; 55 | } 56 | const pageFolderPath = `${databasePath}/${pageId}`; 57 | await createFolderIfDoesNotExist(`${pageFolderPath}`); 58 | // Download cover images 59 | if (page.coverImage) { 60 | // Regex to get the file extension from the URL (e.g. png, jpg, jpeg, etc) 61 | const fileExtension = getFileExtension(page.coverImage); 62 | const coverImagePath = `${pageFolderPath}/cover.${fileExtension}`; 63 | // Remove /public from the mediamap path so it can be used in the Next App (you don't need to include /public in the paths) 64 | const coverImagePathWithoutPublic = `/notion-media/${databaseId}/${pageId}/cover.${fileExtension}`; 65 | 66 | mediaMap[databaseId][pageId].cover = coverImagePathWithoutPublic; 67 | 68 | await downloadMediaToFolder( 69 | page.coverImage, 70 | coverImagePath, 71 | () => { 72 | console.log( 73 | `✅ Downloaded cover image for ${page.title} (id:${pageId}) in database: ${databaseId}\n` 74 | ); 75 | }, 76 | () => { 77 | console.log( 78 | `⏭ Cover image for ${page.title} already exists. Skipping download.\n` 79 | ); 80 | }, 81 | update 82 | ); 83 | } 84 | // Download all blocks and their images 85 | const blocks = await getBlocks(pageId); 86 | for (const block of blocks) { 87 | const blockId = block.id; 88 | if (!isFullBlock(block)) { 89 | continue; 90 | } 91 | let url; 92 | if (block.type === "image") { 93 | const image = block.image; 94 | url = image.type === "external" ? image.external.url : image.file.url; 95 | } 96 | 97 | if (block.type === "video") { 98 | const video = block.video; 99 | url = video.type === "external" ? video.external.url : video.file.url; 100 | } 101 | if (!url) { 102 | continue; 103 | } 104 | const fileExtension = getFileExtension(url); 105 | const blockImagePath = `${pageFolderPath}/${blockId}.${fileExtension}`; 106 | const blockImagePathWithoutPublic = `/notion-media/${databaseId}/${pageId}/${blockId}.${fileExtension}`; 107 | 108 | mediaMap[databaseId][pageId][blockId] = blockImagePathWithoutPublic; 109 | downloadMediaToFolder( 110 | url, 111 | blockImagePath, 112 | () => { 113 | `✅ Downloaded ${block.type} for blockId: ${block.id} in ${page.title} (id:${pageId}) in databaseId: ${databaseId} \n`; 114 | }, 115 | () => { 116 | `⏭ ${block.type} for blockId: ${block.id} in ${page.title} already exists. Skipping download. \n`; 117 | }, 118 | update 119 | ); 120 | } 121 | } 122 | // // Write the image map to a .json file 123 | fs.writeFileSync(`${basePath}/media-map.json`, JSON.stringify(mediaMap)); 124 | } 125 | 126 | export const downloadMediaToFolder = async ( 127 | url: string, 128 | path: string, 129 | callback: () => void, 130 | alreadyExistsCallback: () => void, 131 | update?: boolean 132 | ) => { 133 | if (fs.existsSync(path) && !update) { 134 | alreadyExistsCallback(); 135 | return; 136 | } 137 | // overwrite if file already exists 138 | await request.head(url, (err, res, body) => { 139 | request(url).pipe(fs.createWriteStream(path)).on("close", callback); 140 | }); 141 | }; 142 | -------------------------------------------------------------------------------- /src/generateTypes.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | import { DatabaseObjectResponse } from "@notionhq/client/build/src/api-endpoints"; 3 | import fs from "fs"; 4 | import { getDatabase } from "../src/getFromNotion"; 5 | import { configInterface } from "../types/types"; 6 | import { createFolderIfDoesNotExist, pascalCase } from "./utils"; 7 | 8 | export const generateTypes = async () => { 9 | // Read users config file 10 | const configPath = "./notion-on-next.config.json"; 11 | const config = JSON.parse( 12 | fs.readFileSync(configPath, "utf8") 13 | ) as configInterface; 14 | await createFolderIfDoesNotExist(`${config.typesFolderPath}`); 15 | // Generate types from the user's config file 16 | await initializeTypes(`${config.typesFolderPath}/notion-on-next.types.ts`); 17 | for (const databaseId in config.databases) { 18 | const database = await getDatabase(databaseId); 19 | if (!database) { 20 | console.log( 21 | `Could not find database with id ${databaseId}. Please check your config file.` 22 | ); 23 | return; 24 | } 25 | await generateTypesFromDatabase( 26 | `${config.typesFolderPath}/notion-on-next.types.ts`, 27 | database 28 | ); 29 | } 30 | }; 31 | 32 | export const initializeTypes = async (path: string) => { 33 | fs.writeFileSync( 34 | path, 35 | ` 36 | import { 37 | PageObjectResponse, 38 | } from "@notionhq/client/build/src/api-endpoints"; 39 | 40 | export interface NotionOnNextPageObjectResponse extends PageObjectResponse { 41 | slug: string | undefined; 42 | title: string | undefined; 43 | coverImage: string | undefined; 44 | databaseName: string | undefined; 45 | databaseId: string | undefined; 46 | } 47 | 48 | export interface mediaMapInterface { 49 | [key: string]: { 50 | [key: string]: { 51 | [key: string]: string; 52 | }; 53 | }; 54 | } 55 | ` 56 | ); 57 | console.log("\n🤖 Initialized notion-on-next.types.ts \n"); 58 | }; 59 | 60 | export const generateTypesFromDatabase = async ( 61 | path: string, 62 | database: DatabaseObjectResponse 63 | ) => { 64 | const databaseName = pascalCase(database.title[0].plain_text); 65 | const databaseProperties = database.properties; 66 | const typeDefStart = `\nexport type ${databaseName}PageObjectResponse = NotionOnNextPageObjectResponse & {\n\tproperties: {\n`; 67 | const typeDefEnd = `\n\t}\n}`; 68 | const typeDefProperties = Object.keys(databaseProperties).map((key) => { 69 | const property = databaseProperties[key]; 70 | const propertyType = property.type; 71 | return `\t\t'${key}': Extract`; 72 | }); 73 | 74 | const typeDef = typeDefStart + typeDefProperties.join("\n") + typeDefEnd; 75 | await appendToFile(path, typeDef, () => { 76 | console.log( 77 | `⌨️ Generated a type for your database ${databaseName}: ${database}PageObjectResponse in` + 78 | path + 79 | "\n" 80 | ); 81 | }); 82 | }; 83 | 84 | export const appendToFile = async ( 85 | filePath: string, 86 | data: string, 87 | callback: () => void 88 | ) => { 89 | return new Promise((resolve, reject) => { 90 | fs.appendFile(filePath, data, (err) => { 91 | if (err) reject(err); 92 | callback(); 93 | resolve("done"); 94 | }); 95 | }); 96 | }; 97 | -------------------------------------------------------------------------------- /src/getFromNotion.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Client, 3 | collectPaginatedAPI, 4 | isFullBlock, 5 | isFullDatabase, 6 | isFullPage, 7 | } from "@notionhq/client"; 8 | import { 9 | DatabaseObjectResponse, 10 | GetDatabaseResponse, 11 | PageObjectResponse, 12 | PartialPageObjectResponse, 13 | } from "@notionhq/client/build/src/api-endpoints"; 14 | import * as dotenv from "dotenv"; // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import 15 | import { PagesFilters } from "../types/types"; 16 | import { pascalCase } from "./utils"; 17 | dotenv.config(); 18 | 19 | export const notion = new Client({ auth: process.env.NOTION_KEY }); 20 | 21 | export const getDatabase = async ( 22 | databaseId: string 23 | ): Promise => { 24 | try { 25 | const response = await notion.databases.retrieve({ 26 | database_id: databaseId, 27 | }); 28 | if (!isFullDatabase(response)) { 29 | return undefined; 30 | } 31 | return response; 32 | } catch (e) { 33 | console.error( 34 | "Error: The database ID you provided is invalid. Please double check that you have set up the Notion API integration with the database, and that you are providing the right database IDs." 35 | ); 36 | return undefined; 37 | } 38 | }; 39 | 40 | /** 41 | * This function pulls out the required types for notion-on-next: slug, title, and cover image to make these properties more accessible. 42 | * It also accepts a generic type which is meant to be passed down from getParsedPages, but can be used elsewhere. 43 | */ 44 | export const parsePages = async ( 45 | pages: (PageObjectResponse | PartialPageObjectResponse)[], 46 | database?: DatabaseObjectResponse 47 | ): Promise => { 48 | const parsedPages = pages.map((page) => { 49 | if (!isFullPage(page)) { 50 | return page; 51 | } 52 | const slug = page.url 53 | .split("/") 54 | .slice(3) 55 | .join("/") 56 | .split("-") 57 | .slice(0, -1) 58 | .join("-"); 59 | // Working around type errors: https://github.com/makenotion/notion-sdk-js/issues/154 60 | const nameProp = page.properties.Name; 61 | let title; 62 | if (nameProp?.type === "title") { 63 | title = nameProp?.title[0]?.plain_text; 64 | } 65 | return { 66 | ...page, 67 | slug: slug, 68 | title: title, 69 | databaseName: pascalCase(database?.title[0]?.plain_text), 70 | databaseId: database?.id, 71 | coverImage: 72 | page?.cover?.type === "file" 73 | ? page?.cover?.file?.url 74 | : page?.cover?.external?.url, 75 | }; 76 | }); 77 | return parsedPages as unknown as Type[]; 78 | }; 79 | 80 | /** 81 | * This is a cached function that fetches all pages from a Notion database. 82 | */ 83 | export const getPages = async ( 84 | databaseId: string, 85 | filter?: any, 86 | sorts?: any 87 | ) => { 88 | type Query = { 89 | database_id: string; 90 | filter?: any; 91 | sorts?: any; 92 | }; 93 | let query: Query = { 94 | database_id: databaseId as string, 95 | }; 96 | if (filter) { 97 | query = { 98 | ...query, 99 | filter: filter, 100 | }; 101 | } 102 | if (sorts) { 103 | query = { 104 | ...query, 105 | sorts: sorts, 106 | }; 107 | } 108 | const response = await notion.databases.query(query); 109 | return response.results; 110 | }; 111 | 112 | /** 113 | * Gets all pages from a Notion database and parses them into a more usable format. 114 | * Accepts a generic type, which is generated for you after running setup in notion-on-next. 115 | * The generic type should be a version of PageObjectResponse, but with your database's properties. 116 | */ 117 | export const getParsedPages = async ( 118 | databaseId: string, 119 | // TODO: Talk with notion team about how to get the filter types. They are currently not exported. 120 | filter?: any, 121 | sorts?: any 122 | ) => { 123 | const pages = await getPages(databaseId, filter, sorts); 124 | const database = await getDatabase(databaseId); 125 | const parsedPages = await parsePages(pages, database); 126 | return parsedPages; 127 | }; 128 | 129 | export const getBlocks = async (pageId: string) => { 130 | const blocks = await collectPaginatedAPI(notion.blocks.children.list, { 131 | block_id: pageId, 132 | }); 133 | return blocks; 134 | }; 135 | -------------------------------------------------------------------------------- /src/scaffoldAppDirectory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DatabaseObjectResponse, 3 | GetDatabaseResponse, 4 | } from "@notionhq/client/build/src/api-endpoints"; 5 | import fs from "fs"; 6 | import { getDatabase } from "./getFromNotion"; 7 | import { checkNextVersionNumber, pascalCase, spinalCase } from "./utils"; 8 | 9 | export const scaffoldApp = async ( 10 | database: DatabaseObjectResponse | string, 11 | language = "typescript" 12 | ) => { 13 | if (typeof database === "string") { 14 | const res = await getDatabase(database); 15 | if (res) { 16 | database = res; 17 | } 18 | } 19 | database = database as DatabaseObjectResponse; 20 | // Check if package.json contains next 13 or greater 21 | const nextVersionCompatible = checkNextVersionNumber(13); 22 | if (!nextVersionCompatible) { 23 | console.log("Please update your next version to 13 or greater"); 24 | return; 25 | } 26 | const databaseName = database?.title[0]?.plain_text; 27 | const databaseId = database.id; 28 | const databaseNameSpinalCase = spinalCase(databaseName); 29 | const databaseNamePascalCase = pascalCase(databaseName); 30 | 31 | const databasePath = `./app/${databaseNameSpinalCase}`; 32 | if (!fs.existsSync(databasePath)) { 33 | fs.mkdirSync(databasePath); 34 | } 35 | 36 | const fileExtension = language === "typescript" ? "ts" : "js"; 37 | 38 | const pagePath = `${databasePath}/page.tsx`; 39 | const cardPath = `${databasePath}/${databaseNamePascalCase}Card.tsx`; 40 | const slugPath = `${databasePath}/[slug]`; 41 | const slugPagePath = `${databasePath}/[slug]/page.tsx`; 42 | const getPath = `./app/get.ts`; 43 | 44 | const replaceInPageTemplate = (pageTemplate: string) => { 45 | return pageTemplate 46 | .replace(/DATABASENAMEPASCAL/g, databaseNamePascalCase) 47 | .replace(/DATABASENAMESPINAL/g, databaseNameSpinalCase) 48 | .replace(/DATABASEID/g, databaseId) 49 | .replace(/@ts-nocheck/g, ""); 50 | }; 51 | 52 | fs.copyFileSync( 53 | `./node_modules/notion-on-next/templates/${language}/get.${fileExtension}`, 54 | getPath 55 | ); 56 | 57 | const pageTemplate = fs.readFileSync( 58 | `./node_modules/notion-on-next/templates/${language}/page.${fileExtension}x`, 59 | "utf8" 60 | ); 61 | const pageTemplateReplaced = replaceInPageTemplate(pageTemplate); 62 | fs.writeFileSync(pagePath, pageTemplateReplaced); 63 | 64 | const cardTemplate = fs.readFileSync( 65 | `./node_modules/notion-on-next/templates/${language}/Card.${fileExtension}x`, 66 | "utf8" 67 | ); 68 | const cardTemplateReplaced = replaceInPageTemplate(cardTemplate); 69 | 70 | fs.writeFileSync(cardPath, cardTemplateReplaced); 71 | 72 | if (!fs.existsSync(slugPagePath)) { 73 | fs.mkdirSync(slugPath); 74 | } 75 | const slugPageTemplate = fs.readFileSync( 76 | `./node_modules/notion-on-next/templates/${language}/[slug]/page.${fileExtension}x`, 77 | "utf8" 78 | ); 79 | const slugPageTemplateReplaced = replaceInPageTemplate(slugPageTemplate); 80 | fs.writeFileSync(slugPagePath, slugPageTemplateReplaced); 81 | 82 | console.log( 83 | "🎉 Scaffolded database: " + databaseName + "in " + databasePath + "\n" 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/setup.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | // This file is the executable file. It is run when you run npx-non 4 | import { generateTypesFromDatabase, initializeTypes } from "./generateTypes"; 5 | import { configInterface } from "../types/types"; 6 | import { getDatabase } from "../src/getFromNotion"; 7 | import { fetchImages } from "../src/downloadMedia"; 8 | import { createFolderIfDoesNotExist } from "./utils"; 9 | import { scaffoldApp } from "./scaffoldAppDirectory"; 10 | import prompt from "prompt"; 11 | import fs from "fs"; 12 | 13 | const typeScript = [ 14 | { 15 | name: "typescript", 16 | description: "Would you like to use TypeScript? (y/n)", 17 | pattern: /y[es]*|n[o]?/, 18 | default: "yes", 19 | }, 20 | ]; 21 | 22 | const downloadMedia = [ 23 | { 24 | name: "downloadMedia", 25 | description: 26 | "Would you like to download the media from the databases? (y/n)", 27 | pattern: /y[es]*|n[o]?/, 28 | default: "yes", 29 | }, 30 | ]; 31 | 32 | const typesDestinationFolderPath = [ 33 | { 34 | name: "typesFolderPath", 35 | description: 36 | "Notion-on-next will generate types for you. By default, it will use ./types as the folder to store them. If you would like to change the destination folder, please enter the path or hit enter to use the default.", 37 | default: "./types", 38 | pattern: /^(\.\/)?[a-zA-Z0-9_\-]+$/, 39 | message: "Please enter a valid path", 40 | }, 41 | ]; 42 | 43 | const collectDbIds = [ 44 | { 45 | name: "databaseIds", 46 | description: 47 | "Please enter the database IDs you would like to set up (comma separated)", 48 | // Pattern checks for comma separated list of 32 or 28 character alphanumeric strings that may or may not be separated by hyphens such as the two below 49 | // 12c9bf144f9a429b8fffd63c58694c54, 5b3247dc-63b8-4fd1-b610-5e5a8aabd397 50 | pattern: 51 | /^([a-zA-Z0-9]{32}|[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12})(,\s?([a-zA-Z0-9]{32}|[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}))*$/, 52 | message: 53 | "Please make sure you are entering a comma separated list database IDs, which are 32 alphanumeric characters with hyphens or 28 characters without. ", 54 | }, 55 | ]; 56 | 57 | const scaffoldAppDirectory = [ 58 | { 59 | name: "scaffoldAppDirectory", 60 | description: 61 | "\nWould you like to scaffold the app directory with your databases? WARNING: THIS WILL OVERWRITE YOUR /app/[databasename]. Only recommended if you are starting out with a new build. Still want to scaffold? (y/n)", 62 | pattern: /y[es]*|n[o]?/, 63 | message: "Please enter yes or no", 64 | default: "yes", 65 | }, 66 | ]; 67 | 68 | let configTemplate: configInterface = { 69 | databases: {}, 70 | typesFolderPath: "./types", 71 | }; 72 | 73 | interface ResponsesInterface { 74 | databaseIds: string[]; 75 | typescript: boolean; 76 | typesFolderPath: string; 77 | downloadMedia: boolean; 78 | scaffoldAppDirectory: boolean; 79 | } 80 | 81 | export const setup = () => { 82 | let responses: ResponsesInterface = { 83 | databaseIds: [], 84 | typescript: false, 85 | typesFolderPath: "", 86 | downloadMedia: true, 87 | scaffoldAppDirectory: true, 88 | }; 89 | 90 | prompt.start(); 91 | 92 | prompt.get(collectDbIds, function (err, result) { 93 | if (err) { 94 | console.log(err); 95 | return; 96 | } 97 | const databaseIds = result.databaseIds as string; 98 | responses.databaseIds = databaseIds 99 | .split(",") 100 | .map((id: string) => id.trim()); 101 | 102 | prompt.get(downloadMedia, function (err, result) { 103 | if (err) { 104 | console.log(err); 105 | return; 106 | } 107 | const downloadMedia = result.downloadMedia as string; 108 | responses.downloadMedia = downloadMedia.toLowerCase().includes("y"); 109 | prompt.get(typeScript, function (err, result) { 110 | if (err) { 111 | console.log(err); 112 | return; 113 | } 114 | const typescript = result.typescript as string; 115 | responses.typescript = typescript.toLowerCase().includes("y"); 116 | if (responses.typescript) { 117 | prompt.get(typesDestinationFolderPath, function (err, result) { 118 | if (err) { 119 | console.log(err); 120 | return; 121 | } 122 | const typesFolderPath = result.typesFolderPath as string; 123 | responses.typesFolderPath = typesFolderPath; 124 | prompt.get(scaffoldAppDirectory, function (err, result) { 125 | if (err) { 126 | console.log(err); 127 | return; 128 | } 129 | const scaffoldAppDirectory = 130 | result.scaffoldAppDirectory as string; 131 | responses.scaffoldAppDirectory = scaffoldAppDirectory 132 | .toLowerCase() 133 | .includes("y"); 134 | processResponses(responses); 135 | }); 136 | }); 137 | } else { 138 | responses.typesFolderPath = ""; 139 | processResponses(responses); 140 | } 141 | }); 142 | }); 143 | }); 144 | }; 145 | 146 | const processResponses = async (responses: ResponsesInterface) => { 147 | let configTemplate: configInterface = { 148 | databases: {}, 149 | typesFolderPath: "./types", 150 | }; 151 | 152 | if (responses.typescript) { 153 | const typesFolderPath = responses.typesFolderPath; 154 | if (!typesFolderPath) { 155 | console.error("Error: typesFolderPath is null"); 156 | } 157 | // Check if folder typesFolderPath exists 158 | if (!fs.existsSync(typesFolderPath)) { 159 | fs.mkdirSync(typesFolderPath); 160 | // Create a file in the folder called notion-on-next.types.ts 161 | } 162 | 163 | await initializeTypes(`${typesFolderPath}/notion-on-next.types.ts`); 164 | } else { 165 | configTemplate.typesFolderPath = null; 166 | } 167 | 168 | for (let id of responses.databaseIds) { 169 | const database = await getDatabase(id); 170 | if (!database) { 171 | console.log(`Database ${id} not found`); 172 | return; 173 | } 174 | // If the id is unhypenated, then add the hyphens in this pattern 1a67ddff-a029-4cdc-b860-beb64cce9c77 175 | if (id.length === 32) { 176 | id = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice( 177 | 16, 178 | 20 179 | )}-${id.slice(20)}`; 180 | } 181 | 182 | configTemplate.databases[id] = { 183 | id, 184 | name: database.title[0].plain_text, 185 | }; 186 | 187 | if (responses.typescript) { 188 | await createFolderIfDoesNotExist(`${responses.typesFolderPath}`); 189 | generateTypesFromDatabase( 190 | `${responses.typesFolderPath}/notion-on-next.types.ts`, 191 | database 192 | ); 193 | } 194 | 195 | if (responses.downloadMedia) { 196 | await fetchImages(database.id); 197 | } 198 | 199 | if (responses.scaffoldAppDirectory) { 200 | scaffoldApp(database); 201 | } 202 | } 203 | 204 | fs.writeFileSync( 205 | "./notion-on-next.config.json", 206 | JSON.stringify(configTemplate) 207 | ); 208 | // If typescript or downloadMedia is true, then make requests for the databases 209 | // https://liuwill.notion.site/notion-on-next-3b6292c8a6fe4dbaa12f9af26cffe674 210 | }; 211 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export const createFolderIfDoesNotExist = (path: string) => { 4 | // Promisify 5 | return new Promise((resolve, reject) => { 6 | if (!fs.existsSync(path)) { 7 | fs.mkdirSync(path); 8 | console.log('Created folder "' + path + '"'); 9 | resolve("done"); 10 | } else { 11 | resolve("folder already exists"); 12 | } 13 | }); 14 | }; 15 | 16 | export const appendToFile = async ( 17 | filePath: string, 18 | data: string, 19 | callback: () => void 20 | ) => { 21 | return new Promise((resolve, reject) => { 22 | fs.appendFile(filePath, data, (err) => { 23 | if (err) reject(err); 24 | callback(); 25 | resolve("done"); 26 | }); 27 | }); 28 | }; 29 | 30 | export const getFileExtension = (url: string) => { 31 | return url.match(/\.([0-9a-z]+)(?:[\?#]|$)/i)?.[1]; 32 | }; 33 | 34 | export const checkNextVersionNumber = (compatibleVersion = 13) => { 35 | const packageJson = JSON.parse(fs.readFileSync("./package.json", "utf8")); 36 | const nextVersionString = packageJson.dependencies.next; 37 | 38 | const nextVersionNumber = Number( 39 | nextVersionString.replace("^", "").split(".")[0] 40 | ); 41 | if (nextVersionNumber < compatibleVersion) { 42 | return false; 43 | } 44 | return true; 45 | }; 46 | 47 | export const spinalCase = (text: string | undefined) => { 48 | if (!text) { 49 | return ""; 50 | } 51 | return text 52 | .toLowerCase() 53 | .replace(/ /g, "-") 54 | .replace(/[^a-z0-9-]/gi, ""); 55 | }; 56 | 57 | export const pascalCase = (text: string | undefined) => { 58 | if (!text) { 59 | return ""; 60 | } 61 | return spinalCase(text) 62 | .split("-") 63 | .map((word) => { 64 | if (word.length === 0) { 65 | return ""; 66 | } 67 | return word?.[0]?.toUpperCase() + word?.slice(1); 68 | }) 69 | .join(""); 70 | }; 71 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .notion_page_body { 2 | max-width: 700px; 3 | margin: auto; 4 | } 5 | 6 | .notion_heading_1 { 7 | font-size: 2.5rem; 8 | font-weight: 700; 9 | line-height: 1.2; 10 | margin: 1.75rem 0 1.5rem 0; 11 | } 12 | 13 | .notion_heading_2 { 14 | font-size: 2rem; 15 | font-weight: 700; 16 | line-height: 1.2; 17 | margin: 1.75rem 0 1.5rem 0; 18 | } 19 | 20 | .notion_heading_3 { 21 | font-size: 1.5rem; 22 | font-weight: 700; 23 | line-height: 1.2; 24 | margin: 1.75rem 0 1.5rem 0; 25 | } 26 | 27 | .notion_paragraph { 28 | font-weight: 400; 29 | line-height: 1.5; 30 | margin: 1.25rem 0 1.25rem 0; 31 | font-size: 1rem; 32 | } 33 | 34 | .notion_divider { 35 | border: 0; 36 | border-top: 1px solid #eaecef; 37 | margin: 2rem 0; 38 | } 39 | 40 | .notion_column_list { 41 | display: flex; 42 | flex-wrap: nowrap; 43 | margin: 0 -1rem; 44 | } 45 | 46 | .notion_column { 47 | flex: 1 1 0; 48 | padding: 0 1rem; 49 | } 50 | 51 | .notion_bulleted_list_container { 52 | margin-left: 1rem; 53 | } 54 | 55 | .notion_bulleted_list_item { 56 | list-style-type: circle; 57 | } 58 | 59 | .notion_numbered_list_container { 60 | margin-left: 1rem; 61 | } 62 | 63 | .notion_numbered_list_item { 64 | list-style-type: decimal; 65 | } 66 | 67 | .notion_to_do_container { 68 | display: flex; 69 | align-items: center; 70 | } 71 | 72 | .notion_to_do { 73 | margin-right: 0.5rem; 74 | } 75 | 76 | .notion_caption { 77 | font-size: 0.875rem; 78 | font-weight: 400; 79 | line-height: 1.5; 80 | margin: 0 0 1.5rem; 81 | color: #6e7681; 82 | } 83 | 84 | .notion_callout { 85 | padding: 1rem; 86 | border-radius: 3px; 87 | border: 1px solid #eaecef; 88 | background-color: #f9fafb; 89 | margin: 0 0 1.5rem; 90 | } 91 | 92 | .notion_table, 93 | .notion_table_row, 94 | .notion_table_cell { 95 | border: 1px solid; 96 | } 97 | 98 | .notion_table_cell { 99 | padding: 0.5rem; 100 | } 101 | 102 | .notion_table_header { 103 | font-weight: 700; 104 | background-color: #f9fafb; 105 | } 106 | 107 | .notion_image_container, 108 | .notion_video_container { 109 | margin: 1.25rem 0 1.25rem 0; 110 | } 111 | 112 | .notion_callout { 113 | padding: 1rem; 114 | border-radius: 3px; 115 | border: 1px solid #eaecef; 116 | background-color: lightgrey; 117 | margin: 1.5rem 0 1.5rem 0; 118 | display: flex; 119 | align-items: center; 120 | } 121 | 122 | .notion_callout_emoji { 123 | margin-right: 0.5rem; 124 | } 125 | 126 | .notion_link { 127 | color: #0078d4; 128 | text-decoration: none; 129 | } 130 | 131 | .notion_inline_code { 132 | background-color: #2d434f; 133 | color: #ffe1a8; 134 | padding: 0.1rem 0.5rem; 135 | font-size: smaller; 136 | border-radius: 3px; 137 | } 138 | -------------------------------------------------------------------------------- /templates/javascript/Card.jsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import mediaMap from "../../public/notion-media/media-map.json"; 5 | 6 | // The reason why this Card component is specific to a database is because notion-on-next cannot know what properties are in your database. You may want different cards for different databases. 7 | // It is up to you to complete this component by creating components for your database properties. 8 | 9 | export const DATABASENAMEPASCALCard = ({ page, databaseId }) => { 10 | return ( 11 | 22 |
23 |
24 | {mediaMap[databaseId]?.[page.id]?.cover && ( 25 | {page.title 37 | )} 38 |
39 |
40 |
43 | {page.title} 44 |
45 |
{new Date(page.created_time).toLocaleDateString()}
46 |
47 |
48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /templates/javascript/[slug]/page.jsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import Image from "next/image"; 3 | import { notFound } from "next/navigation"; 4 | import { NotionPageBody } from "notion-on-next"; 5 | import React from "react"; 6 | import mediaMap from "../../../public/notion-media/media-map.json"; 7 | import { DATABASENAMEPASCALPageObjectResponse } from "../../../types/notion-on-next.types"; 8 | import { cachedGetBlocks, cachedGetParsedPages } from "../../get"; 9 | 10 | const databaseId = "DATABASEID"; 11 | 12 | export default async function BlogPage({ params }) { 13 | const { slug } = params; 14 | // This may seem like a roundabout way to retrieve the page, but getParsedPages is a per-request cached function. You can read more about it here https://beta.nextjs.org/docs/data-fetching/caching#preload-pattern-with-cache 15 | // The reason why we have to get all of the pages and then filter is because the Notion API can only search for pages via page id and not slug. 16 | const pages = 17 | (await cachedGetParsedPages) < 18 | DATABASENAMEPASCALPageObjectResponse > 19 | databaseId; 20 | const page = pages.find((page) => page.slug === slug); 21 | if (!page) { 22 | notFound(); 23 | } 24 | const blocks = await cachedGetBlocks(page.id); 25 | 26 | return ( 27 |
28 |
29 | {page.title 35 | 36 |
37 |
40 | {page.title} 41 |
42 |
45 | {new Date(page.created_time).toLocaleDateString()} 46 |
47 |
48 |
49 |
50 | 56 |
57 | ); 58 | } 59 | 60 | export async function generateStaticParams() { 61 | // This generates routes using the slugs created from getParsedPages 62 | const pages = 63 | (await cachedGetParsedPages) < 64 | DATABASENAMEPASCALPageObjectResponse > 65 | databaseId; 66 | return pages.map((page) => ({ 67 | slug: page.slug, 68 | })); 69 | } 70 | -------------------------------------------------------------------------------- /templates/javascript/get.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { getBlocks, getParsedPages } from "notion-on-next"; 3 | import { cache } from "react"; 4 | 5 | export const cachedGetParsedPages = cache(async (pageId, filter, sorts) => { 6 | const pages = await getParsedPages(pageId, filter, sorts); 7 | return pages; 8 | }); 9 | 10 | export const cachedGetBlocks = cache(async (pageId) => { 11 | const blocks = await getBlocks(pageId); 12 | return blocks; 13 | }); 14 | -------------------------------------------------------------------------------- /templates/javascript/page.jsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import "notion-on-next/styles.css"; 3 | import { cachedGetParsedPages } from "../get"; 4 | import { DATABASENAMEPASCALCard } from "./DATABASENAMEPASCALCard"; 5 | const databaseId = "DATABASEID"; 6 | 7 | export default async function DATABASENAMEPASCALBlog() { 8 | const pages = await cachedGetParsedPages( 9 | databaseId, 10 | undefined, // Add filters here: https://developers.notion.com/reference/post-database-query-filter 11 | [{ timestamp: "last_edited_time", direction: "descending" }] // Add sorts here: https://developers.notion.com/reference/post-database-query-sort 12 | ); 13 | return ( 14 |
15 |

18 | DATABASENAMEPASCAL Posts 19 |

20 |
30 | {pages.map((page) => ( 31 | 36 | ))} 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /templates/typescript/Card.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import Link from "next/link"; 3 | import Image from "next/image"; 4 | import _mediaMap from "../../public/notion-media/media-map.json"; 5 | import { mediaMapInterface } from "notion-on-next/types/types"; 6 | import { DATABASENAMEPASCALPageObjectResponse } from "../../types/notion-on-next.types"; 7 | const mediaMap: mediaMapInterface = _mediaMap; 8 | 9 | // The reason why this Card component is specific to a database is because notion-on-next cannot know what properties are in your database. You may want different cards for different databases. 10 | // It is up to you to complete this component by creating components for your database properties. 11 | 12 | export const DATABASENAMEPASCALCard = ({ 13 | page, 14 | databaseId, 15 | }: { 16 | page: DATABASENAMEPASCALPageObjectResponse; 17 | databaseId: string; 18 | }) => { 19 | return ( 20 | 31 |
32 |
33 | {mediaMap[databaseId]?.[page.id]?.cover && ( 34 | {page.title 46 | )} 47 |
48 |
49 |
52 | {page.title} 53 |
54 |
{new Date(page.created_time).toLocaleDateString()}
55 |
56 |
57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /templates/typescript/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { notFound } from "next/navigation"; 3 | import { mediaMapInterface, NotionPageBody } from "notion-on-next"; 4 | import React from "react"; 5 | import _mediaMap from "../../../public/notion-media/media-map.json"; 6 | import { DATABASENAMEPASCALPageObjectResponse } from "../../../types/notion-on-next.types"; 7 | import { cachedGetBlocks, cachedGetParsedPages } from "../../get"; 8 | import Image from "next/image"; 9 | import { NotionOnNextPageObjectResponse } from "notion-on-next/types/types"; 10 | 11 | const mediaMap: mediaMapInterface = _mediaMap; 12 | interface PageProps { 13 | slug: string; 14 | } 15 | const databaseId = "DATABASEID"; 16 | 17 | export default async function BlogPage({ 18 | params, 19 | }: { 20 | params: PageProps; 21 | }): Promise { 22 | const { slug } = params; 23 | // This may seem like a roundabout way to retrieve the page, but getParsedPages is a per-request cached function. You can read more about it here https://beta.nextjs.org/docs/data-fetching/caching#preload-pattern-with-cache 24 | // The reason why we have to get all of the pages and then filter is because the Notion API can only search for pages via page id and not slug. 25 | const pages = 26 | await cachedGetParsedPages( 27 | databaseId 28 | ); 29 | const page = pages.find( 30 | (page: NotionOnNextPageObjectResponse) => page.slug === slug 31 | ); 32 | if (!page) { 33 | notFound(); 34 | } 35 | const blocks = await cachedGetBlocks(page.id); 36 | 37 | return ( 38 |
39 |
40 | {mediaMap[databaseId][page.id].cover && ( 41 | {page.title 47 | )} 48 | 49 |
50 |
53 | {page.title} 54 |
55 |
58 | {new Date(page.created_time).toLocaleDateString()} 59 |
60 |
61 |
62 |
63 | 69 |
70 | ); 71 | } 72 | 73 | export async function generateStaticParams() { 74 | // This generates routes using the slugs created from getParsedPages 75 | const pages = 76 | await cachedGetParsedPages( 77 | databaseId 78 | ); 79 | return pages.map((page: NotionOnNextPageObjectResponse) => ({ 80 | slug: page.slug, 81 | })); 82 | } 83 | -------------------------------------------------------------------------------- /templates/typescript/get.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { 3 | BlockObjectResponse, 4 | PartialBlockObjectResponse, 5 | } from "@notionhq/client/build/src/api-endpoints"; 6 | import { getBlocks, getParsedPages } from "notion-on-next"; 7 | import { cache } from "react"; 8 | 9 | // Need to talk to the Notion team about using types for filter and sorts as they are not currently exposed by the SDK, so leaving them as any for now. 10 | export const cachedGetParsedPages = cache( 11 | async (pageId: string, filter?: any, sorts?: any): Promise => { 12 | const pages: Type[] = await getParsedPages(pageId, filter, sorts); 13 | return pages; 14 | } 15 | ); 16 | 17 | export const cachedGetBlocks = cache( 18 | async ( 19 | pageId: string 20 | ): Promise<(PartialBlockObjectResponse | BlockObjectResponse)[]> => { 21 | const blocks = await getBlocks(pageId); 22 | return blocks; 23 | } 24 | ); 25 | -------------------------------------------------------------------------------- /templates/typescript/page.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { DATABASENAMEPASCALPageObjectResponse } from "../../types/notion-on-next.types"; 3 | import { cachedGetParsedPages } from "../get"; 4 | import { DATABASENAMEPASCALCard } from "./DATABASENAMEPASCALCard"; 5 | import "notion-on-next/styles.css"; 6 | const databaseId = "DATABASEID"; 7 | 8 | export default async function DATABASENAMEPASCALBlog() { 9 | const pages = 10 | await cachedGetParsedPages( 11 | databaseId, 12 | undefined, // Add filters here: https://developers.notion.com/reference/post-database-query-filter 13 | [{ timestamp: "last_edited_time", direction: "descending" }] // Add sorts here: https://developers.notion.com/reference/post-database-query-sort 14 | ); 15 | return ( 16 |
17 |

20 | DATABASENAMEPASCAL Posts 21 |

22 |
32 | {pages.map((page) => ( 33 | 38 | ))} 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "declaration": true, 5 | "outDir": "./dist", 6 | "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 7 | 8 | "module": "commonjs" /* Specify what module code is generated. */, 9 | 10 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 11 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 12 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 13 | /* Type Checking */ 14 | "strict": true /* Enable all strict type-checking options. */, 15 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 16 | }, 17 | // ignore files inside of /src/templates 18 | "exclude": ["node_modules", "./dist/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /types/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.asyncComponent = void 0; 4 | function asyncComponent(fn) { 5 | // @ts-ignore 6 | return fn; 7 | } 8 | exports.asyncComponent = asyncComponent; 9 | -------------------------------------------------------------------------------- /types/types.ts: -------------------------------------------------------------------------------- 1 | import { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints"; 2 | 3 | export function asyncComponent( 4 | fn: (arg: T) => Promise 5 | ): (arg: T) => R { 6 | // @ts-ignore 7 | return fn as (arg: T) => R; 8 | } 9 | 10 | export interface NotionOnNextPageObjectResponse extends PageObjectResponse { 11 | slug: string | undefined; 12 | title: string | undefined; 13 | coverImage: string | undefined; 14 | databaseName: string | undefined; 15 | databaseId: string | undefined; 16 | } 17 | 18 | export interface mediaMapInterface { 19 | [key: string]: { 20 | [key: string]: { 21 | [key: string]: string; 22 | }; 23 | }; 24 | } 25 | 26 | export type MediaMap = { 27 | [key: string]: MediaMapPage | {}; 28 | }; 29 | 30 | export type MediaMapPage = { 31 | cover: string; 32 | [key: string]: string; 33 | }; 34 | 35 | export interface configInterface { 36 | databases: { 37 | [key: string]: { 38 | id: string; 39 | name: string; 40 | }; 41 | }; 42 | typesFolderPath: string | null; 43 | } 44 | 45 | type BooleanFilter = { 46 | type: "checkbox"; 47 | value: boolean; 48 | }; 49 | 50 | type DateFilter = { 51 | type: "date"; 52 | value: string; 53 | }; 54 | 55 | export interface PagesFilters { 56 | [key: string]: BooleanFilter | DateFilter; 57 | } 58 | --------------------------------------------------------------------------------