diff --git a/.env b/.env new file mode 100644 index 0000000..c9ae81a --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +BLOG_INDEX_ID= +NOTION_TOKEN= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0aa4f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +.env.local* + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.blog_index_data +.blog_index_data_previews + +.now +.vercel \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..0b49510 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "semi": false, + "trailingComma": "es5" +} diff --git a/assets/table-view.png b/assets/table-view.png new file mode 100644 index 0000000..bda62f8 Binary files /dev/null and b/assets/table-view.png differ diff --git a/license b/license new file mode 100644 index 0000000..d13cc4b --- /dev/null +++ b/license @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 0000000..ccf37e0 --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,14 @@ +const escape = require('shell-quote').quote +const isWin = process.platform === 'win32' + +module.exports = { + '**/*.{js,jsx,ts,tsx,json,md,mdx,css,html,yml,yaml,scss,sass}': filenames => { + const escapedFileNames = filenames + .map(filename => `"${isWin ? filename : escape([filename])}"`) + .join(' ') + return [ + `prettier --ignore-path='.gitignore' --write ${escapedFileNames}`, + `git add ${escapedFileNames}`, + ] + }, +} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..9bc3dd4 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..f575c73 --- /dev/null +++ b/next.config.js @@ -0,0 +1,60 @@ +const fs = require('fs') +const path = require('path') +const { + NOTION_TOKEN, + BLOG_INDEX_ID, +} = require('./src/lib/notion/server-constants') + +try { + fs.unlinkSync(path.resolve('.blog_index_data')) +} catch (_) { + /* non fatal */ +} +try { + fs.unlinkSync(path.resolve('.blog_index_data_previews')) +} catch (_) { + /* non fatal */ +} + +const warnOrError = + process.env.NODE_ENV !== 'production' + ? console.warn + : (msg) => { + throw new Error(msg) + } + +if (!NOTION_TOKEN) { + // We aren't able to build or serve images from Notion without the + // NOTION_TOKEN being populated + warnOrError( + `\nNOTION_TOKEN is missing from env, this will result in an error\n` + + `Make sure to provide one before starting Next.js` + ) +} + +if (!BLOG_INDEX_ID) { + // We aren't able to build or serve images from Notion without the + // NOTION_TOKEN being populated + warnOrError( + `\nBLOG_INDEX_ID is missing from env, this will result in an error\n` + + `Make sure to provide one before starting Next.js` + ) +} + +module.exports = { + webpack(cfg, { dev, isServer }) { + // only compile build-rss in production server build + if (dev || !isServer) return cfg + + // we're in build mode so enable shared caching for Notion data + process.env.USE_CACHE = 'true' + + const originalEntry = cfg.entry + cfg.entry = async () => { + const entries = { ...(await originalEntry()) } + entries['build-rss.js'] = './src/lib/build-rss.ts' + return entries + } + return cfg + }, +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8634d68 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "notion-blog", + "version": "0.1.0", + "scripts": { + "dev": "next dev", + "start": "next start", + "build": "next build && node .next/server/build-rss.js", + "format": "prettier --write \"**/*.{js,jsx,json,ts,tsx,md,mdx,css,html,yml,yaml,scss,sass}\" --ignore-path .gitignore", + "lint-staged": "lint-staged" + }, + "pre-commit": "lint-staged", + "dependencies": { + "@zeit/react-jsx-parser": "2.0.0", + "async-sema": "3.1.0", + "github-slugger": "1.2.1", + "katex": "0.12.0", + "next": "^11.1.2", + "prismjs": "1.17.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "uuid": "8.1.0" + }, + "devDependencies": { + "@types/katex": "0.11.0", + "@types/node": "14.14.31", + "@types/react": "17.0.2", + "lint-staged": "10.5.4", + "pre-commit": "1.2.2", + "prettier": "2.2.1", + "typescript": "^4.4.4" + } +} diff --git a/public/avatar.png b/public/avatar.png new file mode 100644 index 0000000..87bb613 Binary files /dev/null and b/public/avatar.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..4965832 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/notion.png b/public/notion.png new file mode 100644 index 0000000..185e3a6 Binary files /dev/null and b/public/notion.png differ diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000..c9ad3fc Binary files /dev/null and b/public/og-image.png differ diff --git a/public/vercel-and-notion.png b/public/vercel-and-notion.png new file mode 100644 index 0000000..5aedc7b Binary files /dev/null and b/public/vercel-and-notion.png differ diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2fe537b --- /dev/null +++ b/readme.md @@ -0,0 +1,85 @@ +# Notion Blog + +This is an example Next.js project that shows Next.js' upcoming SSG (static-site generation) support using Notion's **private** API for a backend. + +**Note**: This example uses the experimental SSG hooks only available in the Next.js canary branch! The APIs used within this example will change over time. Since it is using a private API and experimental features, use at your own risk as these things could change at any moment. + +**Live Example hosted on Vercel**: https://notion-blog.vercel.app/ + +## Getting Started + +To view the steps to setup Notion to work with this example view the post at https://notion-blog.vercel.app/blog/my-first-post or follow the steps below. + +## Deploy Your Own + +Deploy your own Notion blog with Vercel. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/ijjk/notion-blog/tree/main&project-name=notion-blog&repository-name=notion-blog) + +or + +1. Clone this repo `git clone https://github.com/ijjk/notion-blog.git` +2. Configure project with [`vc`](https://vercel.com/download) +3. Add your `NOTION_TOKEN` and `BLOG_INDEX_ID` as environment variables in [your project](https://vercel.com/docs/integrations?query=envir#project-level-apis/project-based-environment-variables). See [here](#getting-blog-index-and-token) for how to find these values +4. Do final deployment with `vc` + +Note: if redeploying with `vc` locally and you haven't made any changes to the application's source and only edited in Notion you will need use `vc -f` to bypass build de-duping + +## Creating Your Pages Table + +**Note**: this is auto run if a table isn't detected the first time visiting `/blog` + +### Using the Pre-Configured Script + +1. Create a blank page in Notion +2. Clone this repo `git clone https://github.com/ijjk/notion-blog.git` +3. Install dependencies `cd notion-blog && yarn` +4. Run script to create table `NOTION_TOKEN='token' BLOG_INDEX_ID='new-page-id' node scripts/create-table.js` See [here](#getting-blog-index-and-token) for finding the id for the new page + +### Manually Creating the Table + +1. Create a blank page in Notion +2. Create a **inline** table on that page, don't use a full page table as it requires querying differently +3. Add the below fields to the table + +The table should have the following properties: + +- `Page`: this the blog post's page +- `Slug`: this is the blog post's slug relative to `/blog`, it should be a text property +- `Published`: this filters blog posts in **production**, it should be a checkbox property +- `Date`: this is when the blog post appears as posted, it should be a date property +- `Authors`: this is a list of Notion users that wrote the post, it should be a person property + +![Example Blog Posts Table](./assets/table-view.png) + +## Getting Blog Index and Token + +To get your blog index value, open Notion and Navigate to the Notion page with the table you created above. While on this page you should be able to get the page id from either: + +- the URL, if the URL of your page is https://www.notion.so/Blog-S5qv1QbUzM1wxm3H3SZRQkupi7XjXTul then your `BLOG_INDEX_ID` is `S5qv1QbU-zM1w-xm3H-3SZR-Qkupi7XjXTul` +- the `loadPageChunk` request, if you open your developer console and go to the network tab then reload the page you should see a request for `loadPageChunk` and in the request payload you should see a `pageId` and that is your `BLOG_INDEX_ID` + +To get your Notion token, open Notion and look for the `token_v2` cookie. + +## Creating Blog Posts + +1. In Notion click new on the table to add a new row +2. Fill in the Page name, slug, Date, and Authors +3. At the top of the content area add the content you want to show as a preview (keep this under 2 paragraphs) +4. Add a divider block under your preview content +5. Add the rest of your content under the divider block + +## Running Locally + +To run the project locally you need to follow steps 1 and 2 of [deploying](#deploy-your-own) and then follow the below steps + +1. Install dependencies `yarn` +2. Expose `NOTION_TOKEN` and `BLOG_INDEX_ID` in your environment `export NOTION_TOKEN=''`and `export BLOG_INDEX_ID=''` or `set NOTION_TOKEN="" && set BLOG_INDEX_ID=""` for Windows +3. Run next in development mode `yarn dev` +4. Build and run in production mode `yarn build && yarn start` + +## Credits + +- Guillermo Rauch [@rauchg](https://twitter.com/rauchg) for the initial idea +- Shu Ding [@shuding\_](https://twitter.com/shuding_) for the design help +- Luis Alvarez [@luis_fades](https://twitter.com/luis_fades) for design help and bug catching diff --git a/scripts/create-table.js b/scripts/create-table.js new file mode 100644 index 0000000..8da3b2f --- /dev/null +++ b/scripts/create-table.js @@ -0,0 +1,3 @@ +const main = require('../src/lib/notion/createTable') + +main() diff --git a/src/components/code.tsx b/src/components/code.tsx new file mode 100644 index 0000000..7887e31 --- /dev/null +++ b/src/components/code.tsx @@ -0,0 +1,38 @@ +import Prism from 'prismjs' +import 'prismjs/components/prism-jsx' + +const Code = ({ children, language = 'javascript' }) => { + return ( + <> +
+        
+      
+ + + + ) +} + +export default Code diff --git a/src/components/counter.tsx b/src/components/counter.tsx new file mode 100644 index 0000000..d2ed70f --- /dev/null +++ b/src/components/counter.tsx @@ -0,0 +1,15 @@ +import React, { useState } from 'react' + +const Counter = ({ initialValue }) => { + const [clicks, setClicks] = useState(initialValue) + + return ( +
+

Count: {clicks}

+ + +
+ ) +} + +export default Counter diff --git a/src/components/dynamic.tsx b/src/components/dynamic.tsx new file mode 100644 index 0000000..bf71990 --- /dev/null +++ b/src/components/dynamic.tsx @@ -0,0 +1,16 @@ +import dynamic from 'next/dynamic' +import ExtLink from './ext-link' + +export default { + // default tags + ol: 'ol', + ul: 'ul', + li: 'li', + p: 'p', + blockquote: 'blockquote', + a: ExtLink, + + Code: dynamic(() => import('./code')), + Counter: dynamic(() => import('./counter')), + Equation: dynamic(() => import('./equation')), +} diff --git a/src/components/equation.tsx b/src/components/equation.tsx new file mode 100644 index 0000000..2b3cc0e --- /dev/null +++ b/src/components/equation.tsx @@ -0,0 +1,28 @@ +import { renderToString, ParseError } from 'katex' + +function render(expression: string, displayMode: boolean): string { + let result: string + try { + result = renderToString(expression, { displayMode: displayMode }) + } catch (e) { + if (e instanceof ParseError) { + result = e.message + } + if (process.env.NODE_ENV !== 'production') { + console.error(e) + } + } + return result +} + +const Equation = ({ children, displayMode = true }) => { + return ( + + ) +} + +export default Equation diff --git a/src/components/ext-link.tsx b/src/components/ext-link.tsx new file mode 100644 index 0000000..745956a --- /dev/null +++ b/src/components/ext-link.tsx @@ -0,0 +1,4 @@ +const ExtLink = (props) => ( + +) +export default ExtLink diff --git a/src/components/features.tsx b/src/components/features.tsx new file mode 100644 index 0000000..2f3957c --- /dev/null +++ b/src/components/features.tsx @@ -0,0 +1,56 @@ +import Lightning from './svgs/lightning' +import Jamstack from './svgs/jamstack' +import Wifi from './svgs/wifi' +import Lighthouse from './svgs/lighthouse' +import Plus from './svgs/plus' +import Notion from './svgs/notion' +import Edit from './svgs/edit' +import Scroll from './svgs/scroll' + +const features = [ + { + text: 'Blazing fast', + icon: Lightning, + }, + { + text: 'JAMstack based', + icon: Jamstack, + }, + { + text: 'Always available', + icon: Wifi, + }, + { + text: 'Customizable', + icon: Edit, + }, + { + text: 'Incremental SSG', + icon: Plus, + }, + { + text: 'MIT Licensed', + icon: Scroll, + }, + { + text: 'Edit via Notion', + icon: Notion, + }, + { + text: 'Great scores', + icon: Lighthouse, + }, +] + +const Features = () => ( +
+ {features.map(({ text, icon: Icon }) => ( +
+ {Icon && } +

{text}

+
+ ))} +
+) + +export default Features diff --git a/src/components/footer.tsx b/src/components/footer.tsx new file mode 100644 index 0000000..54a3f5d --- /dev/null +++ b/src/components/footer.tsx @@ -0,0 +1,25 @@ +import ExtLink from './ext-link' + +export default function Footer() { + return ( + <> +
+ Deploy your own! + + deploy to Vercel button + + + or{' '} + + view source + + +
+ + ) +} diff --git a/src/components/header.tsx b/src/components/header.tsx new file mode 100644 index 0000000..122d87b --- /dev/null +++ b/src/components/header.tsx @@ -0,0 +1,52 @@ +import Link from 'next/link' +import Head from 'next/head' +import ExtLink from './ext-link' +import { useRouter } from 'next/router' +import styles from '../styles/header.module.css' + +const navItems: { label: string; page?: string; link?: string }[] = [ + { label: 'Home', page: '/' }, + { label: 'Blog', page: '/blog' }, + { label: 'Contact', page: '/contact' }, + { label: 'Source Code', link: 'https://github.com/ijjk/notion-blog' }, +] + +const ogImageUrl = 'https://notion-blog.now.sh/og-image.png' + +const Header = ({ titlePre = '' }) => { + const { pathname } = useRouter() + + return ( +
+ + {titlePre ? `${titlePre} |` : ''} My Notion Blog + + + + + + + + +
+ ) +} + +export default Header diff --git a/src/components/heading.tsx b/src/components/heading.tsx new file mode 100644 index 0000000..674a538 --- /dev/null +++ b/src/components/heading.tsx @@ -0,0 +1,28 @@ +const collectText = (el, acc = []) => { + if (el) { + if (typeof el === 'string') acc.push(el) + if (Array.isArray(el)) el.map((item) => collectText(item, acc)) + if (typeof el === 'object') collectText(el.props && el.props.children, acc) + } + return acc.join('').trim() +} + +const Heading = ({ children: component, id }: { children: any; id?: any }) => { + const children = component.props.children || '' + let text = children + + if (null == id) { + id = collectText(text) + .toLowerCase() + .replace(/\s/g, '-') + .replace(/[?!:]/g, '') + } + + return ( + + {component} + + ) +} + +export default Heading diff --git a/src/components/svgs/edit.tsx b/src/components/svgs/edit.tsx new file mode 100644 index 0000000..042ac2a --- /dev/null +++ b/src/components/svgs/edit.tsx @@ -0,0 +1,24 @@ +const Edit = (props) => ( + + + + + + + + + + + +) + +export default Edit diff --git a/src/components/svgs/envelope.tsx b/src/components/svgs/envelope.tsx new file mode 100644 index 0000000..af9a1a8 --- /dev/null +++ b/src/components/svgs/envelope.tsx @@ -0,0 +1,7 @@ +const Envelope = (props) => ( + + + +) + +export default Envelope diff --git a/src/components/svgs/github.tsx b/src/components/svgs/github.tsx new file mode 100644 index 0000000..3522b59 --- /dev/null +++ b/src/components/svgs/github.tsx @@ -0,0 +1,7 @@ +const Github = (props) => ( + + + +) + +export default Github diff --git a/src/components/svgs/jamstack.tsx b/src/components/svgs/jamstack.tsx new file mode 100644 index 0000000..82ea873 --- /dev/null +++ b/src/components/svgs/jamstack.tsx @@ -0,0 +1,18 @@ +const Jamstack = (props) => ( + + + +) + +export default Jamstack diff --git a/src/components/svgs/lighthouse.tsx b/src/components/svgs/lighthouse.tsx new file mode 100644 index 0000000..2243667 --- /dev/null +++ b/src/components/svgs/lighthouse.tsx @@ -0,0 +1,19 @@ +const Lighthouse = (props) => ( + + + + +) + +export default Lighthouse diff --git a/src/components/svgs/lightning.tsx b/src/components/svgs/lightning.tsx new file mode 100644 index 0000000..8f394f1 --- /dev/null +++ b/src/components/svgs/lightning.tsx @@ -0,0 +1,18 @@ +const Lightning = (props) => ( + + + +) + +export default Lightning diff --git a/src/components/svgs/linkedin.tsx b/src/components/svgs/linkedin.tsx new file mode 100644 index 0000000..4b70f4f --- /dev/null +++ b/src/components/svgs/linkedin.tsx @@ -0,0 +1,7 @@ +const Linkedin = (props) => ( + + + +) + +export default Linkedin diff --git a/src/components/svgs/notion.tsx b/src/components/svgs/notion.tsx new file mode 100644 index 0000000..adda82b --- /dev/null +++ b/src/components/svgs/notion.tsx @@ -0,0 +1,13 @@ +const Notion = (props) => ( + + + +) + +export default Notion diff --git a/src/components/svgs/plus.tsx b/src/components/svgs/plus.tsx new file mode 100644 index 0000000..1d90eec --- /dev/null +++ b/src/components/svgs/plus.tsx @@ -0,0 +1,19 @@ +const Plus = (props) => ( + + + + +) + +export default Plus diff --git a/src/components/svgs/scroll.tsx b/src/components/svgs/scroll.tsx new file mode 100644 index 0000000..26326f6 --- /dev/null +++ b/src/components/svgs/scroll.tsx @@ -0,0 +1,22 @@ +const Scroll = (props) => ( + + + + + + + +) + +export default Scroll diff --git a/src/components/svgs/twitter.tsx b/src/components/svgs/twitter.tsx new file mode 100644 index 0000000..a9550e0 --- /dev/null +++ b/src/components/svgs/twitter.tsx @@ -0,0 +1,7 @@ +const Twitter = (props) => ( + + + +) + +export default Twitter diff --git a/src/components/svgs/wifi.tsx b/src/components/svgs/wifi.tsx new file mode 100644 index 0000000..a5371ea --- /dev/null +++ b/src/components/svgs/wifi.tsx @@ -0,0 +1,19 @@ +const Wifi = (props) => ( + + + + + + +) + +export default Wifi diff --git a/src/components/svgs/zeit.tsx b/src/components/svgs/zeit.tsx new file mode 100644 index 0000000..a062313 --- /dev/null +++ b/src/components/svgs/zeit.tsx @@ -0,0 +1,25 @@ +const Zeit = (props) => ( + + {'Logotype - Black'} + + + + + + + + +) + +export default Zeit diff --git a/src/lib/blog-helpers.ts b/src/lib/blog-helpers.ts new file mode 100644 index 0000000..e55a514 --- /dev/null +++ b/src/lib/blog-helpers.ts @@ -0,0 +1,30 @@ +export const getBlogLink = (slug: string) => { + return `/blog/${slug}` +} + +export const getDateStr = date => { + return new Date(date).toLocaleString('en-US', { + month: 'long', + day: '2-digit', + year: 'numeric', + }) +} + +export const postIsPublished = (post: any) => { + return post.Published === 'Yes' +} + +export const normalizeSlug = slug => { + if (typeof slug !== 'string') return slug + + let startingSlash = slug.startsWith('/') + let endingSlash = slug.endsWith('/') + + if (startingSlash) { + slug = slug.substr(1) + } + if (endingSlash) { + slug = slug.substr(0, slug.length - 1) + } + return startingSlash || endingSlash ? normalizeSlug(slug) : slug +} diff --git a/src/lib/build-rss.ts b/src/lib/build-rss.ts new file mode 100644 index 0000000..742a5ce --- /dev/null +++ b/src/lib/build-rss.ts @@ -0,0 +1,113 @@ +import { resolve } from 'path' +import { writeFile } from './fs-helpers' +import { renderToStaticMarkup } from 'react-dom/server' + +import { textBlock } from './notion/renderers' +import getBlogIndex from './notion/getBlogIndex' +import getNotionUsers from './notion/getNotionUsers' +import { postIsPublished, getBlogLink } from './blog-helpers' +import { loadEnvConfig } from '@next/env' +import serverConstants from './notion/server-constants' + +// must use weird syntax to bypass auto replacing of NODE_ENV +process.env['NODE' + '_ENV'] = 'production' +process.env.USE_CACHE = 'true' + +// constants +const NOW = new Date().toJSON() + +function mapToAuthor(author) { + return `${author.full_name}` +} + +function decode(string) { + return string + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function mapToEntry(post) { + return ` + + ${post.link} + ${decode(post.title)} + + ${new Date(post.date).toJSON()} + +
+ ${renderToStaticMarkup( + post.preview + ? (post.preview || []).map((block, idx) => + textBlock(block, false, post.title + idx) + ) + : post.content + )} +

+ Read more +

+
+
+ ${(post.authors || []).map(mapToAuthor).join('\n ')} +
` +} + +function concat(total, item) { + return total + item +} + +function createRSS(blogPosts = []) { + const postsString = blogPosts.map(mapToEntry).reduce(concat, '') + + return ` + + My Blog + Blog + + + ${NOW} + My Notion Blog${postsString} + ` +} + +async function main() { + await loadEnvConfig(process.cwd()) + serverConstants.NOTION_TOKEN = process.env.NOTION_TOKEN + serverConstants.BLOG_INDEX_ID = serverConstants.normalizeId( + process.env.BLOG_INDEX_ID + ) + + const postsTable = await getBlogIndex(true) + const neededAuthors = new Set() + + const blogPosts = Object.keys(postsTable) + .map((slug) => { + const post = postsTable[slug] + if (!postIsPublished(post)) return + + post.authors = post.Authors || [] + + for (const author of post.authors) { + neededAuthors.add(author) + } + return post + }) + .filter(Boolean) + + const { users } = await getNotionUsers([...neededAuthors]) + + blogPosts.forEach((post) => { + post.authors = post.authors.map((id) => users[id]) + post.link = getBlogLink(post.Slug) + post.title = post.Page + post.date = post.Date + }) + + const outputPath = './public/atom' + await writeFile(resolve(outputPath), createRSS(blogPosts)) + console.log(`Atom feed file generated at \`${outputPath}\``) +} + +main().catch((error) => console.error(error)) diff --git a/src/lib/fs-helpers.ts b/src/lib/fs-helpers.ts new file mode 100644 index 0000000..7de1195 --- /dev/null +++ b/src/lib/fs-helpers.ts @@ -0,0 +1,5 @@ +import fs from 'fs' +import { promisify } from 'util' + +export const readFile = promisify(fs.readFile) +export const writeFile = promisify(fs.writeFile) diff --git a/src/lib/notion/createTable.js b/src/lib/notion/createTable.js new file mode 100644 index 0000000..d47a718 --- /dev/null +++ b/src/lib/notion/createTable.js @@ -0,0 +1,374 @@ +// commonjs so it can be run without transpiling +const { v4: uuid } = require('uuid') +const fetch = require('node-fetch') +const { + BLOG_INDEX_ID: pageId, + NOTION_TOKEN, + API_ENDPOINT, +} = require('./server-constants') + +async function main() { + const userId = await getUserId() + const transactionId = () => uuid() + const collectionId = uuid() + const collectionViewId = uuid() + const viewId = uuid() + const now = Date.now() + const pageId1 = uuid() + const pageId2 = uuid() + const pageId3 = uuid() + let existingBlockId = await getExistingexistingBlockId() + + const requestBody = { + requestId: uuid(), + transactions: [ + { + id: transactionId(), + operations: [ + { + id: collectionId, + table: 'block', + path: [], + command: 'update', + args: { + id: collectionId, + type: 'collection_view', + collection_id: collectionViewId, + view_ids: [viewId], + properties: {}, + created_time: now, + last_edited_time: now, + }, + }, + { + id: pageId1, + table: 'block', + path: [], + command: 'update', + args: { + id: pageId1, + type: 'page', + parent_id: collectionViewId, + parent_table: 'collection', + alive: true, + properties: {}, + created_time: now, + last_edited_time: now, + }, + }, + { + id: pageId2, + table: 'block', + path: [], + command: 'update', + args: { + id: pageId2, + type: 'page', + parent_id: collectionViewId, + parent_table: 'collection', + alive: true, + properties: {}, + created_time: now, + last_edited_time: now, + }, + }, + { + id: pageId3, + table: 'block', + path: [], + command: 'update', + args: { + id: pageId3, + type: 'page', + parent_id: collectionViewId, + parent_table: 'collection', + alive: true, + properties: {}, + created_time: now, + last_edited_time: now, + }, + }, + { + id: viewId, + table: 'collection_view', + path: [], + command: 'update', + args: { + id: viewId, + version: 0, + type: 'table', + name: 'Default View', + format: { + table_properties: [ + { property: 'title', visible: true, width: 276 }, + { property: 'S6_"', visible: true }, + { property: 'la`A', visible: true }, + { property: 'a`af', visible: true }, + { property: 'ijjk', visible: true }, + ], + table_wrap: true, + }, + query2: { + aggregations: [{ property: 'title', aggregator: 'count' }], + }, + page_sort: [pageId1, pageId2, pageId3], + parent_id: collectionId, + parent_table: 'block', + alive: true, + }, + }, + { + id: collectionViewId, + table: 'collection', + path: [], + command: 'update', + args: { + id: collectionViewId, + schema: { + title: { name: 'Page', type: 'title' }, + 'S6_"': { name: 'Slug', type: 'text' }, + 'la`A': { name: 'Published', type: 'checkbox' }, + 'a`af': { name: 'Date', type: 'date' }, + ijjk: { name: 'Authors', type: 'person' }, + }, + format: { + collection_page_properties: [ + { property: 'S6_"', visible: true }, + { property: 'la`A', visible: true }, + { property: 'a`af', visible: true }, + { property: 'ijjk', visible: true }, + ], + }, + parent_id: collectionId, + parent_table: 'block', + alive: true, + }, + }, + { + id: collectionId, + table: 'block', + path: [], + command: 'update', + args: { parent_id: pageId, parent_table: 'block', alive: true }, + }, + { + table: 'block', + id: pageId, + path: ['content'], + command: 'listAfter', + args: { + ...(existingBlockId + ? { + after: existingBlockId, + } + : {}), + id: collectionId, + }, + }, + { + table: 'block', + id: collectionId, + path: ['created_by_id'], + command: 'set', + args: userId, + }, + { + table: 'block', + id: collectionId, + path: ['created_by_table'], + command: 'set', + args: 'notion_user', + }, + { + table: 'block', + id: collectionId, + path: ['last_edited_time'], + command: 'set', + args: now, + }, + { + table: 'block', + id: collectionId, + path: ['last_edited_by_id'], + command: 'set', + args: userId, + }, + { + table: 'block', + id: collectionId, + path: ['last_edited_by_table'], + command: 'set', + args: 'notion_user', + }, + { + table: 'block', + id: pageId1, + path: ['created_by_id'], + command: 'set', + args: userId, + }, + { + table: 'block', + id: pageId1, + path: ['created_by_table'], + command: 'set', + args: 'notion_user', + }, + { + table: 'block', + id: pageId1, + path: ['last_edited_time'], + command: 'set', + args: now, + }, + { + table: 'block', + id: pageId1, + path: ['last_edited_by_id'], + command: 'set', + args: userId, + }, + { + table: 'block', + id: pageId1, + path: ['last_edited_by_table'], + command: 'set', + args: 'notion_user', + }, + { + table: 'block', + id: pageId2, + path: ['created_by_id'], + command: 'set', + args: userId, + }, + { + table: 'block', + id: pageId2, + path: ['created_by_table'], + command: 'set', + args: 'notion_user', + }, + { + table: 'block', + id: pageId2, + path: ['last_edited_time'], + command: 'set', + args: now, + }, + { + table: 'block', + id: pageId2, + path: ['last_edited_by_id'], + command: 'set', + args: userId, + }, + { + table: 'block', + id: pageId2, + path: ['last_edited_by_table'], + command: 'set', + args: 'notion_user', + }, + { + table: 'block', + id: pageId3, + path: ['created_by_id'], + command: 'set', + args: userId, + }, + { + table: 'block', + id: pageId3, + path: ['created_by_table'], + command: 'set', + args: 'notion_user', + }, + { + table: 'block', + id: pageId3, + path: ['last_edited_time'], + command: 'set', + args: now, + }, + { + table: 'block', + id: pageId3, + path: ['last_edited_by_id'], + command: 'set', + args: userId, + }, + { + table: 'block', + id: pageId3, + path: ['last_edited_by_table'], + command: 'set', + args: 'notion_user', + }, + ], + }, + ], + } + + const res = await fetch(`${API_ENDPOINT}/submitTransaction`, { + method: 'POST', + headers: { + cookie: `token_v2=${NOTION_TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!res.ok) { + throw new Error(`Failed to add table, request status ${res.status}`) + } +} + +async function getExistingexistingBlockId() { + const res = await fetch(`${API_ENDPOINT}/loadPageChunk`, { + method: 'POST', + headers: { + cookie: `token_v2=${NOTION_TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + pageId, + limit: 25, + cursor: { stack: [] }, + chunkNumber: 0, + verticalColumns: false, + }), + }) + + if (!res.ok) { + throw new Error( + `failed to get existing block id, request status: ${res.status}` + ) + } + const data = await res.json() + const id = Object.keys(data ? data.recordMap.block : {}).find( + id => id !== pageId + ) + return id || uuid() +} + +async function getUserId() { + const res = await fetch(`${API_ENDPOINT}/loadUserContent`, { + method: 'POST', + headers: { + cookie: `token_v2=${NOTION_TOKEN}`, + 'content-type': 'application/json', + }, + body: '{}', + }) + + if (!res.ok) { + throw new Error( + `failed to get Notion user id, request status: ${res.status}` + ) + } + const data = await res.json() + return Object.keys(data.recordMap.notion_user)[0] +} + +module.exports = main diff --git a/src/lib/notion/getBlogIndex.ts b/src/lib/notion/getBlogIndex.ts new file mode 100644 index 0000000..2c02622 --- /dev/null +++ b/src/lib/notion/getBlogIndex.ts @@ -0,0 +1,76 @@ +import { Sema } from 'async-sema' +import rpc, { values } from './rpc' +import getTableData from './getTableData' +import { getPostPreview } from './getPostPreview' +import { readFile, writeFile } from '../fs-helpers' +import { BLOG_INDEX_ID, BLOG_INDEX_CACHE } from './server-constants' + +export default async function getBlogIndex(previews = true) { + let postsTable: any = null + const useCache = process.env.USE_CACHE === 'true' + const cacheFile = `${BLOG_INDEX_CACHE}${previews ? '_previews' : ''}` + + if (useCache) { + try { + postsTable = JSON.parse(await readFile(cacheFile, 'utf8')) + } catch (_) { + /* not fatal */ + } + } + + if (!postsTable) { + try { + const data = await rpc('loadPageChunk', { + pageId: BLOG_INDEX_ID, + limit: 100, // TODO: figure out Notion's way of handling pagination + cursor: { stack: [] }, + chunkNumber: 0, + verticalColumns: false, + }) + + // Parse table with posts + const tableBlock = values(data.recordMap.block).find( + (block: any) => block.value.type === 'collection_view' + ) + + postsTable = await getTableData(tableBlock, true) + } catch (err) { + console.warn( + `Failed to load Notion posts, have you run the create-table script?` + ) + return {} + } + + // only get 10 most recent post's previews + const postsKeys = Object.keys(postsTable).splice(0, 10) + + const sema = new Sema(3, { capacity: postsKeys.length }) + + if (previews) { + await Promise.all( + postsKeys + .sort((a, b) => { + const postA = postsTable[a] + const postB = postsTable[b] + const timeA = postA.Date + const timeB = postB.Date + return Math.sign(timeB - timeA) + }) + .map(async (postKey) => { + await sema.acquire() + const post = postsTable[postKey] + post.preview = post.id + ? await getPostPreview(postsTable[postKey].id) + : [] + sema.release() + }) + ) + } + + if (useCache) { + writeFile(cacheFile, JSON.stringify(postsTable), 'utf8').catch(() => {}) + } + } + + return postsTable +} diff --git a/src/lib/notion/getNotionAssetUrls.ts b/src/lib/notion/getNotionAssetUrls.ts new file mode 100644 index 0000000..2db1eab --- /dev/null +++ b/src/lib/notion/getNotionAssetUrls.ts @@ -0,0 +1,40 @@ +import fetch from 'node-fetch' +import { getError } from './rpc' +import { NextApiResponse } from 'next' +import { NOTION_TOKEN, API_ENDPOINT } from './server-constants' + +export default async function getNotionAsset( + res: NextApiResponse, + assetUrl: string, + blockId: string +): Promise<{ + signedUrls: string[] +}> { + const requestURL = `${API_ENDPOINT}/getSignedFileUrls` + const assetRes = await fetch(requestURL, { + method: 'POST', + headers: { + cookie: `token_v2=${NOTION_TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + urls: [ + { + url: assetUrl, + permissionRecord: { + table: 'block', + id: blockId, + }, + }, + ], + }), + }) + + if (assetRes.ok) { + return assetRes.json() + } else { + console.log('bad request', assetRes.status) + res.json({ status: 'error', message: 'failed to load Notion asset' }) + throw new Error(await getError(assetRes)) + } +} diff --git a/src/lib/notion/getNotionUsers.ts b/src/lib/notion/getNotionUsers.ts new file mode 100644 index 0000000..3948a35 --- /dev/null +++ b/src/lib/notion/getNotionUsers.ts @@ -0,0 +1,25 @@ +import rpc from './rpc' + +export default async function getNotionUsers(ids: string[]) { + const { results = [] } = await rpc('getRecordValues', { + requests: ids.map((id: string) => ({ + id, + table: 'notion_user', + })), + }) + + const users: any = {} + + for (const result of results) { + const { value } = result || { value: {} } + const { given_name, family_name } = value + let full_name = given_name || '' + + if (family_name) { + full_name = `${full_name} ${family_name}` + } + users[value.id] = { full_name } + } + + return { users } +} diff --git a/src/lib/notion/getPageData.ts b/src/lib/notion/getPageData.ts new file mode 100644 index 0000000..292cc59 --- /dev/null +++ b/src/lib/notion/getPageData.ts @@ -0,0 +1,44 @@ +import rpc, { values } from './rpc' + +export default async function getPageData(pageId: string) { + // a reasonable size limit for the largest blog post (1MB), + // as one chunk is about 10KB + const maximumChunckNumer = 100 + + try { + var chunkNumber = 0 + var data = await loadPageChunk({ pageId, chunkNumber }) + var blocks = data.recordMap.block + + while (data.cursor.stack.length !== 0 && chunkNumber < maximumChunckNumer) { + chunkNumber = chunkNumber + 1 + data = await loadPageChunk({ pageId, chunkNumber, cursor: data.cursor }) + blocks = Object.assign(blocks, data.recordMap.block) + } + const blockArray = values(blocks) + if (blockArray[0] && blockArray[0].value.content) { + // remove table blocks + blockArray.splice(0, 3) + } + return { blocks: blockArray } + } catch (err) { + console.error(`Failed to load pageData for ${pageId}`, err) + return { blocks: [] } + } +} + +export function loadPageChunk({ + pageId, + limit = 30, + cursor = { stack: [] }, + chunkNumber = 0, + verticalColumns = false, +}: any) { + return rpc('loadPageChunk', { + pageId, + limit, + cursor, + chunkNumber, + verticalColumns, + }) +} diff --git a/src/lib/notion/getPostPreview.ts b/src/lib/notion/getPostPreview.ts new file mode 100644 index 0000000..d3ab72e --- /dev/null +++ b/src/lib/notion/getPostPreview.ts @@ -0,0 +1,29 @@ +import { loadPageChunk } from './getPageData' +import { values } from './rpc' + +const nonPreviewTypes = new Set(['editor', 'page', 'collection_view']) + +export async function getPostPreview(pageId: string) { + let blocks + let dividerIndex = 0 + + const data = await loadPageChunk({ pageId, limit: 10 }) + blocks = values(data.recordMap.block) + + for (let i = 0; i < blocks.length; i++) { + if (blocks[i].value.type === 'divider') { + dividerIndex = i + break + } + } + + blocks = blocks + .splice(0, dividerIndex) + .filter( + ({ value: { type, properties } }: any) => + !nonPreviewTypes.has(type) && properties + ) + .map((block: any) => block.value.properties.title) + + return blocks +} diff --git a/src/lib/notion/getTableData.ts b/src/lib/notion/getTableData.ts new file mode 100644 index 0000000..46b309f --- /dev/null +++ b/src/lib/notion/getTableData.ts @@ -0,0 +1,104 @@ +import { values } from './rpc' +import Slugger from 'github-slugger' +import queryCollection from './queryCollection' +import { normalizeSlug } from '../blog-helpers' + +export default async function loadTable(collectionBlock: any, isPosts = false) { + const slugger = new Slugger() + + const { value } = collectionBlock + let table: any = {} + const col = await queryCollection({ + collectionId: value.collection_id, + collectionViewId: value.view_ids[0], + }) + const entries = values(col.recordMap.block).filter((block: any) => { + return block.value && block.value.parent_id === value.collection_id + }) + + const colId = Object.keys(col.recordMap.collection)[0] + const schema = col.recordMap.collection[colId].value.schema + const schemaKeys = Object.keys(schema) + + for (const entry of entries) { + const props = entry.value && entry.value.properties + const row: any = {} + + if (!props) continue + if (entry.value.content) { + row.id = entry.value.id + } + + schemaKeys.forEach(key => { + // might be undefined + let val = props[key] && props[key][0][0] + + // authors and blocks are centralized + if (val && props[key][0][1]) { + const type = props[key][0][1][0] + + switch (type[0]) { + case 'a': // link + val = type[1] + break + case 'u': // user + val = props[key] + .filter((arr: any[]) => arr.length > 1) + .map((arr: any[]) => arr[1][0][1]) + break + case 'p': // page (block) + const page = col.recordMap.block[type[1]] + row.id = page.value.id + val = page.value.properties.title[0][0] + break + case 'd': // date + // start_date: 2019-06-18 + // start_time: 07:00 + // time_zone: Europe/Berlin, America/Los_Angeles + + if (!type[1].start_date) { + break + } + // initial with provided date + const providedDate = new Date( + type[1].start_date + ' ' + (type[1].start_time || '') + ).getTime() + + // calculate offset from provided time zone + const timezoneOffset = + new Date( + new Date().toLocaleString('en-US', { + timeZone: type[1].time_zone, + }) + ).getTime() - new Date().getTime() + + // initialize subtracting time zone offset + val = new Date(providedDate - timezoneOffset).getTime() + break + default: + console.error('unknown type', type[0], type) + break + } + } + + if (typeof val === 'string') { + val = val.trim() + } + row[schema[key].name] = val || null + }) + + // auto-generate slug from title + row.Slug = normalizeSlug(row.Slug || slugger.slug(row.Page || '')) + + const key = row.Slug + if (isPosts && !key) continue + + if (key) { + table[key] = row + } else { + if (!Array.isArray(table)) table = [] + table.push(row) + } + } + return table +} diff --git a/src/lib/notion/queryCollection.ts b/src/lib/notion/queryCollection.ts new file mode 100644 index 0000000..758e1d1 --- /dev/null +++ b/src/lib/notion/queryCollection.ts @@ -0,0 +1,36 @@ +import rpc from './rpc' + +export default function queryCollection({ + collectionId, + collectionViewId, + loader = {}, + query = {}, +}: any) { + const queryCollectionBody = { + loader: { + type: 'reducer', + reducers: { + collection_group_results: { + type: 'results', + limit: 999, + loadContentCover: true, + }, + 'table:uncategorized:title:count': { + type: 'aggregation', + aggregation: { + property: 'title', + aggregator: 'count', + }, + }, + }, + searchQuery: '', + userTimeZone: 'America/Phoenix', + }, + } + + return rpc('queryCollection', { + collectionId, + collectionViewId, + ...queryCollectionBody, + }) +} diff --git a/src/lib/notion/renderers.ts b/src/lib/notion/renderers.ts new file mode 100644 index 0000000..6d3d644 --- /dev/null +++ b/src/lib/notion/renderers.ts @@ -0,0 +1,49 @@ +import React from 'react' +import components from '../../components/dynamic' + +function applyTags(tags = [], children, noPTag = false, key) { + let child = children + + for (const tag of tags) { + const props: { [key: string]: any } = { key } + let tagName = tag[0] + + if (noPTag && tagName === 'p') tagName = React.Fragment + if (tagName === 'c') tagName = 'code' + if (tagName === '_') { + tagName = 'span' + props.className = 'underline' + } + if (tagName === 'a') { + props.href = tag[1] + } + if (tagName === 'e') { + tagName = components.Equation + props.displayMode = false + child = tag[1] + } + + child = React.createElement(components[tagName] || tagName, props, child) + } + return child +} + +export function textBlock(text = [], noPTag = false, mainKey) { + const children = [] + let key = 0 + + for (const textItem of text) { + key++ + if (textItem.length === 1) { + children.push(textItem) + continue + } + children.push(applyTags(textItem[1], textItem[0], noPTag, key)) + } + return React.createElement( + noPTag ? React.Fragment : components.p, + { key: mainKey }, + ...children, + noPTag + ) +} diff --git a/src/lib/notion/rpc.ts b/src/lib/notion/rpc.ts new file mode 100644 index 0000000..8d6b378 --- /dev/null +++ b/src/lib/notion/rpc.ts @@ -0,0 +1,49 @@ +import fetch, { Response } from 'node-fetch' +import { API_ENDPOINT, NOTION_TOKEN } from './server-constants' + +export default async function rpc(fnName: string, body: any) { + if (!NOTION_TOKEN) { + throw new Error('NOTION_TOKEN is not set in env') + } + const res = await fetch(`${API_ENDPOINT}/${fnName}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: `token_v2=${NOTION_TOKEN}`, + }, + body: JSON.stringify(body), + }) + + if (res.ok) { + return res.json() + } else { + throw new Error(await getError(res)) + } +} + +export async function getError(res: Response) { + return `Notion API error (${res.status}) \n${getJSONHeaders( + res + )}\n ${await getBodyOrNull(res)}` +} + +export function getJSONHeaders(res: Response) { + return JSON.stringify(res.headers.raw()) +} + +export function getBodyOrNull(res: Response) { + try { + return res.text() + } catch (err) { + return null + } +} + +export function values(obj: any) { + const vals: any = [] + + Object.keys(obj).forEach(key => { + vals.push(obj[key]) + }) + return vals +} diff --git a/src/lib/notion/server-constants.js b/src/lib/notion/server-constants.js new file mode 100644 index 0000000..837555e --- /dev/null +++ b/src/lib/notion/server-constants.js @@ -0,0 +1,29 @@ +// use commonjs so it can be required without transpiling +const path = require('path') + +const normalizeId = (id) => { + if (!id) return id + if (id.length === 36) return id + if (id.length !== 32) { + throw new Error( + `Invalid blog-index-id: ${id} should be 32 characters long. Info here https://github.com/ijjk/notion-blog#getting-blog-index-and-token` + ) + } + return `${id.substr(0, 8)}-${id.substr(8, 4)}-${id.substr(12, 4)}-${id.substr( + 16, + 4 + )}-${id.substr(20)}` +} + +const NOTION_TOKEN = process.env.NOTION_TOKEN +const BLOG_INDEX_ID = normalizeId(process.env.BLOG_INDEX_ID) +const API_ENDPOINT = 'https://www.notion.so/api/v3' +const BLOG_INDEX_CACHE = path.resolve('.blog_index_data') + +module.exports = { + NOTION_TOKEN, + BLOG_INDEX_ID, + API_ENDPOINT, + BLOG_INDEX_CACHE, + normalizeId, +} diff --git a/src/lib/notion/utils.ts b/src/lib/notion/utils.ts new file mode 100644 index 0000000..9dbb271 --- /dev/null +++ b/src/lib/notion/utils.ts @@ -0,0 +1,30 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +export function setHeaders(req: NextApiRequest, res: NextApiResponse): boolean { + // set SPR/CORS headers + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate') + res.setHeader('Access-Control-Allow-Methods', 'GET') + res.setHeader('Access-Control-Allow-Headers', 'pragma') + + if (req.method === 'OPTIONS') { + res.status(200) + res.end() + return true + } + return false +} + +export async function handleData(res: NextApiResponse, data: any) { + data = data || { status: 'error', message: 'unhandled request' } + res.status(data.status !== 'error' ? 200 : 500) + res.json(data) +} + +export function handleError(res: NextApiResponse, error: string | Error) { + console.error(error) + res.status(500).json({ + status: 'error', + message: 'an error occurred processing request', + }) +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx new file mode 100644 index 0000000..e27ab7f --- /dev/null +++ b/src/pages/_app.tsx @@ -0,0 +1,12 @@ +import '../styles/global.css' +import 'katex/dist/katex.css' +import Footer from '../components/footer' + +export default function MyApp({ Component, pageProps }) { + return ( + <> + +