├── .env
├── public
├── avatar.png
├── favicon.ico
├── notion.png
├── og-image.png
└── vercel-and-notion.png
├── assets
└── table-view.png
├── scripts
└── create-table.js
├── .prettierrc.json
├── src
├── components
│ ├── ext-link.tsx
│ ├── dynamic.tsx
│ ├── svgs
│ │ ├── lightning.tsx
│ │ ├── plus.tsx
│ │ ├── jamstack.tsx
│ │ ├── lighthouse.tsx
│ │ ├── wifi.tsx
│ │ ├── scroll.tsx
│ │ ├── edit.tsx
│ │ ├── linkedin.tsx
│ │ ├── envelope.tsx
│ │ ├── zeit.tsx
│ │ ├── twitter.tsx
│ │ ├── notion.tsx
│ │ └── github.tsx
│ ├── counter.tsx
│ ├── equation.tsx
│ ├── footer.tsx
│ ├── heading.tsx
│ ├── code.tsx
│ ├── features.tsx
│ └── header.tsx
├── lib
│ ├── fs-helpers.ts
│ ├── notion
│ │ ├── getNotionUsers.ts
│ │ ├── getPostPreview.ts
│ │ ├── queryCollection.ts
│ │ ├── server-constants.js
│ │ ├── utils.ts
│ │ ├── getNotionAssetUrls.ts
│ │ ├── rpc.ts
│ │ ├── getPageData.ts
│ │ ├── renderers.ts
│ │ ├── getBlogIndex.ts
│ │ ├── getTableData.ts
│ │ └── createTable.js
│ ├── blog-helpers.ts
│ └── build-rss.ts
├── styles
│ ├── shared.module.css
│ ├── contact.module.css
│ ├── header.module.css
│ ├── blog.module.css
│ └── global.css
└── pages
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── api
│ ├── clear-preview.ts
│ ├── preview.ts
│ ├── preview-post.ts
│ └── asset.ts
│ ├── contact.tsx
│ ├── index.tsx
│ └── blog
│ ├── index.tsx
│ └── [slug].tsx
├── next-env.d.ts
├── .gitignore
├── lint-staged.config.js
├── tsconfig.json
├── package.json
├── license
├── next.config.js
└── readme.md
/.env:
--------------------------------------------------------------------------------
1 | BLOG_INDEX_ID=
2 | NOTION_TOKEN=
--------------------------------------------------------------------------------
/public/avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ijjk/notion-blog/HEAD/public/avatar.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ijjk/notion-blog/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/notion.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ijjk/notion-blog/HEAD/public/notion.png
--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ijjk/notion-blog/HEAD/public/og-image.png
--------------------------------------------------------------------------------
/assets/table-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ijjk/notion-blog/HEAD/assets/table-view.png
--------------------------------------------------------------------------------
/scripts/create-table.js:
--------------------------------------------------------------------------------
1 | const main = require('../src/lib/notion/createTable')
2 |
3 | main()
4 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": false,
4 | "trailingComma": "es5"
5 | }
6 |
--------------------------------------------------------------------------------
/public/vercel-and-notion.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ijjk/notion-blog/HEAD/public/vercel-and-notion.png
--------------------------------------------------------------------------------
/src/components/ext-link.tsx:
--------------------------------------------------------------------------------
1 | const ExtLink = (props) => (
2 |
3 | )
4 | export default ExtLink
5 |
--------------------------------------------------------------------------------
/src/lib/fs-helpers.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import { promisify } from 'util'
3 |
4 | export const readFile = promisify(fs.readFile)
5 | export const writeFile = promisify(fs.writeFile)
6 |
--------------------------------------------------------------------------------
/src/styles/shared.module.css:
--------------------------------------------------------------------------------
1 | .layout img {
2 | margin: auto;
3 | max-width: 98%;
4 | display: block;
5 | height: auto;
6 | }
7 |
8 | .layout h1,
9 | .layout h2 {
10 | text-align: center;
11 | }
12 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // NOTE: This file should not be edited
6 | // see https://nextjs.org/docs/basic-features/typescript for more information.
7 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/global.css'
2 | import 'katex/dist/katex.css'
3 | import Footer from '../components/footer'
4 |
5 | export default function MyApp({ Component, pageProps }) {
6 | return (
7 | <>
8 |
9 |
10 | >
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from 'next/document'
2 |
3 | class MyDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
9 |
10 |
11 |
12 |
13 | )
14 | }
15 | }
16 |
17 | export default MyDocument
18 |
--------------------------------------------------------------------------------
/src/components/dynamic.tsx:
--------------------------------------------------------------------------------
1 | import dynamic from 'next/dynamic'
2 | import ExtLink from './ext-link'
3 |
4 | export default {
5 | // default tags
6 | ol: 'ol',
7 | ul: 'ul',
8 | li: 'li',
9 | p: 'p',
10 | blockquote: 'blockquote',
11 | a: ExtLink,
12 |
13 | Code: dynamic(() => import('./code')),
14 | Counter: dynamic(() => import('./counter')),
15 | Equation: dynamic(() => import('./equation')),
16 | }
17 |
--------------------------------------------------------------------------------
/src/pages/api/clear-preview.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | export default (req: NextApiRequest, res: NextApiResponse) => {
4 | if (req.query.slug) {
5 | res.clearPreviewData()
6 | res.writeHead(307, { Location: `/blog/${req.query.slug}` })
7 | res.end()
8 | } else {
9 | res.clearPreviewData()
10 | res.writeHead(307, { Location: `/blog` })
11 | res.end()
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/svgs/lightning.tsx:
--------------------------------------------------------------------------------
1 | const Lightning = (props) => (
2 |
14 |
15 |
16 | )
17 |
18 | export default Lightning
19 |
--------------------------------------------------------------------------------
/src/components/svgs/plus.tsx:
--------------------------------------------------------------------------------
1 | const Plus = (props) => (
2 |
14 |
15 |
16 |
17 | )
18 |
19 | export default Plus
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | .env.local*
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | .blog_index_data
28 | .blog_index_data_previews
29 |
30 | .now
31 | .vercel
--------------------------------------------------------------------------------
/src/components/svgs/jamstack.tsx:
--------------------------------------------------------------------------------
1 | const Jamstack = (props) => (
2 |
14 |
15 |
16 | )
17 |
18 | export default Jamstack
19 |
--------------------------------------------------------------------------------
/src/components/counter.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | const Counter = ({ initialValue }) => {
4 | const [clicks, setClicks] = useState(initialValue)
5 |
6 | return (
7 |
8 |
Count: {clicks}
9 |
setClicks(clicks + 1)}>increase count
10 |
setClicks(clicks - 1)}>decrease count
11 |
12 | )
13 | }
14 |
15 | export default Counter
16 |
--------------------------------------------------------------------------------
/src/components/svgs/lighthouse.tsx:
--------------------------------------------------------------------------------
1 | const Lighthouse = (props) => (
2 |
14 |
15 |
16 |
17 | )
18 |
19 | export default Lighthouse
20 |
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | const escape = require('shell-quote').quote
2 | const isWin = process.platform === 'win32'
3 |
4 | module.exports = {
5 | '**/*.{js,jsx,ts,tsx,json,md,mdx,css,html,yml,yaml,scss,sass}': filenames => {
6 | const escapedFileNames = filenames
7 | .map(filename => `"${isWin ? filename : escape([filename])}"`)
8 | .join(' ')
9 | return [
10 | `prettier --ignore-path='.gitignore' --write ${escapedFileNames}`,
11 | `git add ${escapedFileNames}`,
12 | ]
13 | },
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/svgs/wifi.tsx:
--------------------------------------------------------------------------------
1 | const Wifi = (props) => (
2 |
12 |
13 |
14 |
15 |
16 |
17 | )
18 |
19 | export default Wifi
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve"
16 | },
17 | "exclude": ["node_modules"],
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/svgs/scroll.tsx:
--------------------------------------------------------------------------------
1 | const Scroll = (props) => (
2 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | )
21 |
22 | export default Scroll
23 |
--------------------------------------------------------------------------------
/src/styles/contact.module.css:
--------------------------------------------------------------------------------
1 | .links {
2 | display: block;
3 | margin: 1rem auto;
4 | max-width: 500px;
5 | text-align: center;
6 | }
7 |
8 | .links a {
9 | width: 48px;
10 | height: 48px;
11 | margin: 0 10px;
12 | display: inline-block;
13 | transition: opacity 200ms ease;
14 | }
15 |
16 | .links a:hover {
17 | opacity: 0.6;
18 | }
19 |
20 | .avatar {
21 | display: block;
22 | text-align: center;
23 | margin: 1rem 0;
24 | }
25 |
26 | .avatar img {
27 | height: 120px;
28 | }
29 |
30 | .name {
31 | text-align: center;
32 | }
33 |
34 | .name a {
35 | color: inherit;
36 | }
37 |
--------------------------------------------------------------------------------
/src/styles/header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | display: block;
3 | min-height: 64px;
4 | padding: 2em 0;
5 | text-align: center;
6 | letter-spacing: -0.02em;
7 | }
8 |
9 | .header ul {
10 | list-style: none;
11 | padding: 0;
12 | }
13 |
14 | .header ul li {
15 | display: inline-block;
16 | padding: 0 10px;
17 | }
18 |
19 | .header :global(a) {
20 | color: var(--accents-3);
21 | font-weight: 400;
22 | }
23 |
24 | .header :global(a.active) {
25 | color: #0070f3;
26 | font-weight: 600;
27 | }
28 |
29 | @media (max-width: 600px) {
30 | .header {
31 | padding: .5em 0 2em;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/svgs/edit.tsx:
--------------------------------------------------------------------------------
1 | const Edit = (props) => (
2 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 |
24 | export default Edit
25 |
--------------------------------------------------------------------------------
/src/components/svgs/linkedin.tsx:
--------------------------------------------------------------------------------
1 | const Linkedin = (props) => (
2 |
3 |
4 |
5 | )
6 |
7 | export default Linkedin
8 |
--------------------------------------------------------------------------------
/src/components/svgs/envelope.tsx:
--------------------------------------------------------------------------------
1 | const Envelope = (props) => (
2 |
3 |
4 |
5 | )
6 |
7 | export default Envelope
8 |
--------------------------------------------------------------------------------
/src/components/svgs/zeit.tsx:
--------------------------------------------------------------------------------
1 | const Zeit = (props) => (
2 |
3 | {'Logotype - Black'}
4 |
5 |
12 |
13 |
14 |
15 |
16 |
22 |
23 | )
24 |
25 | export default Zeit
26 |
--------------------------------------------------------------------------------
/src/lib/notion/getNotionUsers.ts:
--------------------------------------------------------------------------------
1 | import rpc from './rpc'
2 |
3 | export default async function getNotionUsers(ids: string[]) {
4 | const { results = [] } = await rpc('getRecordValues', {
5 | requests: ids.map((id: string) => ({
6 | id,
7 | table: 'notion_user',
8 | })),
9 | })
10 |
11 | const users: any = {}
12 |
13 | for (const result of results) {
14 | const { value } = result || { value: {} }
15 | const { given_name, family_name } = value
16 | let full_name = given_name || ''
17 |
18 | if (family_name) {
19 | full_name = `${full_name} ${family_name}`
20 | }
21 | users[value.id] = { full_name }
22 | }
23 |
24 | return { users }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/equation.tsx:
--------------------------------------------------------------------------------
1 | import { renderToString, ParseError } from 'katex'
2 |
3 | function render(expression: string, displayMode: boolean): string {
4 | let result: string
5 | try {
6 | result = renderToString(expression, { displayMode: displayMode })
7 | } catch (e) {
8 | if (e instanceof ParseError) {
9 | result = e.message
10 | }
11 | if (process.env.NODE_ENV !== 'production') {
12 | console.error(e)
13 | }
14 | }
15 | return result
16 | }
17 |
18 | const Equation = ({ children, displayMode = true }) => {
19 | return (
20 |
25 | )
26 | }
27 |
28 | export default Equation
29 |
--------------------------------------------------------------------------------
/src/pages/api/preview.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 | import getPageData from '../../lib/notion/getPageData'
3 | import getBlogIndex from '../../lib/notion/getBlogIndex'
4 |
5 | export default async (req: NextApiRequest, res: NextApiResponse) => {
6 | if (typeof req.query.token !== 'string') {
7 | return res.status(401).json({ message: 'invalid token' })
8 | }
9 | if (req.query.token !== process.env.NOTION_TOKEN) {
10 | return res.status(404).json({ message: 'not authorized' })
11 | }
12 |
13 | const postsTable = await getBlogIndex()
14 |
15 | if (!postsTable) {
16 | return res.status(401).json({ message: 'Failed to fetch posts' })
17 | }
18 |
19 | res.setPreviewData({})
20 | res.writeHead(307, { Location: `/blog` })
21 | res.end()
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import ExtLink from './ext-link'
2 |
3 | export default function Footer() {
4 | return (
5 | <>
6 |
7 | Deploy your own!
8 |
9 |
15 |
16 |
17 | or{' '}
18 |
19 | view source
20 |
21 |
22 |
23 | >
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/blog-helpers.ts:
--------------------------------------------------------------------------------
1 | export const getBlogLink = (slug: string) => {
2 | return `/blog/${slug}`
3 | }
4 |
5 | export const getDateStr = date => {
6 | return new Date(date).toLocaleString('en-US', {
7 | month: 'long',
8 | day: '2-digit',
9 | year: 'numeric',
10 | })
11 | }
12 |
13 | export const postIsPublished = (post: any) => {
14 | return post.Published === 'Yes'
15 | }
16 |
17 | export const normalizeSlug = slug => {
18 | if (typeof slug !== 'string') return slug
19 |
20 | let startingSlash = slug.startsWith('/')
21 | let endingSlash = slug.endsWith('/')
22 |
23 | if (startingSlash) {
24 | slug = slug.substr(1)
25 | }
26 | if (endingSlash) {
27 | slug = slug.substr(0, slug.length - 1)
28 | }
29 | return startingSlash || endingSlash ? normalizeSlug(slug) : slug
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/heading.tsx:
--------------------------------------------------------------------------------
1 | const collectText = (el, acc = []) => {
2 | if (el) {
3 | if (typeof el === 'string') acc.push(el)
4 | if (Array.isArray(el)) el.map((item) => collectText(item, acc))
5 | if (typeof el === 'object') collectText(el.props && el.props.children, acc)
6 | }
7 | return acc.join('').trim()
8 | }
9 |
10 | const Heading = ({ children: component, id }: { children: any; id?: any }) => {
11 | const children = component.props.children || ''
12 | let text = children
13 |
14 | if (null == id) {
15 | id = collectText(text)
16 | .toLowerCase()
17 | .replace(/\s/g, '-')
18 | .replace(/[?!:]/g, '')
19 | }
20 |
21 | return (
22 |
23 | {component}
24 |
25 | )
26 | }
27 |
28 | export default Heading
29 |
--------------------------------------------------------------------------------
/src/lib/notion/getPostPreview.ts:
--------------------------------------------------------------------------------
1 | import { loadPageChunk } from './getPageData'
2 | import { values } from './rpc'
3 |
4 | const nonPreviewTypes = new Set(['editor', 'page', 'collection_view'])
5 |
6 | export async function getPostPreview(pageId: string) {
7 | let blocks
8 | let dividerIndex = 0
9 |
10 | const data = await loadPageChunk({ pageId, limit: 10 })
11 | blocks = values(data.recordMap.block)
12 |
13 | for (let i = 0; i < blocks.length; i++) {
14 | if (blocks[i].value.type === 'divider') {
15 | dividerIndex = i
16 | break
17 | }
18 | }
19 |
20 | blocks = blocks
21 | .splice(0, dividerIndex)
22 | .filter(
23 | ({ value: { type, properties } }: any) =>
24 | !nonPreviewTypes.has(type) && properties
25 | )
26 | .map((block: any) => block.value.properties.title)
27 |
28 | return blocks
29 | }
30 |
--------------------------------------------------------------------------------
/src/lib/notion/queryCollection.ts:
--------------------------------------------------------------------------------
1 | import rpc from './rpc'
2 |
3 | export default function queryCollection({
4 | collectionId,
5 | collectionViewId,
6 | loader = {},
7 | query = {},
8 | }: any) {
9 | const queryCollectionBody = {
10 | loader: {
11 | type: 'reducer',
12 | reducers: {
13 | collection_group_results: {
14 | type: 'results',
15 | limit: 999,
16 | loadContentCover: true,
17 | },
18 | 'table:uncategorized:title:count': {
19 | type: 'aggregation',
20 | aggregation: {
21 | property: 'title',
22 | aggregator: 'count',
23 | },
24 | },
25 | },
26 | searchQuery: '',
27 | userTimeZone: 'America/Phoenix',
28 | },
29 | }
30 |
31 | return rpc('queryCollection', {
32 | collectionId,
33 | collectionViewId,
34 | ...queryCollectionBody,
35 | })
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/svgs/twitter.tsx:
--------------------------------------------------------------------------------
1 | const Twitter = (props) => (
2 |
3 |
4 |
5 | )
6 |
7 | export default Twitter
8 |
--------------------------------------------------------------------------------
/src/components/code.tsx:
--------------------------------------------------------------------------------
1 | import Prism from 'prismjs'
2 | import 'prismjs/components/prism-jsx'
3 |
4 | const Code = ({ children, language = 'javascript' }) => {
5 | return (
6 | <>
7 |
8 |
17 |
18 |
19 |
34 | >
35 | )
36 | }
37 |
38 | export default Code
39 |
--------------------------------------------------------------------------------
/src/lib/notion/server-constants.js:
--------------------------------------------------------------------------------
1 | // use commonjs so it can be required without transpiling
2 | const path = require('path')
3 |
4 | const normalizeId = (id) => {
5 | if (!id) return id
6 | if (id.length === 36) return id
7 | if (id.length !== 32) {
8 | throw new Error(
9 | `Invalid blog-index-id: ${id} should be 32 characters long. Info here https://github.com/ijjk/notion-blog#getting-blog-index-and-token`
10 | )
11 | }
12 | return `${id.substr(0, 8)}-${id.substr(8, 4)}-${id.substr(12, 4)}-${id.substr(
13 | 16,
14 | 4
15 | )}-${id.substr(20)}`
16 | }
17 |
18 | const NOTION_TOKEN = process.env.NOTION_TOKEN
19 | const BLOG_INDEX_ID = normalizeId(process.env.BLOG_INDEX_ID)
20 | const API_ENDPOINT = 'https://www.notion.so/api/v3'
21 | const BLOG_INDEX_CACHE = path.resolve('.blog_index_data')
22 |
23 | module.exports = {
24 | NOTION_TOKEN,
25 | BLOG_INDEX_ID,
26 | API_ENDPOINT,
27 | BLOG_INDEX_CACHE,
28 | normalizeId,
29 | }
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "notion-blog",
3 | "version": "0.1.0",
4 | "scripts": {
5 | "dev": "next dev",
6 | "start": "next start",
7 | "build": "next build && node .next/server/build-rss.js",
8 | "format": "prettier --write \"**/*.{js,jsx,json,ts,tsx,md,mdx,css,html,yml,yaml,scss,sass}\" --ignore-path .gitignore",
9 | "lint-staged": "lint-staged"
10 | },
11 | "pre-commit": "lint-staged",
12 | "dependencies": {
13 | "@zeit/react-jsx-parser": "2.0.0",
14 | "async-sema": "3.1.0",
15 | "github-slugger": "1.2.1",
16 | "katex": "0.12.0",
17 | "next": "^11.1.2",
18 | "prismjs": "1.17.1",
19 | "react": "^17.0.2",
20 | "react-dom": "^17.0.2",
21 | "uuid": "8.1.0"
22 | },
23 | "devDependencies": {
24 | "@types/katex": "0.11.0",
25 | "@types/node": "14.14.31",
26 | "@types/react": "17.0.2",
27 | "lint-staged": "10.5.4",
28 | "pre-commit": "1.2.2",
29 | "prettier": "2.2.1",
30 | "typescript": "^4.4.4"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/notion/utils.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | export function setHeaders(req: NextApiRequest, res: NextApiResponse): boolean {
4 | // set SPR/CORS headers
5 | res.setHeader('Access-Control-Allow-Origin', '*')
6 | res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate')
7 | res.setHeader('Access-Control-Allow-Methods', 'GET')
8 | res.setHeader('Access-Control-Allow-Headers', 'pragma')
9 |
10 | if (req.method === 'OPTIONS') {
11 | res.status(200)
12 | res.end()
13 | return true
14 | }
15 | return false
16 | }
17 |
18 | export async function handleData(res: NextApiResponse, data: any) {
19 | data = data || { status: 'error', message: 'unhandled request' }
20 | res.status(data.status !== 'error' ? 200 : 500)
21 | res.json(data)
22 | }
23 |
24 | export function handleError(res: NextApiResponse, error: string | Error) {
25 | console.error(error)
26 | res.status(500).json({
27 | status: 'error',
28 | message: 'an error occurred processing request',
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/src/lib/notion/getNotionAssetUrls.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch'
2 | import { getError } from './rpc'
3 | import { NextApiResponse } from 'next'
4 | import { NOTION_TOKEN, API_ENDPOINT } from './server-constants'
5 |
6 | export default async function getNotionAsset(
7 | res: NextApiResponse,
8 | assetUrl: string,
9 | blockId: string
10 | ): Promise<{
11 | signedUrls: string[]
12 | }> {
13 | const requestURL = `${API_ENDPOINT}/getSignedFileUrls`
14 | const assetRes = await fetch(requestURL, {
15 | method: 'POST',
16 | headers: {
17 | cookie: `token_v2=${NOTION_TOKEN}`,
18 | 'content-type': 'application/json',
19 | },
20 | body: JSON.stringify({
21 | urls: [
22 | {
23 | url: assetUrl,
24 | permissionRecord: {
25 | table: 'block',
26 | id: blockId,
27 | },
28 | },
29 | ],
30 | }),
31 | })
32 |
33 | if (assetRes.ok) {
34 | return assetRes.json()
35 | } else {
36 | console.log('bad request', assetRes.status)
37 | res.json({ status: 'error', message: 'failed to load Notion asset' })
38 | throw new Error(await getError(assetRes))
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/features.tsx:
--------------------------------------------------------------------------------
1 | import Lightning from './svgs/lightning'
2 | import Jamstack from './svgs/jamstack'
3 | import Wifi from './svgs/wifi'
4 | import Lighthouse from './svgs/lighthouse'
5 | import Plus from './svgs/plus'
6 | import Notion from './svgs/notion'
7 | import Edit from './svgs/edit'
8 | import Scroll from './svgs/scroll'
9 |
10 | const features = [
11 | {
12 | text: 'Blazing fast',
13 | icon: Lightning,
14 | },
15 | {
16 | text: 'JAMstack based',
17 | icon: Jamstack,
18 | },
19 | {
20 | text: 'Always available',
21 | icon: Wifi,
22 | },
23 | {
24 | text: 'Customizable',
25 | icon: Edit,
26 | },
27 | {
28 | text: 'Incremental SSG',
29 | icon: Plus,
30 | },
31 | {
32 | text: 'MIT Licensed',
33 | icon: Scroll,
34 | },
35 | {
36 | text: 'Edit via Notion',
37 | icon: Notion,
38 | },
39 | {
40 | text: 'Great scores',
41 | icon: Lighthouse,
42 | },
43 | ]
44 |
45 | const Features = () => (
46 |
47 | {features.map(({ text, icon: Icon }) => (
48 |
49 | {Icon && }
50 |
{text}
51 |
52 | ))}
53 |
54 | )
55 |
56 | export default Features
57 |
--------------------------------------------------------------------------------
/src/components/svgs/notion.tsx:
--------------------------------------------------------------------------------
1 | const Notion = (props) => (
2 |
9 |
10 |
11 | )
12 |
13 | export default Notion
14 |
--------------------------------------------------------------------------------
/src/pages/api/preview-post.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 | import getPageData from '../../lib/notion/getPageData'
3 | import getBlogIndex from '../../lib/notion/getBlogIndex'
4 | import { getBlogLink } from '../../lib/blog-helpers'
5 |
6 | export default async (req: NextApiRequest, res: NextApiResponse) => {
7 | if (typeof req.query.token !== 'string') {
8 | return res.status(401).json({ message: 'invalid token' })
9 | }
10 | if (req.query.token !== process.env.NOTION_TOKEN) {
11 | return res.status(404).json({ message: 'not authorized' })
12 | }
13 |
14 | if (typeof req.query.slug !== 'string') {
15 | return res.status(401).json({ message: 'invalid slug' })
16 | }
17 | const postsTable = await getBlogIndex()
18 | const post = postsTable[req.query.slug]
19 |
20 | if (!post) {
21 | console.log(`Failed to find post for slug: ${req.query.slug}`)
22 | return res.status(404).json({
23 | message: `no post found for ${req.query.slug}`,
24 | })
25 | }
26 |
27 | const postData = await getPageData(post.id)
28 |
29 | if (!postData) {
30 | return res.status(401).json({ message: 'Invalid slug' })
31 | }
32 |
33 | res.setPreviewData({})
34 | res.writeHead(307, { Location: getBlogLink(post.Slug) })
35 | res.end()
36 | }
37 |
--------------------------------------------------------------------------------
/src/lib/notion/rpc.ts:
--------------------------------------------------------------------------------
1 | import fetch, { Response } from 'node-fetch'
2 | import { API_ENDPOINT, NOTION_TOKEN } from './server-constants'
3 |
4 | export default async function rpc(fnName: string, body: any) {
5 | if (!NOTION_TOKEN) {
6 | throw new Error('NOTION_TOKEN is not set in env')
7 | }
8 | const res = await fetch(`${API_ENDPOINT}/${fnName}`, {
9 | method: 'POST',
10 | headers: {
11 | 'content-type': 'application/json',
12 | cookie: `token_v2=${NOTION_TOKEN}`,
13 | },
14 | body: JSON.stringify(body),
15 | })
16 |
17 | if (res.ok) {
18 | return res.json()
19 | } else {
20 | throw new Error(await getError(res))
21 | }
22 | }
23 |
24 | export async function getError(res: Response) {
25 | return `Notion API error (${res.status}) \n${getJSONHeaders(
26 | res
27 | )}\n ${await getBodyOrNull(res)}`
28 | }
29 |
30 | export function getJSONHeaders(res: Response) {
31 | return JSON.stringify(res.headers.raw())
32 | }
33 |
34 | export function getBodyOrNull(res: Response) {
35 | try {
36 | return res.text()
37 | } catch (err) {
38 | return null
39 | }
40 | }
41 |
42 | export function values(obj: any) {
43 | const vals: any = []
44 |
45 | Object.keys(obj).forEach(key => {
46 | vals.push(obj[key])
47 | })
48 | return vals
49 | }
50 |
--------------------------------------------------------------------------------
/src/pages/api/asset.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 | import getNotionAssetUrls from '../../lib/notion/getNotionAssetUrls'
3 | import { setHeaders, handleData, handleError } from '../../lib/notion/utils'
4 |
5 | export default async function notionApi(
6 | req: NextApiRequest,
7 | res: NextApiResponse
8 | ) {
9 | if (setHeaders(req, res)) return
10 | try {
11 | const { assetUrl, blockId } = req.query as { [k: string]: string }
12 |
13 | if (!assetUrl || !blockId) {
14 | handleData(res, {
15 | status: 'error',
16 | message: 'asset url or blockId missing',
17 | })
18 | } else {
19 | // we need to re-encode it since it's decoded when added to req.query
20 | const { signedUrls = [], ...urlsResponse } = await getNotionAssetUrls(
21 | res,
22 | assetUrl,
23 | blockId
24 | )
25 |
26 | if (signedUrls.length === 0) {
27 | console.error('Failed to get signedUrls', urlsResponse)
28 | return handleData(res, {
29 | status: 'error',
30 | message: 'Failed to get asset URL',
31 | })
32 | }
33 |
34 | res.status(307)
35 | res.setHeader('Location', signedUrls.pop())
36 | res.end()
37 | }
38 | } catch (error) {
39 | handleError(res, error)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/lib/notion/getPageData.ts:
--------------------------------------------------------------------------------
1 | import rpc, { values } from './rpc'
2 |
3 | export default async function getPageData(pageId: string) {
4 | // a reasonable size limit for the largest blog post (1MB),
5 | // as one chunk is about 10KB
6 | const maximumChunckNumer = 100
7 |
8 | try {
9 | var chunkNumber = 0
10 | var data = await loadPageChunk({ pageId, chunkNumber })
11 | var blocks = data.recordMap.block
12 |
13 | while (data.cursor.stack.length !== 0 && chunkNumber < maximumChunckNumer) {
14 | chunkNumber = chunkNumber + 1
15 | data = await loadPageChunk({ pageId, chunkNumber, cursor: data.cursor })
16 | blocks = Object.assign(blocks, data.recordMap.block)
17 | }
18 | const blockArray = values(blocks)
19 | if (blockArray[0] && blockArray[0].value.content) {
20 | // remove table blocks
21 | blockArray.splice(0, 3)
22 | }
23 | return { blocks: blockArray }
24 | } catch (err) {
25 | console.error(`Failed to load pageData for ${pageId}`, err)
26 | return { blocks: [] }
27 | }
28 | }
29 |
30 | export function loadPageChunk({
31 | pageId,
32 | limit = 30,
33 | cursor = { stack: [] },
34 | chunkNumber = 0,
35 | verticalColumns = false,
36 | }: any) {
37 | return rpc('loadPageChunk', {
38 | pageId,
39 | limit,
40 | cursor,
41 | chunkNumber,
42 | verticalColumns,
43 | })
44 | }
45 |
--------------------------------------------------------------------------------
/src/lib/notion/renderers.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import components from '../../components/dynamic'
3 |
4 | function applyTags(tags = [], children, noPTag = false, key) {
5 | let child = children
6 |
7 | for (const tag of tags) {
8 | const props: { [key: string]: any } = { key }
9 | let tagName = tag[0]
10 |
11 | if (noPTag && tagName === 'p') tagName = React.Fragment
12 | if (tagName === 'c') tagName = 'code'
13 | if (tagName === '_') {
14 | tagName = 'span'
15 | props.className = 'underline'
16 | }
17 | if (tagName === 'a') {
18 | props.href = tag[1]
19 | }
20 | if (tagName === 'e') {
21 | tagName = components.Equation
22 | props.displayMode = false
23 | child = tag[1]
24 | }
25 |
26 | child = React.createElement(components[tagName] || tagName, props, child)
27 | }
28 | return child
29 | }
30 |
31 | export function textBlock(text = [], noPTag = false, mainKey) {
32 | const children = []
33 | let key = 0
34 |
35 | for (const textItem of text) {
36 | key++
37 | if (textItem.length === 1) {
38 | children.push(textItem)
39 | continue
40 | }
41 | children.push(applyTags(textItem[1], textItem[0], noPTag, key))
42 | }
43 | return React.createElement(
44 | noPTag ? React.Fragment : components.p,
45 | { key: mainKey },
46 | ...children,
47 | noPTag
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/svgs/github.tsx:
--------------------------------------------------------------------------------
1 | const Github = (props) => (
2 |
3 |
4 |
5 | )
6 |
7 | export default Github
8 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | const {
4 | NOTION_TOKEN,
5 | BLOG_INDEX_ID,
6 | } = require('./src/lib/notion/server-constants')
7 |
8 | try {
9 | fs.unlinkSync(path.resolve('.blog_index_data'))
10 | } catch (_) {
11 | /* non fatal */
12 | }
13 | try {
14 | fs.unlinkSync(path.resolve('.blog_index_data_previews'))
15 | } catch (_) {
16 | /* non fatal */
17 | }
18 |
19 | const warnOrError =
20 | process.env.NODE_ENV !== 'production'
21 | ? console.warn
22 | : (msg) => {
23 | throw new Error(msg)
24 | }
25 |
26 | if (!NOTION_TOKEN) {
27 | // We aren't able to build or serve images from Notion without the
28 | // NOTION_TOKEN being populated
29 | warnOrError(
30 | `\nNOTION_TOKEN is missing from env, this will result in an error\n` +
31 | `Make sure to provide one before starting Next.js`
32 | )
33 | }
34 |
35 | if (!BLOG_INDEX_ID) {
36 | // We aren't able to build or serve images from Notion without the
37 | // NOTION_TOKEN being populated
38 | warnOrError(
39 | `\nBLOG_INDEX_ID is missing from env, this will result in an error\n` +
40 | `Make sure to provide one before starting Next.js`
41 | )
42 | }
43 |
44 | module.exports = {
45 | webpack(cfg, { dev, isServer }) {
46 | // only compile build-rss in production server build
47 | if (dev || !isServer) return cfg
48 |
49 | // we're in build mode so enable shared caching for Notion data
50 | process.env.USE_CACHE = 'true'
51 |
52 | const originalEntry = cfg.entry
53 | cfg.entry = async () => {
54 | const entries = { ...(await originalEntry()) }
55 | entries['build-rss.js'] = './src/lib/build-rss.ts'
56 | return entries
57 | }
58 | return cfg
59 | },
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import Head from 'next/head'
3 | import ExtLink from './ext-link'
4 | import { useRouter } from 'next/router'
5 | import styles from '../styles/header.module.css'
6 |
7 | const navItems: { label: string; page?: string; link?: string }[] = [
8 | { label: 'Home', page: '/' },
9 | { label: 'Blog', page: '/blog' },
10 | { label: 'Contact', page: '/contact' },
11 | { label: 'Source Code', link: 'https://github.com/ijjk/notion-blog' },
12 | ]
13 |
14 | const ogImageUrl = 'https://notion-blog.now.sh/og-image.png'
15 |
16 | const Header = ({ titlePre = '' }) => {
17 | const { pathname } = useRouter()
18 |
19 | return (
20 |
21 |
22 | {titlePre ? `${titlePre} |` : ''} My Notion Blog
23 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | {navItems.map(({ label, page, link }) => (
35 |
36 | {page ? (
37 |
38 |
39 | {label}
40 |
41 |
42 | ) : (
43 | {label}
44 | )}
45 |
46 | ))}
47 |
48 |
49 | )
50 | }
51 |
52 | export default Header
53 |
--------------------------------------------------------------------------------
/src/pages/contact.tsx:
--------------------------------------------------------------------------------
1 | import Header from '../components/header'
2 | import ExtLink from '../components/ext-link'
3 |
4 | import sharedStyles from '../styles/shared.module.css'
5 | import contactStyles from '../styles/contact.module.css'
6 |
7 | import GitHub from '../components/svgs/github'
8 | import Twitter from '../components/svgs/twitter'
9 | import Envelope from '../components/svgs/envelope'
10 | import LinkedIn from '../components/svgs/linkedin'
11 |
12 | const contacts = [
13 | {
14 | Comp: Twitter,
15 | alt: 'twitter icon',
16 | link: 'https://twitter.com/_ijjk',
17 | },
18 | {
19 | Comp: GitHub,
20 | alt: 'github icon',
21 | link: 'https://github.com/ijjk',
22 | },
23 | {
24 | Comp: LinkedIn,
25 | alt: 'linkedin icon',
26 | link: 'https://www.linkedin.com/in/jj-kasper-0b5392166/',
27 | },
28 | {
29 | Comp: Envelope,
30 | alt: 'envelope icon',
31 | link: 'mailto:jj@jjsweb.site?subject=Notion Blog',
32 | },
33 | ]
34 |
35 | export default function Contact() {
36 | return (
37 | <>
38 |
39 |
40 |
41 |
42 |
43 |
44 |
Contact
45 |
46 |
47 | JJ Kasper - Next.js Engineer @{' '}
48 | Vercel
49 |
50 |
51 |
52 | {contacts.map(({ Comp, link, alt }) => {
53 | return (
54 |
55 |
56 |
57 | )
58 | })}
59 |
60 |
61 | >
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/src/lib/notion/getBlogIndex.ts:
--------------------------------------------------------------------------------
1 | import { Sema } from 'async-sema'
2 | import rpc, { values } from './rpc'
3 | import getTableData from './getTableData'
4 | import { getPostPreview } from './getPostPreview'
5 | import { readFile, writeFile } from '../fs-helpers'
6 | import { BLOG_INDEX_ID, BLOG_INDEX_CACHE } from './server-constants'
7 |
8 | export default async function getBlogIndex(previews = true) {
9 | let postsTable: any = null
10 | const useCache = process.env.USE_CACHE === 'true'
11 | const cacheFile = `${BLOG_INDEX_CACHE}${previews ? '_previews' : ''}`
12 |
13 | if (useCache) {
14 | try {
15 | postsTable = JSON.parse(await readFile(cacheFile, 'utf8'))
16 | } catch (_) {
17 | /* not fatal */
18 | }
19 | }
20 |
21 | if (!postsTable) {
22 | try {
23 | const data = await rpc('loadPageChunk', {
24 | pageId: BLOG_INDEX_ID,
25 | limit: 100, // TODO: figure out Notion's way of handling pagination
26 | cursor: { stack: [] },
27 | chunkNumber: 0,
28 | verticalColumns: false,
29 | })
30 |
31 | // Parse table with posts
32 | const tableBlock = values(data.recordMap.block).find(
33 | (block: any) => block.value.type === 'collection_view'
34 | )
35 |
36 | postsTable = await getTableData(tableBlock, true)
37 | } catch (err) {
38 | console.warn(
39 | `Failed to load Notion posts, have you run the create-table script?`
40 | )
41 | return {}
42 | }
43 |
44 | // only get 10 most recent post's previews
45 | const postsKeys = Object.keys(postsTable).splice(0, 10)
46 |
47 | const sema = new Sema(3, { capacity: postsKeys.length })
48 |
49 | if (previews) {
50 | await Promise.all(
51 | postsKeys
52 | .sort((a, b) => {
53 | const postA = postsTable[a]
54 | const postB = postsTable[b]
55 | const timeA = postA.Date
56 | const timeB = postB.Date
57 | return Math.sign(timeB - timeA)
58 | })
59 | .map(async (postKey) => {
60 | await sema.acquire()
61 | const post = postsTable[postKey]
62 | post.preview = post.id
63 | ? await getPostPreview(postsTable[postKey].id)
64 | : []
65 | sema.release()
66 | })
67 | )
68 | }
69 |
70 | if (useCache) {
71 | writeFile(cacheFile, JSON.stringify(postsTable), 'utf8').catch(() => {})
72 | }
73 | }
74 |
75 | return postsTable
76 | }
77 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Header from '../components/header'
2 | import ExtLink from '../components/ext-link'
3 | import Features from '../components/features'
4 | import sharedStyles from '../styles/shared.module.css'
5 |
6 | export default function Index() {
7 | return (
8 | <>
9 |
10 |
11 |
17 |
My Notion Blog
18 |
19 | Blazing Fast Notion Blog with Next.js'{' '}
20 |
25 | SSG
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | This is a statically generated{' '}
34 | Next.js site with a{' '}
35 | Notion powered blog that
36 | is deployed with Vercel
37 | . It leverages some upcoming features in Next.js like{' '}
38 |
39 | SSG support
40 | {' '}
41 | and{' '}
42 |
43 | built-in CSS support
44 | {' '}
45 | which allow us to achieve all of the benefits listed above including
46 | blazing fast speeds, great local editing experience, and always
47 | being available!
48 |
49 |
50 |
51 | Get started by creating a new page in Notion and clicking the deploy
52 | button below. After you supply your token and the blog index id (the
53 | page's id in Notion) we will automatically create the table for you!
54 | See{' '}
55 |
56 | here in the readme
57 | {' '}
58 | for finding the new page's id. To get your token from Notion, login
59 | and look for a cookie under www.notion.so with the name `token_v2`.
60 | After finding your token and your blog's page id you should be good
61 | to go!
62 |
63 |
64 |
65 | >
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/src/lib/notion/getTableData.ts:
--------------------------------------------------------------------------------
1 | import { values } from './rpc'
2 | import Slugger from 'github-slugger'
3 | import queryCollection from './queryCollection'
4 | import { normalizeSlug } from '../blog-helpers'
5 |
6 | export default async function loadTable(collectionBlock: any, isPosts = false) {
7 | const slugger = new Slugger()
8 |
9 | const { value } = collectionBlock
10 | let table: any = {}
11 | const col = await queryCollection({
12 | collectionId: value.collection_id,
13 | collectionViewId: value.view_ids[0],
14 | })
15 | const entries = values(col.recordMap.block).filter((block: any) => {
16 | return block.value && block.value.parent_id === value.collection_id
17 | })
18 |
19 | const colId = Object.keys(col.recordMap.collection)[0]
20 | const schema = col.recordMap.collection[colId].value.schema
21 | const schemaKeys = Object.keys(schema)
22 |
23 | for (const entry of entries) {
24 | const props = entry.value && entry.value.properties
25 | const row: any = {}
26 |
27 | if (!props) continue
28 | if (entry.value.content) {
29 | row.id = entry.value.id
30 | }
31 |
32 | schemaKeys.forEach(key => {
33 | // might be undefined
34 | let val = props[key] && props[key][0][0]
35 |
36 | // authors and blocks are centralized
37 | if (val && props[key][0][1]) {
38 | const type = props[key][0][1][0]
39 |
40 | switch (type[0]) {
41 | case 'a': // link
42 | val = type[1]
43 | break
44 | case 'u': // user
45 | val = props[key]
46 | .filter((arr: any[]) => arr.length > 1)
47 | .map((arr: any[]) => arr[1][0][1])
48 | break
49 | case 'p': // page (block)
50 | const page = col.recordMap.block[type[1]]
51 | row.id = page.value.id
52 | val = page.value.properties.title[0][0]
53 | break
54 | case 'd': // date
55 | // start_date: 2019-06-18
56 | // start_time: 07:00
57 | // time_zone: Europe/Berlin, America/Los_Angeles
58 |
59 | if (!type[1].start_date) {
60 | break
61 | }
62 | // initial with provided date
63 | const providedDate = new Date(
64 | type[1].start_date + ' ' + (type[1].start_time || '')
65 | ).getTime()
66 |
67 | // calculate offset from provided time zone
68 | const timezoneOffset =
69 | new Date(
70 | new Date().toLocaleString('en-US', {
71 | timeZone: type[1].time_zone,
72 | })
73 | ).getTime() - new Date().getTime()
74 |
75 | // initialize subtracting time zone offset
76 | val = new Date(providedDate - timezoneOffset).getTime()
77 | break
78 | default:
79 | console.error('unknown type', type[0], type)
80 | break
81 | }
82 | }
83 |
84 | if (typeof val === 'string') {
85 | val = val.trim()
86 | }
87 | row[schema[key].name] = val || null
88 | })
89 |
90 | // auto-generate slug from title
91 | row.Slug = normalizeSlug(row.Slug || slugger.slug(row.Page || ''))
92 |
93 | const key = row.Slug
94 | if (isPosts && !key) continue
95 |
96 | if (key) {
97 | table[key] = row
98 | } else {
99 | if (!Array.isArray(table)) table = []
100 | table.push(row)
101 | }
102 | }
103 | return table
104 | }
105 |
--------------------------------------------------------------------------------
/src/pages/blog/index.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import Header from '../../components/header'
3 |
4 | import blogStyles from '../../styles/blog.module.css'
5 | import sharedStyles from '../../styles/shared.module.css'
6 |
7 | import {
8 | getBlogLink,
9 | getDateStr,
10 | postIsPublished,
11 | } from '../../lib/blog-helpers'
12 | import { textBlock } from '../../lib/notion/renderers'
13 | import getNotionUsers from '../../lib/notion/getNotionUsers'
14 | import getBlogIndex from '../../lib/notion/getBlogIndex'
15 |
16 | export async function getStaticProps({ preview }) {
17 | const postsTable = await getBlogIndex()
18 |
19 | const authorsToGet: Set = new Set()
20 | const posts: any[] = Object.keys(postsTable)
21 | .map((slug) => {
22 | const post = postsTable[slug]
23 | // remove draft posts in production
24 | if (!preview && !postIsPublished(post)) {
25 | return null
26 | }
27 | post.Authors = post.Authors || []
28 | for (const author of post.Authors) {
29 | authorsToGet.add(author)
30 | }
31 | return post
32 | })
33 | .filter(Boolean)
34 |
35 | const { users } = await getNotionUsers([...authorsToGet])
36 |
37 | posts.map((post) => {
38 | post.Authors = post.Authors.map((id) => users[id].full_name)
39 | })
40 |
41 | return {
42 | props: {
43 | preview: preview || false,
44 | posts,
45 | },
46 | revalidate: 10,
47 | }
48 | }
49 |
50 | const Index = ({ posts = [], preview }) => {
51 | return (
52 | <>
53 |
54 | {preview && (
55 |
56 |
57 | Note:
58 | {` `}Viewing in preview mode{' '}
59 |
60 | Exit Preview
61 |
62 |
63 |
64 | )}
65 |
66 |
My Notion Blog
67 | {posts.length === 0 && (
68 |
There are no posts yet
69 | )}
70 | {posts.map((post) => {
71 | return (
72 |
73 |
74 |
75 | {!post.Published && (
76 | Draft
77 | )}
78 |
79 | {post.Page}
80 |
81 |
82 |
83 | {post.Authors.length > 0 && (
84 |
By: {post.Authors.join(' ')}
85 | )}
86 | {post.Date && (
87 |
Posted: {getDateStr(post.Date)}
88 | )}
89 |
90 | {(!post.preview || post.preview.length === 0) &&
91 | 'No preview available'}
92 | {(post.preview || []).map((block, idx) =>
93 | textBlock(block, true, `${post.Slug}${idx}`)
94 | )}
95 |
96 |
97 | )
98 | })}
99 |
100 | >
101 | )
102 | }
103 |
104 | export default Index
105 |
--------------------------------------------------------------------------------
/src/lib/build-rss.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'path'
2 | import { writeFile } from './fs-helpers'
3 | import { renderToStaticMarkup } from 'react-dom/server'
4 |
5 | import { textBlock } from './notion/renderers'
6 | import getBlogIndex from './notion/getBlogIndex'
7 | import getNotionUsers from './notion/getNotionUsers'
8 | import { postIsPublished, getBlogLink } from './blog-helpers'
9 | import { loadEnvConfig } from '@next/env'
10 | import serverConstants from './notion/server-constants'
11 |
12 | // must use weird syntax to bypass auto replacing of NODE_ENV
13 | process.env['NODE' + '_ENV'] = 'production'
14 | process.env.USE_CACHE = 'true'
15 |
16 | // constants
17 | const NOW = new Date().toJSON()
18 |
19 | function mapToAuthor(author) {
20 | return `${author.full_name} `
21 | }
22 |
23 | function decode(string) {
24 | return string
25 | .replace(/&/g, '&')
26 | .replace(//g, '>')
28 | .replace(/"/g, '"')
29 | .replace(/'/g, ''')
30 | }
31 |
32 | function mapToEntry(post) {
33 | return `
34 |
35 | ${post.link}
36 | ${decode(post.title)}
37 |
38 | ${new Date(post.date).toJSON()}
39 |
40 |
41 | ${renderToStaticMarkup(
42 | post.preview
43 | ? (post.preview || []).map((block, idx) =>
44 | textBlock(block, false, post.title + idx)
45 | )
46 | : post.content
47 | )}
48 |
49 | Read more
50 |
51 |
52 |
53 | ${(post.authors || []).map(mapToAuthor).join('\n ')}
54 | `
55 | }
56 |
57 | function concat(total, item) {
58 | return total + item
59 | }
60 |
61 | function createRSS(blogPosts = []) {
62 | const postsString = blogPosts.map(mapToEntry).reduce(concat, '')
63 |
64 | return `
65 |
66 | My Blog
67 | Blog
68 |
69 |
70 | ${NOW}
71 | My Notion Blog ${postsString}
72 | `
73 | }
74 |
75 | async function main() {
76 | await loadEnvConfig(process.cwd())
77 | serverConstants.NOTION_TOKEN = process.env.NOTION_TOKEN
78 | serverConstants.BLOG_INDEX_ID = serverConstants.normalizeId(
79 | process.env.BLOG_INDEX_ID
80 | )
81 |
82 | const postsTable = await getBlogIndex(true)
83 | const neededAuthors = new Set()
84 |
85 | const blogPosts = Object.keys(postsTable)
86 | .map((slug) => {
87 | const post = postsTable[slug]
88 | if (!postIsPublished(post)) return
89 |
90 | post.authors = post.Authors || []
91 |
92 | for (const author of post.authors) {
93 | neededAuthors.add(author)
94 | }
95 | return post
96 | })
97 | .filter(Boolean)
98 |
99 | const { users } = await getNotionUsers([...neededAuthors])
100 |
101 | blogPosts.forEach((post) => {
102 | post.authors = post.authors.map((id) => users[id])
103 | post.link = getBlogLink(post.Slug)
104 | post.title = post.Page
105 | post.date = post.Date
106 | })
107 |
108 | const outputPath = './public/atom'
109 | await writeFile(resolve(outputPath), createRSS(blogPosts))
110 | console.log(`Atom feed file generated at \`${outputPath}\``)
111 | }
112 |
113 | main().catch((error) => console.error(error))
114 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Notion Blog
2 |
3 | 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.
4 |
5 | **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.
6 |
7 | **Live Example hosted on Vercel**: https://notion-blog.vercel.app/
8 |
9 | ## Getting Started
10 |
11 | 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.
12 |
13 | ## Deploy Your Own
14 |
15 | Deploy your own Notion blog with Vercel.
16 |
17 | [](https://vercel.com/new/git/external?repository-url=https://github.com/ijjk/notion-blog/tree/main&project-name=notion-blog&repository-name=notion-blog)
18 |
19 | or
20 |
21 | 1. Clone this repo `git clone https://github.com/ijjk/notion-blog.git`
22 | 2. Configure project with [`vc`](https://vercel.com/download)
23 | 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
24 | 4. Do final deployment with `vc`
25 |
26 | 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
27 |
28 | ## Creating Your Pages Table
29 |
30 | **Note**: this is auto run if a table isn't detected the first time visiting `/blog`
31 |
32 | ### Using the Pre-Configured Script
33 |
34 | 1. Create a blank page in Notion
35 | 2. Clone this repo `git clone https://github.com/ijjk/notion-blog.git`
36 | 3. Install dependencies `cd notion-blog && yarn`
37 | 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
38 |
39 | ### Manually Creating the Table
40 |
41 | 1. Create a blank page in Notion
42 | 2. Create a **inline** table on that page, don't use a full page table as it requires querying differently
43 | 3. Add the below fields to the table
44 |
45 | The table should have the following properties:
46 |
47 | - `Page`: this the blog post's page
48 | - `Slug`: this is the blog post's slug relative to `/blog`, it should be a text property
49 | - `Published`: this filters blog posts in **production**, it should be a checkbox property
50 | - `Date`: this is when the blog post appears as posted, it should be a date property
51 | - `Authors`: this is a list of Notion users that wrote the post, it should be a person property
52 |
53 | 
54 |
55 | ## Getting Blog Index and Token
56 |
57 | 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:
58 |
59 | - 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`
60 | - 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`
61 |
62 | To get your Notion token, open Notion and look for the `token_v2` cookie.
63 |
64 | ## Creating Blog Posts
65 |
66 | 1. In Notion click new on the table to add a new row
67 | 2. Fill in the Page name, slug, Date, and Authors
68 | 3. At the top of the content area add the content you want to show as a preview (keep this under 2 paragraphs)
69 | 4. Add a divider block under your preview content
70 | 5. Add the rest of your content under the divider block
71 |
72 | ## Running Locally
73 |
74 | To run the project locally you need to follow steps 1 and 2 of [deploying](#deploy-your-own) and then follow the below steps
75 |
76 | 1. Install dependencies `yarn`
77 | 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
78 | 3. Run next in development mode `yarn dev`
79 | 4. Build and run in production mode `yarn build && yarn start`
80 |
81 | ## Credits
82 |
83 | - Guillermo Rauch [@rauchg](https://twitter.com/rauchg) for the initial idea
84 | - Shu Ding [@shuding\_](https://twitter.com/shuding_) for the design help
85 | - Luis Alvarez [@luis_fades](https://twitter.com/luis_fades) for design help and bug catching
86 |
--------------------------------------------------------------------------------
/src/styles/blog.module.css:
--------------------------------------------------------------------------------
1 | .blogIndex {
2 | padding: 0 5%;
3 | }
4 |
5 | .blogIndex h1 {
6 | margin-bottom: 50px;
7 | }
8 |
9 | .previewAlertContainer {
10 | display: flex;
11 | justify-content: center;
12 | }
13 |
14 | .previewAlert {
15 | display: inline-flex;
16 | align-items: center;
17 | justify-content: space-between;
18 | text-align: center;
19 | border: 1px solid #eaeaea;
20 | width: 400px;
21 | padding: 16px;
22 | border-radius: 5px;
23 | }
24 |
25 | .escapePreview {
26 | display: flex;
27 | flex-direction: column;
28 | align-items: center;
29 | justify-content: center;
30 | border: none;
31 | background-color: black;
32 | border: 1px solid black;
33 | color: white;
34 | padding: 10px;
35 | height: 24px;
36 | border-radius: 5px;
37 | transition: all 0.2s ease 0s;
38 | }
39 | .escapePreview:hover {
40 | background-color: white;
41 | color: black;
42 | border: 1px solid black;
43 | cursor: pointer;
44 | }
45 |
46 | .titleContainer {
47 | display: inline-flex;
48 | align-items: center;
49 | justify-content: flex-start;
50 | }
51 | .draftBadge {
52 | border-radius: 16px;
53 | background-color: black;
54 | color: white;
55 | font-size: 14px;
56 | font-weight: 200;
57 | padding: 2px 7px;
58 | margin-right: 6px;
59 | }
60 |
61 | .noPosts {
62 | text-align: center;
63 | }
64 |
65 | .postPreview {
66 | max-width: 600px;
67 | margin: 10px auto;
68 | border-bottom: 1px solid rgba(0, 0, 0, 0.8);
69 | }
70 |
71 | .postPreview:last-child {
72 | border-bottom: none;
73 | }
74 |
75 | .postPreview h3,
76 | .post h1 {
77 | margin-bottom: 0;
78 | }
79 |
80 | .post h1 {
81 | margin-top: 0;
82 | }
83 |
84 | .post :global(h2),
85 | .post :global(h3) {
86 | margin: 3rem 0 1.5rem 0;
87 | }
88 |
89 | .post :global(video),
90 | .post :global(img),
91 | .post :global(.asset-wrapper) {
92 | margin: 2rem auto;
93 | box-shadow: 0 8px 8px rgba(0, 0, 0, 0.3);
94 | max-width: 100%;
95 | display: block;
96 | }
97 |
98 | .post :global(.tweet) {
99 | margin: 2rem auto;
100 | text-align: center;
101 | }
102 |
103 | .post :global(.callout) {
104 | padding: 16px 16px 16px 12px;
105 | display: flex;
106 | width: 100%;
107 | border-radius: 3px;
108 | border-width: 1px;
109 | border-style: solid;
110 | border-color: transparent;
111 | background: rgba(235, 236, 237, 0.6);
112 | }
113 |
114 | .post :global(.callout .text) {
115 | margin-left: 8px;
116 | }
117 |
118 | .post :global(.underline) {
119 | text-decoration: underline;
120 | }
121 |
122 | .postPreview :global(.posted) {
123 | margin-bottom: 0.5em;
124 | }
125 |
126 | .post {
127 | max-width: calc(600px + 10%);
128 | margin: 0 auto;
129 | padding: 5%;
130 | }
131 |
132 | .post :global(hr) {
133 | margin-bottom: 2em;
134 | }
135 |
136 | .post :global(a + p) {
137 | margin-top: 0;
138 | }
139 |
140 | .bookmark {
141 | width: 100%;
142 | max-width: 100%;
143 | margin-top: 8px;
144 | margin-bottom: 8px;
145 | background-color: var(--bg);
146 | }
147 |
148 | .bookmark img {
149 | box-shadow: none;
150 | margin: 0;
151 | padding: 0;
152 | }
153 |
154 | .bookmarkContentsWrapper {
155 | display: block;
156 | color: inherit;
157 | text-decoration: none;
158 | flex-grow: 1;
159 | min-width: 0px;
160 | }
161 |
162 | .bookmarkContents {
163 | user-select: none;
164 | cursor: pointer;
165 | width: 100%;
166 | display: flex;
167 | flex-wrap: wrap-reverse;
168 | align-items: stretch;
169 | text-align: left;
170 | overflow: hidden;
171 | border: 1px solid #cacaca;
172 | border-radius: 3px;
173 | position: relative;
174 | color: inherit;
175 | fill: inherit;
176 | }
177 |
178 | .bookmarkInfo {
179 | flex: 4 1 180px;
180 | padding: 12px 14px 14px;
181 | overflow: hidden;
182 | text-align: left;
183 | }
184 |
185 | .bookmarkTitle {
186 | font-size: 14px;
187 | line-height: 20px;
188 | white-space: nowrap;
189 | overflow: hidden;
190 | text-overflow: ellipsis;
191 | min-height: 24px;
192 | margin-bottom: 2px;
193 | }
194 |
195 | .bookmarkDescription {
196 | font-size: 12px;
197 | line-height: 16px;
198 | height: 32px;
199 | overflow: hidden;
200 | }
201 |
202 | .bookmarkLinkWrapper {
203 | display: flex;
204 | margin-top: 6px;
205 | }
206 |
207 | .bookmarkLinkIcon {
208 | width: 16px;
209 | height: 16px;
210 | min-width: 16px;
211 | }
212 |
213 | .bookmarkLink {
214 | font-size: 12px;
215 | line-height: 16px;
216 | white-space: nowrap;
217 | overflow: hidden;
218 | text-overflow: ellipsis;
219 | margin-left: 6px;
220 | }
221 |
222 | .bookmarkCoverWrapper1 {
223 | flex: 1 1 180px;
224 | display: block;
225 | position: relative;
226 | }
227 |
228 | .bookmarkCoverWrapper2 {
229 | position: absolute;
230 | top: 0px;
231 | left: 0px;
232 | right: 0px;
233 | bottom: 0px;
234 | padding: 0;
235 | background-color: #ff0000;
236 | }
237 |
238 | .bookmarkCoverWrapper3 {
239 | width: 100%;
240 | height: 100%;
241 | padding: 0;
242 | margin: 0;
243 | }
244 |
245 | .bookmarkCover {
246 | display: block;
247 | object-fit: cover;
248 | border-radius: 1px;
249 | width: 100%;
250 | height: 100%;
251 | }
252 |
--------------------------------------------------------------------------------
/src/styles/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --gap-quarter: 0.25rem;
3 | --gap-half: 0.5rem;
4 | --gap: 1rem;
5 | --gap-double: 2rem;
6 |
7 | --bg: #fff;
8 | --fg: #000;
9 | --accents-1: #111;
10 | --accents-2: #333;
11 | --accents-3: #888;
12 | --geist-foreground: #000;
13 |
14 | --radius: 8px;
15 |
16 | --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
17 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
18 | sans-serif;
19 | --font-mono: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
20 | Bitstream Vera Sans Mono, Courier New, monospace;
21 | }
22 |
23 | * {
24 | box-sizing: border-box;
25 | }
26 |
27 | html,
28 | body {
29 | padding: 0;
30 | margin: 0;
31 | font-size: 20px;
32 | }
33 |
34 | body {
35 | min-height: 100vh;
36 | background: var(--bg);
37 | color: var(--fg);
38 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
39 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
40 |
41 | background-image: radial-gradient(#ddd 1px, transparent 1px),
42 | radial-gradient(#ddd 1px, transparent 1px);
43 | background-position: 0 0, 25px 25px;
44 | background-attachment: fixed;
45 | background-size: 50px 50px;
46 |
47 | /* Hack */
48 | overflow-x: hidden;
49 | }
50 |
51 | code[class*='language-'],
52 | pre[class*='language-'] {
53 | color: #000;
54 | text-align: left;
55 | white-space: pre;
56 | word-spacing: normal;
57 | word-break: normal;
58 | font-size: 0.95em;
59 | line-height: 1.4em;
60 | tab-size: 4;
61 | hyphens: none;
62 | }
63 | .token.comment,
64 | .token.prolog,
65 | .token.doctype,
66 | .token.cdata {
67 | color: #999;
68 | }
69 | .token.namespace {
70 | opacity: 0.7;
71 | }
72 | .token.string,
73 | .token.attr-value {
74 | color: #028265;
75 | }
76 | .token.punctuation,
77 | .token.operator {
78 | color: #000;
79 | }
80 | .token.url,
81 | .token.symbol,
82 | .token.boolean,
83 | .token.variable,
84 | .token.constant {
85 | color: #36acaa;
86 | }
87 | .token.atrule,
88 | .language-autohotkey .token.selector,
89 | .language-json .token.boolean,
90 | code[class*='language-css'] {
91 | font-weight: 600;
92 | }
93 | .language-json .token.boolean {
94 | color: var(--geist-success);
95 | }
96 | .token.keyword {
97 | color: #ff0078;
98 | font-weight: bolder;
99 | }
100 | .token.function,
101 | .token.tag,
102 | .token.class-name,
103 | .token.number,
104 | .token.tag .token.punctuation {
105 | color: var(--geist-success);
106 | }
107 | .language-autohotkey .token.tag {
108 | color: #9a050f;
109 | }
110 | .token.selector,
111 | .language-autohotkey .token.keyword {
112 | color: #00009f;
113 | }
114 | .token.important,
115 | .token.bold {
116 | font-weight: bold;
117 | }
118 | .token.italic {
119 | font-style: italic;
120 | }
121 | .token.deleted {
122 | color: red;
123 | font-weight: bolder;
124 | }
125 | .token.inserted {
126 | color: var(--geist-success);
127 | font-weight: bolder;
128 | }
129 | .language-json .token.property,
130 | .language-markdown .token.title {
131 | color: #000;
132 | font-weight: bolder;
133 | }
134 | .language-markdown .token.code {
135 | color: var(--geist-success);
136 | font-weight: normal;
137 | }
138 | .language-markdown .token.list,
139 | .language-markdown .token.hr {
140 | color: #999;
141 | }
142 | .language-markdown .token.url {
143 | color: #ff0078;
144 | font-weight: bolder;
145 | }
146 | .token.selector {
147 | color: #2b91af;
148 | }
149 | .token.property,
150 | .token.entity {
151 | color: #f00;
152 | }
153 | .token.attr-name,
154 | .token.regex {
155 | color: #d9931e;
156 | }
157 | .token.directive.tag .tag {
158 | background: #ff0;
159 | color: #393a34;
160 | }
161 | /* dark */
162 | pre.dark[class*='language-'] {
163 | color: #fafbfc;
164 | }
165 | .language-json .dark .token.boolean {
166 | color: var(--geist-success);
167 | }
168 | .dark .token.string {
169 | color: #50e3c2;
170 | }
171 | .dark .token.function,
172 | .dark .token.tag,
173 | .dark .token.class-name,
174 | .dark .token.number {
175 | color: #2ba8ff;
176 | }
177 | .dark .token.attr-value,
178 | .dark .token.punctuation,
179 | .dark .token.operator {
180 | color: #efefef;
181 | }
182 | .dark .token.attr-name,
183 | .dark .token.regex {
184 | color: #fac863;
185 | }
186 | .language-json .dark .token.property,
187 | .language-markdown .dark .token.title {
188 | color: #fff;
189 | }
190 | .language-markdown .dark .token.code {
191 | color: #50e3c2;
192 | }
193 |
194 | .links {
195 | display: flex;
196 | text-align: center;
197 | justify-content: center;
198 | align-items: center;
199 | }
200 |
201 | .features {
202 | display: flex;
203 | flex-wrap: wrap;
204 | margin: 4rem auto;
205 | width: 920px;
206 | max-width: calc(100vw - var(--gap-double));
207 | }
208 |
209 | .feature {
210 | flex: 0 0 25%;
211 | align-items: center;
212 | display: inline-flex;
213 | padding: 0 0.5rem 1.5rem;
214 | margin: 0 auto;
215 | }
216 |
217 | .feature h4 {
218 | margin: 0 0 0 0.5rem;
219 | font-weight: 700;
220 | font-size: 0.95rem;
221 | white-space: nowrap;
222 | }
223 |
224 | @media (max-width: 600px) {
225 | .feature div {
226 | flex-basis: auto;
227 | padding-left: 0;
228 | }
229 |
230 | .feature h4 {
231 | font-size: 0.75rem;
232 | }
233 | }
234 |
235 | .explanation {
236 | font-size: 1rem;
237 | width: 35rem;
238 | max-width: 100vw;
239 | padding: 0 2rem;
240 | background: var(--bg);
241 | margin: var(--gap-half) auto;
242 | }
243 | figure {
244 | font-size: 0.85rem;
245 | color: #999;
246 | line-height: 1.8;
247 | }
248 |
249 | figure {
250 | font-size: 0.85rem;
251 | color: #999;
252 | }
253 |
254 | a {
255 | color: #0070f3;
256 | text-decoration: none;
257 | }
258 | a:hover {
259 | color: #3291ff;
260 | }
261 |
262 | .links {
263 | margin-top: var(--gap);
264 | }
265 |
266 | mark {
267 | padding: var(--gap-quarter);
268 | border-radius: var(--radius);
269 | background: rgba(247, 212, 255, 0.8);
270 | }
271 |
272 | .title {
273 | text-align: center;
274 | }
275 |
276 | .logo :global(svg) {
277 | max-width: calc(100vw - var(--gap-double));
278 | }
279 |
280 | h1 {
281 | margin: var(--gap-double) 0 calc(0.5 * var(--gap)) 0;
282 | font-size: 2.25rem;
283 | font-weight: 800;
284 | letter-spacing: -0.05rem;
285 | }
286 |
287 | h2 {
288 | font-weight: 300;
289 | font-size: 1.25rem;
290 | letter-spacing: -0.02rem;
291 | color: var(--accents-3);
292 | }
293 |
294 | .video {
295 | width: 1080px;
296 | max-width: calc(100vw - 40px);
297 | transform: translateX(-50%);
298 | margin-left: 50%;
299 | text-align: center;
300 | cursor: pointer;
301 | }
302 | .video :global(video) {
303 | max-width: 100%;
304 | max-height: 90vh;
305 | outline: none;
306 | }
307 |
308 | p {
309 | color: #555;
310 | font-weight: 400;
311 | font-size: 0.94rem;
312 | line-height: 1.7;
313 | }
314 | pre {
315 | white-space: pre;
316 | }
317 | pre :global(code) {
318 | overflow: auto;
319 | -webkit-overflow-scrolling: touch;
320 | }
321 |
322 | code {
323 | font-size: 0.8rem;
324 | background: #f1f1f1;
325 | padding: 0.2rem;
326 | border-radius: var(--radius);
327 | font-family: var(--font-mono);
328 | }
329 |
330 | .slice {
331 | position: relative;
332 | }
333 |
334 | .slice::after {
335 | content: '';
336 | position: absolute;
337 | left: 0;
338 | top: 0;
339 | height: 100%;
340 | width: 130%;
341 | background: #fff;
342 | transform: skew(-20deg);
343 | }
344 |
345 | @media (max-width: 600px) {
346 | .explanation {
347 | padding: 0 1rem 4rem;
348 | }
349 |
350 | h2 {
351 | font-size: 0.95rem;
352 | letter-spacing: 0;
353 | }
354 | }
355 |
356 | .dotted {
357 | border-bottom: 1px dashed black;
358 | }
359 |
360 | footer {
361 | padding: 2em 0;
362 | text-align: center;
363 | }
364 |
365 | footer img {
366 | display: block;
367 | margin: 0.5rem auto;
368 | }
369 |
370 | footer span:nth-child(3) {
371 | color: #777777;
372 | }
373 |
374 | footer span:nth-child(3) a {
375 | color: inherit;
376 | }
377 |
--------------------------------------------------------------------------------
/src/lib/notion/createTable.js:
--------------------------------------------------------------------------------
1 | // commonjs so it can be run without transpiling
2 | const { v4: uuid } = require('uuid')
3 | const fetch = require('node-fetch')
4 | const {
5 | BLOG_INDEX_ID: pageId,
6 | NOTION_TOKEN,
7 | API_ENDPOINT,
8 | } = require('./server-constants')
9 |
10 | async function main() {
11 | const userId = await getUserId()
12 | const transactionId = () => uuid()
13 | const collectionId = uuid()
14 | const collectionViewId = uuid()
15 | const viewId = uuid()
16 | const now = Date.now()
17 | const pageId1 = uuid()
18 | const pageId2 = uuid()
19 | const pageId3 = uuid()
20 | let existingBlockId = await getExistingexistingBlockId()
21 |
22 | const requestBody = {
23 | requestId: uuid(),
24 | transactions: [
25 | {
26 | id: transactionId(),
27 | operations: [
28 | {
29 | id: collectionId,
30 | table: 'block',
31 | path: [],
32 | command: 'update',
33 | args: {
34 | id: collectionId,
35 | type: 'collection_view',
36 | collection_id: collectionViewId,
37 | view_ids: [viewId],
38 | properties: {},
39 | created_time: now,
40 | last_edited_time: now,
41 | },
42 | },
43 | {
44 | id: pageId1,
45 | table: 'block',
46 | path: [],
47 | command: 'update',
48 | args: {
49 | id: pageId1,
50 | type: 'page',
51 | parent_id: collectionViewId,
52 | parent_table: 'collection',
53 | alive: true,
54 | properties: {},
55 | created_time: now,
56 | last_edited_time: now,
57 | },
58 | },
59 | {
60 | id: pageId2,
61 | table: 'block',
62 | path: [],
63 | command: 'update',
64 | args: {
65 | id: pageId2,
66 | type: 'page',
67 | parent_id: collectionViewId,
68 | parent_table: 'collection',
69 | alive: true,
70 | properties: {},
71 | created_time: now,
72 | last_edited_time: now,
73 | },
74 | },
75 | {
76 | id: pageId3,
77 | table: 'block',
78 | path: [],
79 | command: 'update',
80 | args: {
81 | id: pageId3,
82 | type: 'page',
83 | parent_id: collectionViewId,
84 | parent_table: 'collection',
85 | alive: true,
86 | properties: {},
87 | created_time: now,
88 | last_edited_time: now,
89 | },
90 | },
91 | {
92 | id: viewId,
93 | table: 'collection_view',
94 | path: [],
95 | command: 'update',
96 | args: {
97 | id: viewId,
98 | version: 0,
99 | type: 'table',
100 | name: 'Default View',
101 | format: {
102 | table_properties: [
103 | { property: 'title', visible: true, width: 276 },
104 | { property: 'S6_"', visible: true },
105 | { property: 'la`A', visible: true },
106 | { property: 'a`af', visible: true },
107 | { property: 'ijjk', visible: true },
108 | ],
109 | table_wrap: true,
110 | },
111 | query2: {
112 | aggregations: [{ property: 'title', aggregator: 'count' }],
113 | },
114 | page_sort: [pageId1, pageId2, pageId3],
115 | parent_id: collectionId,
116 | parent_table: 'block',
117 | alive: true,
118 | },
119 | },
120 | {
121 | id: collectionViewId,
122 | table: 'collection',
123 | path: [],
124 | command: 'update',
125 | args: {
126 | id: collectionViewId,
127 | schema: {
128 | title: { name: 'Page', type: 'title' },
129 | 'S6_"': { name: 'Slug', type: 'text' },
130 | 'la`A': { name: 'Published', type: 'checkbox' },
131 | 'a`af': { name: 'Date', type: 'date' },
132 | ijjk: { name: 'Authors', type: 'person' },
133 | },
134 | format: {
135 | collection_page_properties: [
136 | { property: 'S6_"', visible: true },
137 | { property: 'la`A', visible: true },
138 | { property: 'a`af', visible: true },
139 | { property: 'ijjk', visible: true },
140 | ],
141 | },
142 | parent_id: collectionId,
143 | parent_table: 'block',
144 | alive: true,
145 | },
146 | },
147 | {
148 | id: collectionId,
149 | table: 'block',
150 | path: [],
151 | command: 'update',
152 | args: { parent_id: pageId, parent_table: 'block', alive: true },
153 | },
154 | {
155 | table: 'block',
156 | id: pageId,
157 | path: ['content'],
158 | command: 'listAfter',
159 | args: {
160 | ...(existingBlockId
161 | ? {
162 | after: existingBlockId,
163 | }
164 | : {}),
165 | id: collectionId,
166 | },
167 | },
168 | {
169 | table: 'block',
170 | id: collectionId,
171 | path: ['created_by_id'],
172 | command: 'set',
173 | args: userId,
174 | },
175 | {
176 | table: 'block',
177 | id: collectionId,
178 | path: ['created_by_table'],
179 | command: 'set',
180 | args: 'notion_user',
181 | },
182 | {
183 | table: 'block',
184 | id: collectionId,
185 | path: ['last_edited_time'],
186 | command: 'set',
187 | args: now,
188 | },
189 | {
190 | table: 'block',
191 | id: collectionId,
192 | path: ['last_edited_by_id'],
193 | command: 'set',
194 | args: userId,
195 | },
196 | {
197 | table: 'block',
198 | id: collectionId,
199 | path: ['last_edited_by_table'],
200 | command: 'set',
201 | args: 'notion_user',
202 | },
203 | {
204 | table: 'block',
205 | id: pageId1,
206 | path: ['created_by_id'],
207 | command: 'set',
208 | args: userId,
209 | },
210 | {
211 | table: 'block',
212 | id: pageId1,
213 | path: ['created_by_table'],
214 | command: 'set',
215 | args: 'notion_user',
216 | },
217 | {
218 | table: 'block',
219 | id: pageId1,
220 | path: ['last_edited_time'],
221 | command: 'set',
222 | args: now,
223 | },
224 | {
225 | table: 'block',
226 | id: pageId1,
227 | path: ['last_edited_by_id'],
228 | command: 'set',
229 | args: userId,
230 | },
231 | {
232 | table: 'block',
233 | id: pageId1,
234 | path: ['last_edited_by_table'],
235 | command: 'set',
236 | args: 'notion_user',
237 | },
238 | {
239 | table: 'block',
240 | id: pageId2,
241 | path: ['created_by_id'],
242 | command: 'set',
243 | args: userId,
244 | },
245 | {
246 | table: 'block',
247 | id: pageId2,
248 | path: ['created_by_table'],
249 | command: 'set',
250 | args: 'notion_user',
251 | },
252 | {
253 | table: 'block',
254 | id: pageId2,
255 | path: ['last_edited_time'],
256 | command: 'set',
257 | args: now,
258 | },
259 | {
260 | table: 'block',
261 | id: pageId2,
262 | path: ['last_edited_by_id'],
263 | command: 'set',
264 | args: userId,
265 | },
266 | {
267 | table: 'block',
268 | id: pageId2,
269 | path: ['last_edited_by_table'],
270 | command: 'set',
271 | args: 'notion_user',
272 | },
273 | {
274 | table: 'block',
275 | id: pageId3,
276 | path: ['created_by_id'],
277 | command: 'set',
278 | args: userId,
279 | },
280 | {
281 | table: 'block',
282 | id: pageId3,
283 | path: ['created_by_table'],
284 | command: 'set',
285 | args: 'notion_user',
286 | },
287 | {
288 | table: 'block',
289 | id: pageId3,
290 | path: ['last_edited_time'],
291 | command: 'set',
292 | args: now,
293 | },
294 | {
295 | table: 'block',
296 | id: pageId3,
297 | path: ['last_edited_by_id'],
298 | command: 'set',
299 | args: userId,
300 | },
301 | {
302 | table: 'block',
303 | id: pageId3,
304 | path: ['last_edited_by_table'],
305 | command: 'set',
306 | args: 'notion_user',
307 | },
308 | ],
309 | },
310 | ],
311 | }
312 |
313 | const res = await fetch(`${API_ENDPOINT}/submitTransaction`, {
314 | method: 'POST',
315 | headers: {
316 | cookie: `token_v2=${NOTION_TOKEN}`,
317 | 'content-type': 'application/json',
318 | },
319 | body: JSON.stringify(requestBody),
320 | })
321 |
322 | if (!res.ok) {
323 | throw new Error(`Failed to add table, request status ${res.status}`)
324 | }
325 | }
326 |
327 | async function getExistingexistingBlockId() {
328 | const res = await fetch(`${API_ENDPOINT}/loadPageChunk`, {
329 | method: 'POST',
330 | headers: {
331 | cookie: `token_v2=${NOTION_TOKEN}`,
332 | 'content-type': 'application/json',
333 | },
334 | body: JSON.stringify({
335 | pageId,
336 | limit: 25,
337 | cursor: { stack: [] },
338 | chunkNumber: 0,
339 | verticalColumns: false,
340 | }),
341 | })
342 |
343 | if (!res.ok) {
344 | throw new Error(
345 | `failed to get existing block id, request status: ${res.status}`
346 | )
347 | }
348 | const data = await res.json()
349 | const id = Object.keys(data ? data.recordMap.block : {}).find(
350 | id => id !== pageId
351 | )
352 | return id || uuid()
353 | }
354 |
355 | async function getUserId() {
356 | const res = await fetch(`${API_ENDPOINT}/loadUserContent`, {
357 | method: 'POST',
358 | headers: {
359 | cookie: `token_v2=${NOTION_TOKEN}`,
360 | 'content-type': 'application/json',
361 | },
362 | body: '{}',
363 | })
364 |
365 | if (!res.ok) {
366 | throw new Error(
367 | `failed to get Notion user id, request status: ${res.status}`
368 | )
369 | }
370 | const data = await res.json()
371 | return Object.keys(data.recordMap.notion_user)[0]
372 | }
373 |
374 | module.exports = main
375 |
--------------------------------------------------------------------------------
/src/pages/blog/[slug].tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import fetch from 'node-fetch'
3 | import { useRouter } from 'next/router'
4 | import Header from '../../components/header'
5 | import Heading from '../../components/heading'
6 | import components from '../../components/dynamic'
7 | import ReactJSXParser from '@zeit/react-jsx-parser'
8 | import blogStyles from '../../styles/blog.module.css'
9 | import { textBlock } from '../../lib/notion/renderers'
10 | import getPageData from '../../lib/notion/getPageData'
11 | import React, { CSSProperties, useEffect } from 'react'
12 | import getBlogIndex from '../../lib/notion/getBlogIndex'
13 | import getNotionUsers from '../../lib/notion/getNotionUsers'
14 | import { getBlogLink, getDateStr } from '../../lib/blog-helpers'
15 |
16 | // Get the data for each blog post
17 | export async function getStaticProps({ params: { slug }, preview }) {
18 | // load the postsTable so that we can get the page's ID
19 | const postsTable = await getBlogIndex()
20 | const post = postsTable[slug]
21 |
22 | // if we can't find the post or if it is unpublished and
23 | // viewed without preview mode then we just redirect to /blog
24 | if (!post || (post.Published !== 'Yes' && !preview)) {
25 | console.log(`Failed to find post for slug: ${slug}`)
26 | return {
27 | props: {
28 | redirect: '/blog',
29 | preview: false,
30 | },
31 | unstable_revalidate: 5,
32 | }
33 | }
34 | const postData = await getPageData(post.id)
35 | post.content = postData.blocks
36 |
37 | for (let i = 0; i < postData.blocks.length; i++) {
38 | const { value } = postData.blocks[i]
39 | const { type, properties } = value
40 | if (type == 'tweet') {
41 | const src = properties.source[0][0]
42 | // parse id from https://twitter.com/_ijjk/status/TWEET_ID format
43 | const tweetId = src.split('/')[5].split('?')[0]
44 | if (!tweetId) continue
45 |
46 | try {
47 | const res = await fetch(
48 | `https://api.twitter.com/1/statuses/oembed.json?id=${tweetId}`
49 | )
50 | const json = await res.json()
51 | properties.html = json.html.split('