feat: lots of refactors and improvements

This commit is contained in:
Travis Fischer
2022-03-23 04:05:22 -04:00
parent c0904c8811
commit 5417bb9bbc
27 changed files with 830 additions and 3062 deletions

View File

@@ -7,15 +7,6 @@
# @see https://github.com/rolodato/dotenv-safe for more details.
# ------------------------------------------------------------------------------
# Optional (for preview image support)
#GOOGLE_APPLICATION_CREDENTIALS=
# Optional (for preview image support)
#GCLOUD_PROJECT=
# Optional (for preview image support)
#FIREBASE_COLLECTION_IMAGES=
# Optional (for fathom analytics)
#NEXT_PUBLIC_FATHOM_ID=

View File

@@ -1,38 +0,0 @@
{
"root": true,
"parserOptions": {
"ecmaVersion": 2020,
"ecmaFeatures": {
"legacyDecorators": true,
"jsx": true
}
},
"settings": {
"react": {
"version": "detect"
}
},
"ignorePatterns": ["**/*.js", "**/*.jsx"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"plugin:@typescript-eslint/recommended",
"standard",
"standard-react",
"plugin:prettier/recommended"
],
"env": { "browser": true, "node": true },
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"no-use-before-define": 0,
"@typescript-eslint/no-use-before-define": 0,
"space-before-function-paren": 0,
"react/prop-types": 0,
"react/jsx-handler-names": 0,
"react/jsx-fragments": 0,
"react/no-unused-prop-types": 0,
"import/export": 0,
"standard/no-callback-literal": 0
}
}

27
.eslintrc.json Normal file
View File

@@ -0,0 +1,27 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react", "react-hooks"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"settings": {
"react": {
"version": "detect"
}
},
"env": {
"browser": true,
"node": true
},
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": 2,
"react/prop-types": 0
}
}

View File

@@ -1,5 +0,0 @@
language: node_js
cache: yarn
node_js:
- 12
- 14

View File

@@ -1,86 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import got from 'got'
import lqip from 'lqip-modern'
import { isPreviewImageSupportEnabled } from '../lib/config'
import * as types from '../lib/types'
import * as db from '../lib/db'
export default async (
req: NextApiRequest,
res: NextApiResponse
): Promise<void> => {
if (req.method !== 'POST') {
return res.status(405).send({ error: 'method not allowed' })
}
if (!isPreviewImageSupportEnabled) {
return res.status(418).send({
error: 'preview image support has been disabled for this deployment'
})
}
const { url, id } = req.body
const result = await createPreviewImage(url, id)
res.setHeader(
'Cache-Control',
result.error
? 'public, s-maxage=60, max-age=60, stale-while-revalidate=60'
: 'public, immutable, s-maxage=31536000, max-age=31536000, stale-while-revalidate=60'
)
res.status(200).json(result)
}
export async function createPreviewImage(
url: string,
id: string
): Promise<types.PreviewImage> {
console.log('createPreviewImage lambda', { url, id })
const doc = db.images.doc(id)
try {
const model = await doc.get()
if (model.exists) {
return model.data() as types.PreviewImage
}
const { body } = await got(url, { responseType: 'buffer' })
const result = await lqip(body)
console.log('lqip', result.metadata)
const image = {
url,
originalWidth: result.metadata.originalWidth,
originalHeight: result.metadata.originalHeight,
width: result.metadata.width,
height: result.metadata.height,
type: result.metadata.type,
dataURIBase64: result.metadata.dataURIBase64
}
await doc.create(image)
return image
} catch (err) {
console.error('lqip error', err)
try {
const error: any = {
url,
error: err.message || 'unknown error'
}
if (err?.response?.statusCode) {
error.statusCode = err?.response?.statusCode
}
await doc.create(error)
return error
} catch (err) {
// ignore errors
console.error(err)
}
}
}

View File

@@ -26,7 +26,7 @@ export const Footer: React.FC<{
return (
<footer className={styles.footer}>
<div className={styles.copyright}>Copyright 2021 {config.author}</div>
<div className={styles.copyright}>Copyright 2022 {config.author}</div>
{hasMounted ? (
<div className={styles.settings}>

View File

@@ -1,4 +1,5 @@
import * as React from 'react'
import Image from 'next/image'
import Head from 'next/head'
import Link from 'next/link'
import dynamic from 'next/dynamic'
@@ -33,10 +34,13 @@ import { PageActions } from './PageActions'
import { Footer } from './Footer'
import { PageSocial } from './PageSocial'
import { GitHubShareButton } from './GitHubShareButton'
import { ReactUtterances } from './ReactUtterances'
import styles from './styles.module.css'
// -----------------------------------------------------------------------------
// dynamic imports for optional components
// -----------------------------------------------------------------------------
// const Code = dynamic(() =>
// import('react-notion-x').then((notion) => notion.Code)
// )
@@ -51,21 +55,15 @@ import styles from './styles.module.css'
// ssr: false
// }
// )
// TODO: PDF support via "react-pdf" package has numerous troubles building
// with next.js
//
// const Pdf = dynamic(
// () => import('react-notion-x').then((notion) => notion.Pdf),
// () => import('react-notion-x').then((notion) => (notion as any).Pdf),
// { ssr: false }
// )
const Equation = dynamic(() =>
import('react-notion-x').then((notion) => notion.Equation)
)
// we're now using a much lighter-weight tweet renderer react-static-tweets
// instead of the official iframe-based embed widget from twitter
// const Tweet = dynamic(() => import('react-tweet-embed'))
//
// const Equation = dynamic(() =>
// import('react-notion-x').then((notion) => notion.Equation)
// )
const Modal = dynamic(
() => import('react-notion-x').then((notion) => notion.Modal),
@@ -139,22 +137,10 @@ export const NotionPage: React.FC<types.PageProps> = ({
const socialDescription =
getPageDescription(block, recordMap) ?? config.description
let comments: React.ReactNode = null
let pageAside: React.ReactChild = null
// only display comments and page actions on blog post pages
if (isBlogPost) {
if (config.utterancesGitHubRepo) {
comments = (
<ReactUtterances
repo={config.utterancesGitHubRepo}
issueMap='issue-term'
issueTerm='title'
theme={darkMode.value ? 'photon-dark' : 'github-light'}
/>
)
}
const tweet = getPageTweet(block, recordMap)
if (tweet) {
pageAside = <PageActions tweet={tweet} />
@@ -249,15 +235,43 @@ export const NotionPage: React.FC<types.PageProps> = ({
<a {...props} />
</Link>
),
image: ({
src,
alt,
width,
height,
className,
style,
...rest
}) => {
const layout = width && height ? 'intrinsic' : 'fill'
return (
<Image
{...rest}
className={className}
src={src}
alt={alt}
width={layout === 'intrinsic' && width}
height={layout === 'intrinsic' && height}
objectFit={style?.objectFit}
objectPosition={style?.objectPosition}
layout={layout}
/>
)
},
code: Code,
collection: Collection,
collectionRow: CollectionRow,
tweet: Tweet,
modal: Modal,
equation: Equation
modal: Modal
}}
recordMap={recordMap}
rootPageId={site.rootNotionPageId}
rootDomain={site.domain}
fullPage={!isLiteMode}
darkMode={darkMode.value}
previewImages={site.previewImages !== false}
@@ -270,7 +284,6 @@ export const NotionPage: React.FC<types.PageProps> = ({
mapPageUrl={siteMapPageUrl}
mapImageUrl={mapNotionImageUrl}
searchNotion={searchNotion}
pageFooter={comments}
pageAside={pageAside}
footer={
<Footer

View File

@@ -28,7 +28,8 @@ export const Page404: React.FC<types.PageProps> = ({ site, pageId, error }) => {
) : (
pageId && (
<p>
Make sure that Notion page "{pageId}" is publicly accessible.
Make sure that Notion page &quot;{pageId}&quot; is publicly
accessible.
</p>
)
)}

View File

@@ -1,110 +0,0 @@
import React from 'react'
import styles from './styles.module.css'
export type MappingType =
| 'pathname'
| 'url'
| 'title'
| 'og:title'
| 'issue-number'
| 'issue-term'
export type Theme =
| 'github-light'
| 'github-dark'
| 'preferred-color-scheme'
| 'github-dark-orange'
| 'icy-dark'
| 'dark-blue'
| 'photon-dark'
interface ReactUtterancesProps {
repo: string
issueMap: MappingType
issueTerm?: string
issueNumber?: number
label?: string
theme: Theme
}
interface ReactUtterancesState {
pending: boolean
}
export class ReactUtterances extends React.Component<
ReactUtterancesProps,
ReactUtterancesState
> {
reference: React.RefObject<HTMLDivElement>
scriptElement: any
constructor(props: ReactUtterancesProps) {
super(props)
if (props.issueMap === 'issue-term' && props.issueTerm === undefined) {
throw Error(
"Property 'issueTerm' must be provided with issueMap 'issue-term'"
)
}
if (props.issueMap === 'issue-number' && props.issueNumber === undefined) {
throw Error(
"Property 'issueNumber' must be provided with issueMap 'issue-number'"
)
}
this.reference = React.createRef<HTMLDivElement>()
this.state = { pending: true }
}
UNSAFE_componentWillReceiveProps(props) {
// this.scriptElement.setAttribute('theme', props.theme)
const iframe = document.querySelector('iframe.utterances-frame') as any
if (iframe) {
iframe.contentWindow.postMessage(
{ type: 'set-theme', theme: props.theme },
'https://utteranc.es/'
)
}
}
componentDidMount(): void {
const { repo, issueMap, issueTerm, issueNumber, label, theme } = this.props
const scriptElement = document.createElement('script')
scriptElement.src = 'https://utteranc.es/client.js'
scriptElement.async = true
scriptElement.defer = true
scriptElement.setAttribute('repo', repo)
scriptElement.setAttribute('crossorigin', 'annonymous')
scriptElement.setAttribute('theme', theme)
scriptElement.onload = () => this.setState({ pending: false })
if (label) {
scriptElement.setAttribute('label', label)
}
if (issueMap === 'issue-number') {
scriptElement.setAttribute('issue-number', issueNumber.toString())
} else if (issueMap === 'issue-term') {
scriptElement.setAttribute('issue-term', issueTerm)
} else {
scriptElement.setAttribute('issue-term', issueMap)
}
// TODO: Check current availability
this.scriptElement = scriptElement
this.reference.current.appendChild(scriptElement)
}
render(): React.ReactElement {
return (
<div className={styles.comments}>
<div className={styles.utterances} ref={this.reference}>
{this.state.pending && <p>Loading Comments...</p>}
</div>
</div>
)
}
}

View File

@@ -107,19 +107,6 @@
border-top: 1px solid var(--fg-color-0);
}
.utterances {
margin-top: 2em;
width: 100%;
}
@media only screen and (min-width: 567px) {
.utterances {
width: calc(100% + 60px);
position: relative;
left: -60px;
}
}
@media only screen and (max-width: 566px) {
.footer {
flex-direction: column;

View File

@@ -36,6 +36,9 @@ export const pageUrlAdditions = cleanPageUrlMap(
'pageUrlAdditions'
)
export const isDev =
process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
// general site config
export const name: string = getSiteConfig('name')
export const author: string = getSiteConfig('author')
@@ -70,24 +73,20 @@ export const defaultPageCoverPosition: number = getSiteConfig(
0.5
)
// Optional utteranc.es comments via GitHub issue comments
export const utterancesGitHubRepo: string | null = getSiteConfig(
'utterancesGitHubRepo',
null
)
// Optional image CDN host to proxy all image requests through
export const imageCDNHost: string | null = getSiteConfig('imageCDNHost', null)
// Optional whether or not to enable support for LQIP preview images
// (requires a Google Firebase collection)
export const isPreviewImageSupportEnabled: boolean = getSiteConfig(
'isPreviewImageSupportEnabled',
false
)
export const isDev =
process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
// Optional whether or not to enable support for LQIP preview images
export const isTweetEmbedSupportEnabled: boolean = getSiteConfig(
'isTweetEmbedSupportEnabled',
true
)
// where it all starts -- the site's root Notion page
export const includeNotionIdInUrls: boolean = getSiteConfig(
@@ -105,7 +104,6 @@ export const host = isDev ? `http://localhost:${port}` : `https://${domain}`
export const apiBaseUrl = `${host}/api`
export const api = {
createPreviewImage: `${apiBaseUrl}/create-preview-image`,
searchNotion: `${apiBaseUrl}/search-notion`
}
@@ -119,46 +117,6 @@ export const fathomConfig = fathomId
}
: undefined
const defaultEnvValueForPreviewImageSupport =
isPreviewImageSupportEnabled && isServer ? undefined : null
export const googleProjectId = getEnv(
'GCLOUD_PROJECT',
defaultEnvValueForPreviewImageSupport
)
export const googleApplicationCredentials = getGoogleApplicationCredentials()
export const firebaseCollectionImages = getEnv(
'FIREBASE_COLLECTION_IMAGES',
defaultEnvValueForPreviewImageSupport
)
// this hack is necessary because vercel doesn't support secret files so we need to encode our google
// credentials a base64-encoded string of the JSON-ified content
function getGoogleApplicationCredentials() {
if (!isPreviewImageSupportEnabled || !isServer) {
return null
}
try {
const googleApplicationCredentialsBase64 = getEnv(
'GOOGLE_APPLICATION_CREDENTIALS',
defaultEnvValueForPreviewImageSupport
)
return JSON.parse(
Buffer.from(googleApplicationCredentialsBase64, 'base64').toString()
)
} catch (err) {
console.error(
'Firebase config error: invalid "GOOGLE_APPLICATION_CREDENTIALS" should be base64-encoded JSON\n'
)
throw err
}
}
function cleanPageUrlMap(
pageUrlMap: PageUrlOverridesMap,
label: string

View File

@@ -1,14 +0,0 @@
import * as firestore from '@google-cloud/firestore'
import * as config from './config'
export let db: firestore.Firestore = null
export let images: firestore.CollectionReference = null
if (config.isPreviewImageSupportEnabled) {
db = new firestore.Firestore({
projectId: config.googleProjectId,
credentials: config.googleApplicationCredentials
})
images = db.collection(config.firebaseCollectionImages)
}

View File

@@ -1,58 +0,0 @@
import crypto from 'crypto'
import got from 'got'
import pMap from 'p-map'
import { api, isPreviewImageSupportEnabled } from './config'
import * as types from './types'
import * as db from './db'
function sha256(input: Buffer | string) {
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input)
return crypto.createHash('sha256').update(buffer).digest('hex')
}
export async function getPreviewImages(
images: string[]
): Promise<types.PreviewImageMap> {
if (!isPreviewImageSupportEnabled) {
return {}
}
const imageDocRefs = images.map((url) => {
const id = sha256(url)
return db.images.doc(id)
})
if (!imageDocRefs.length) {
return {}
}
const imageDocs = await db.db.getAll(...imageDocRefs)
const results = await pMap(imageDocs, async (model, index) => {
if (model.exists) {
return model.data() as types.PreviewImage
} else {
const json = {
url: images[index],
id: model.id
}
console.log('createPreviewImage server-side', json)
// TODO: should we fire and forget here to speed up builds?
return got
.post(api.createPreviewImage, { json })
.json() as Promise<types.PreviewImage>
}
})
return results
.filter(Boolean)
.filter((image) => !image.error)
.reduce(
(acc, result) => ({
...acc,
[result.url]: result
}),
{}
)
}

View File

@@ -48,7 +48,6 @@ export const mapImageUrl = (imageUrl: string) => {
}
if (imageCDNHost) {
// Our proxy uses Cloudflare's global CDN to cache these image assets
return `${imageCDNHost}/${encodeURIComponent(imageUrl)}`
} else {
return imageUrl

View File

@@ -1,9 +1,12 @@
import { NotionAPI } from 'notion-client'
import { ExtendedRecordMap, SearchParams, SearchResults } from 'notion-types'
import { getPreviewImages } from './get-preview-images'
import { mapNotionImageUrl } from './map-image-url'
import { fetchTweetAst } from 'static-tweets'
import pMap from 'p-map'
import { getPreviewImageMap } from './preview-images'
import { getTweetAstMap } from './tweet-embeds'
import {
isPreviewImageSupportEnabled,
isTweetEmbedSupportEnabled
} from './config'
export const notion = new NotionAPI({
apiBaseUrl: process.env.NOTION_API_BASE_URL
@@ -11,92 +14,16 @@ export const notion = new NotionAPI({
export async function getPage(pageId: string): Promise<ExtendedRecordMap> {
const recordMap = await notion.getPage(pageId)
const blockIds = Object.keys(recordMap.block)
const imageUrls: string[] = blockIds
.map((blockId) => {
const block = recordMap.block[blockId]?.value
if (isPreviewImageSupportEnabled) {
const previewImageMap = await getPreviewImageMap(recordMap)
;(recordMap as any).preview_images = previewImageMap
}
if (block) {
if (block.type === 'image') {
const source = block.properties?.source?.[0]?.[0]
if (source) {
return {
block,
url: source
}
}
}
if ((block.format as any)?.page_cover) {
const source = (block.format as any).page_cover
return {
block,
url: source
}
}
}
return null
})
.filter(Boolean)
.map(({ block, url }) => mapNotionImageUrl(url, block))
.filter(Boolean)
const urls = Array.from(new Set(imageUrls))
const previewImageMap = await getPreviewImages(urls)
;(recordMap as any).preview_images = previewImageMap
const tweetIds: string[] = blockIds
.map((blockId) => {
const block = recordMap.block[blockId]?.value
if (block) {
if (block.type === 'tweet') {
const src = block.properties?.source?.[0]?.[0]
if (src) {
const id = src.split('?')[0].split('/').pop()
if (id) return id
}
}
}
return null
})
.filter(Boolean)
const tweetAsts = await pMap(
tweetIds,
async (tweetId) => {
try {
return {
tweetId,
tweetAst: await fetchTweetAst(tweetId)
}
} catch (err) {
console.error('error fetching tweet info', tweetId, err)
}
},
{
concurrency: 4
}
)
const tweetAstMap = tweetAsts.reduce((acc, { tweetId, tweetAst }) => {
if (tweetAst) {
return {
...acc,
[tweetId]: tweetAst
}
} else {
return acc
}
}, {})
;(recordMap as any).tweetAstMap = tweetAstMap
if (isTweetEmbedSupportEnabled) {
const tweetAstMap = await getTweetAstMap(recordMap)
;(recordMap as any).tweetAstMap = tweetAstMap
}
return recordMap
}

79
lib/preview-images.ts Normal file
View File

@@ -0,0 +1,79 @@
import got from 'got'
import lqip from 'lqip-modern'
import pMap from 'p-map'
import pMemoize from 'p-memoize'
import { ExtendedRecordMap, PreviewImage, PreviewImageMap } from 'notion-types'
import { mapNotionImageUrl } from './map-image-url'
// NOTE: this is just an example of how to pre-compute preview images.
// Depending on how many images you're working with, this can potentially be
// very expensive to recompute, so in production we recommend that you cache
// the preview image results in a key-value database of your choosing.
// If you're not sure where to start, check out https://github.com/jaredwray/keyv
export async function getPreviewImageMap(
recordMap: ExtendedRecordMap
): Promise<PreviewImageMap> {
const blockIds = Object.keys(recordMap.block)
const imageUrls: string[] = blockIds
.map((blockId) => {
const block = recordMap.block[blockId]?.value
if (block) {
if (block.type === 'image') {
const signedUrl = recordMap.signed_urls?.[block.id]
const source = signedUrl || block.properties?.source?.[0]?.[0]
if (source) {
return {
block,
url: source
}
}
}
if ((block.format as any)?.page_cover) {
const source = (block.format as any).page_cover
return {
block,
url: source
}
}
}
return null
})
.filter(Boolean)
.map(({ block, url }) => mapNotionImageUrl(url, block))
.filter(Boolean)
const urls = Array.from(new Set(imageUrls))
const previewImagesMap = Object.fromEntries(
await pMap(urls, async (url) => [url, await getPreviewImage(url)], {
concurrency: 8
})
)
return previewImagesMap
}
async function createPreviewImage(url: string): Promise<PreviewImage | null> {
try {
const { body } = await got(url, { responseType: 'buffer' })
const result = await lqip(body)
console.log('lqip', result.metadata)
return {
originalWidth: result.metadata.originalWidth,
originalHeight: result.metadata.originalHeight,
dataURIBase64: result.metadata.dataURIBase64
}
} catch (err) {
console.warn('error creating preview image', url, err)
return null
}
}
export const getPreviewImage = pMemoize(createPreviewImage)

55
lib/tweet-embeds.ts Normal file
View File

@@ -0,0 +1,55 @@
import { ExtendedRecordMap } from 'notion-types'
import { fetchTweetAst } from 'static-tweets'
import pMap from 'p-map'
export async function getTweetAstMap(recordMap: ExtendedRecordMap) {
const blockIds = Object.keys(recordMap.block)
const tweetIds: string[] = blockIds
.map((blockId) => {
const block = recordMap.block[blockId]?.value
if (block) {
if (block.type === 'tweet') {
const src = block.properties?.source?.[0]?.[0]
if (src) {
const id = src.split('?')[0].split('/').pop()
if (id) return id
}
}
}
return null
})
.filter(Boolean)
const tweetAsts = await pMap(
tweetIds,
async (tweetId) => {
try {
return {
tweetId,
tweetAst: await fetchTweetAst(tweetId)
}
} catch (err) {
console.error('error fetching tweet info', tweetId, err)
}
},
{
concurrency: 4
}
)
const tweetAstMap = tweetAsts.reduce((acc, { tweetId, tweetAst }) => {
if (tweetAst) {
return {
...acc,
[tweetId]: tweetAst
}
} else {
return acc
}
}, {})
return tweetAstMap
}

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 Travis Fischer
Copyright (c) 2022 Travis Fischer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

1
next-env.d.ts vendored
View File

@@ -1,5 +1,4 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited

View File

@@ -5,7 +5,14 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
})
module.exports = withBundleAnalyzer({
staticPageGenerationTimeout: 300,
images: {
domains: ['pbs.twimg.com']
domains: [
'www.notion.so',
'notion.so',
'images.unsplash.com',
'pbs.twimg.com'
],
formats: ['image/avif', 'image/webp']
}
})

View File

@@ -13,61 +13,54 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"deploy": "vercel --prod",
"deploy": "vercel deploy",
"deps": "run-s deps:*",
"deps:update": "[ -z $GITHUB_ACTIONS ] && yarn add notion-client notion-types notion-utils react-notion-x || echo 'Skipping deps:update on CI'",
"deps:link": "[ -z $GITHUB_ACTIONS ] && yarn link notion-client notion-types notion-utils react-notion-x || echo 'Skipping deps:link on CI'",
"analyze": "cross-env ANALYZE=true next build",
"analyze:server": "cross-env BUNDLE_ANALYZE=server next build",
"analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build",
"test": "run-s test:*",
"test:lint": "eslint .",
"test:prettier": "prettier '**/*.{js,jsx,ts,tsx}' --check",
"posttest": "run-s build"
"test": "run-p test:*",
"test:lint": "eslint '**/*.{ts,tsx}'",
"test:prettier": "prettier '**/*.{js,jsx,ts,tsx}' --check"
},
"dependencies": {
"@google-cloud/firestore": "^4.10.1",
"classnames": "^2.3.1",
"date-fns": "^2.25.0",
"fathom-client": "^3.0.0",
"got": "^11.8.2",
"isomorphic-unfetch": "^3.1.0",
"lqip-modern": "^1.2.0",
"next": "^11.1.2",
"next": "^12.1.0",
"node-fetch": "^2.6.1",
"notion-client": "^4.10.0",
"notion-types": "^4.10.0",
"notion-utils": "^4.10.0",
"notion-client": "^4.19.1",
"notion-types": "^4.19.1",
"notion-utils": "^4.19.1",
"p-map": "^4.0.0",
"p-memoize": "^4.0.0",
"react": "^17.0.2",
"react-body-classname": "^1.3.1",
"react-dom": "^17.0.2",
"react-icons": "^4.3.1",
"react-notion-x": "^4.11.0",
"react-static-tweets": "^0.5.3",
"react-use": "^17.2.4",
"static-tweets": "^0.5.3",
"react-notion-x": "^4.19.2",
"react-static-tweets": "^0.7.1",
"react-use": "^17.3.2",
"static-tweets": "^0.7.1",
"use-dark-mode": "^2.3.1"
},
"devDependencies": {
"@next/bundle-analyzer": "^11.1.2",
"@next/bundle-analyzer": "^12.1.0",
"@types/classnames": "^2.3.1",
"@types/node": "^16.11.2",
"@types/node-fetch": "^3.0.3",
"@types/react": "^17.0.31",
"@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/parser": "^5.1.0",
"@typescript-eslint/eslint-plugin": "^5.15.0",
"@typescript-eslint/parser": "^5.15.0",
"cross-env": "^7.0.2",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^16.0.3",
"eslint-config-standard-react": "^11.0.1",
"eslint-plugin-import": "^2.25.2",
"eslint-plugin-node": "^11.0.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-promise": "^5.1.1",
"eslint-plugin-react": "^7.26.1",
"eslint": "^8.11.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.3.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.4.1",
"typescript": "^4.4.4"

View File

@@ -48,20 +48,20 @@ export default function App({ Component, pageProps }) {
const router = useRouter()
React.useEffect(() => {
function onRouteChangeComplete() {
Fathom.trackPageview()
}
if (fathomId) {
Fathom.load(fathomId, fathomConfig)
function onRouteChangeComplete() {
Fathom.trackPageview()
}
router.events.on('routeChangeComplete', onRouteChangeComplete)
return () => {
router.events.off('routeChangeComplete', onRouteChangeComplete)
}
}
}, [])
}, [router.events])
return <Component {...pageProps} />
}

View File

@@ -24,14 +24,11 @@ The site is then deployed to [Vercel](http://vercel.com).
- Robust support for Notion content via [react-notion-x](https://github.com/NotionX/react-notion-x)
- Next.js / TS / React / Notion
- Excellent page speeds
- Sexy LQIP image previews
- Embedded GitHub comments
- Automatic open graph images
- Automatic pretty URLs
- Automatic table of contents
- Full support for dark mode
- Quick search via CMD+P just like in Notion
- Responsive for desktop / tablet / mobile
- Responsive for different devices
- Optimized for Next.js and Vercel
## Setup
@@ -52,7 +49,7 @@ You'll want to make your root Notion page **public** and then copy the link to y
In order to find your Notion workspace ID (optional), just load any of your site's pages into your browser and open up the developer console. There will be a global variable that you can access called `block` which is the Notion data for the current page, and you just have to type `block.space_id` which will print out your page's workspace ID.
I recommend setting up a collection on your home page (optional; I use an inline gallery [here](https://notion.so/78fc5a4b88d74b0e824e29407e9f1ec1)) that contains all of your articles / projects / content. There are no structural constraints on your Notion workspace, however, so feel free to add content as you would normally in Notion. There are a few parts of the code with logic to only show comments on blog post pages (collection item detail pages).
I recommend setting up a collection on your home page (optional; I use an inline gallery [here](https://notion.so/78fc5a4b88d74b0e824e29407e9f1ec1)) that contains all of your articles / projects / content. There are no structural constraints on your Notion workspace, however, so feel free to add content as you would normally in Notion.
## URL Paths
@@ -62,9 +59,9 @@ In development, it will use `/nextjs-notion-blog-d1b5dcf8b9ff425b8aef5ce6f073020
In production, it will use `/nextjs-notion-blog` which is a bit nicer as it gets rid of the extra ID clutter.
The mapping of Notion ID to slugified page titles is done automatically for you as part of the build process. Just keep in mind that if you plan on changing page titles over time, you probably want to make sure old links will still work, and we don't currently provide a solution for detecting old links aside from Next.js built-in [support for redirects](https://nextjs.org/docs/api-reference/next.config.js/redirects).
The mapping of Notion ID to slugified page titles is done automatically as part of the build process. Just keep in mind that if you plan on changing page titles over time, you probably want to make sure old links will still work, and we don't currently provide a solution for detecting old links aside from Next.js's built-in [support for redirects](https://nextjs.org/docs/api-reference/next.config.js/redirects).
See [mapPageUrl](./lib/map-page-url.ts) and [getCanonicalPageId](https://github.com/NotionX/react-notion-x/blob/master/packages/notion-utils/src/get-canonical-page-id.ts) from for more details.
See [mapPageUrl](./lib/map-page-url.ts) and [getCanonicalPageId](https://github.com/NotionX/react-notion-x/blob/master/packages/notion-utils/src/get-canonical-page-id.ts) for more details.
NOTE: if you have multiple pages in your workspace with the same slugified name, the app will throw an error letting you know that there are duplicate URL pathnames.
@@ -79,9 +76,9 @@ It should be pretty easy to customize most styling-related things, especially wi
### Dark Mode
<p align="center">
<img alt="Light Mode" src="https://ssfy.io/https%3A%2F%2Fwww.notion.so%2Fimage%2Fhttps%253A%252F%252Fs3-us-west-2.amazonaws.com%252Fsecure.notion-static.com%252F83ea9f0f-4761-4c0b-b53e-1913627975fc%252Ftransitivebullsh.it_-opt.jpg%3Ftable%3Dblock%26id%3Ded7e8f60-c6d1-449e-840b-5c7762505c44%26cache%3Dv2" width="45%">
<img alt="Light Mode" src="https://transitive-bs.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F83ea9f0f-4761-4c0b-b53e-1913627975fc%2Ftransitivebullsh.it_-opt.jpg?table=block&id=ed7e8f60-c6d1-449e-840b-5c7762505c44&spaceId=fde5ac74-eea3-4527-8f00-4482710e1af3&width=2000&userId=&cache=v2" width="45%">
&nbsp; &nbsp; &nbsp; &nbsp;
<img alt="Dark Mode" src="https://ssfy.io/https%3A%2F%2Fwww.notion.so%2Fimage%2Fhttps%253A%252F%252Fs3-us-west-2.amazonaws.com%252Fsecure.notion-static.com%252Fc0839d6c-7141-48df-8afd-69b27fed84aa%252Ftransitivebullsh.it__(1)-opt.jpg%3Ftable%3Dblock%26id%3D23b11fe5-d6df-422d-9674-39cf7f547523%26cache%3Dv2" width="45%">
<img alt="Dark Mode" src="https://transitive-bs.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Fc0839d6c-7141-48df-8afd-69b27fed84aa%2Ftransitivebullsh.it__(1)-opt.jpg?table=block&id=23b11fe5-d6df-422d-9674-39cf7f547523&spaceId=fde5ac74-eea3-4527-8f00-4482710e1af3&width=2000&userId=&cache=v2" width="45%">
</p>
Dark mode is fully supported and can be toggled via the sun / moon icon in the footer.
@@ -102,49 +99,6 @@ To enable analytics, just add a `NEXT_PUBLIC_FATHOM_ID` environment variable.
This environment variable will only be taken into account in production, so you don't have to worry about messing up your analytics with localhost development.
### GitHub Comments
<p align="center">
<img alt="Embedded GitHub Comments" src="https://ssfy.io/https%3A%2F%2Fwww.notion.so%2Fimage%2Fhttps%253A%252F%252Fs3-us-west-2.amazonaws.com%252Fsecure.notion-static.com%252Fa43f996c-de07-4d8a-8461-b35f9d43e4b2%252Fcomments-desktop-opt.jpg%3Ftable%3Dblock%26id%3Ded07d7c2-57c9-4aba-81b3-f5fa069371d4%26cache%3Dv2" width="420">
</p>
[Utteranc.es](https://utteranc.es/) is an amazing [open source project](https://github.com/utterance/utterances) which enables developers to embed GitHub issues as a comments section on their websites. Genius.
The integration is really simple. Just edit the `utterancesGitHubRepo` config value to point to the repo you'd like to use for issue comments.
You probably want to read through the Utterances docs before enabling this in production, since there are some subtleties around how issues get mapped to pages on your site, but overall the setup was super easy imho and I love the results.
### Preview Images
This is a really cool feature that's inspired by Medium's smooth image loading, where we first load a low quality, blurred version of an image and animate in the full quality version once it loads. It's such a nice effect, but it does add a bit of work to set up.
If `isPreviewImageSupportEnabled` is set to `true`, then the app will compute LQIP images via [lqip-modern](https://github.com/transitive-bullshit/lqip-modern) for all images referenced by your Notion workspace. These will be stored in a Google Firebase collection (as base64 JPEG data), so they only need to be computed once.
You'll have to set up your own Google Firebase instance of Firestore and supply three environment variables:
```bash
# base64-encoded string containing your google credentials json file
GOOGLE_APPLICATION_CREDENTIALS=
# name of your google cloud project
GCLOUD_PROJECT=
# name of the firebase collection to store images in
FIREBASE_COLLECTION_IMAGES=
```
The actual work happens in the [create-preview-image](./api/create-preview-image) serverless function.
### Automatic Social Images
<p align="center">
<img alt="Auto-generated social image" src="https://ssfy.io/https%3A%2F%2Fwww.notion.so%2Fimage%2Fhttps%253A%252F%252Fs3-us-west-2.amazonaws.com%252Fsecure.notion-static.com%252Fe1877c31-0bc9-46b7-8aaf-7bcae21baf2b%252Fsocial-image-opt.jpeg%3Ftable%3Dblock%26id%3D735b04d2-2a77-4035-8942-a17f8d41fe83%26cache%3Dv2" width="420">
</p>
Open Graph images like this one will be generated for each page of your site automatically based each page's content.
Note that you shouldn't have to do anything extra to enable this feature as long as you're deploying to Vercel.
### Automatic Table of Contents
<p align="center">
@@ -163,7 +117,7 @@ This table of contents uses the same logic that Notion uses for its built-in Tab
<p align="center">
<a href="https://transitivebullsh.it/free-resources-for-indie-saas-devs">
<img alt="Mobile Article Page" src="https://ssfy.io/https%3A%2F%2Fwww.notion.so%2Fimage%2Fhttps%253A%252F%252Fs3-us-west-2.amazonaws.com%252Fsecure.notion-static.com%252F6c05a0f9-59a0-4322-bef9-3f08fe4efc6a%252Farticle-mobile-opt.jpg%3Ftable%3Dblock%26id%3Da1eb2263-fdf1-4d51-a3d4-8a02cb32bbba%26cache%3Dv2" width="300">
<img alt="Mobile Article Page" src="https://transitive-bs.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F6c05a0f9-59a0-4322-bef9-3f08fe4efc6a%2Farticle-mobile-opt.jpg?table=block&id=a1eb2263-fdf1-4d51-a3d4-8a02cb32bbba&spaceId=fde5ac74-eea3-4527-8f00-4482710e1af3&width=2000&userId=&cache=v2" width="300">
</a>
</p>
@@ -171,7 +125,7 @@ This table of contents uses the same logic that Notion uses for its built-in Tab
<p align="center">
<a href="https://transitivebullsh.it">
<img alt="Desktop Home Page" src="https://ssfy.io/https%3A%2F%2Fwww.notion.so%2Fimage%2Fhttps%253A%252F%252Fs3-us-west-2.amazonaws.com%252Fsecure.notion-static.com%252F1d3ab4b2-60af-4b95-b35d-cac5d440b8ca%252Ftransitivebullsh.it_-opt.jpg%3Ftable%3Dblock%26id%3D97f445e8-2da1-41cd-996a-5ad0e73a1d79%26cache%3Dv2" width="600">
<img alt="Desktop Home Page" src="https://transitive-bs.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F1d3ab4b2-60af-4b95-b35d-cac5d440b8ca%2Ftransitivebullsh.it_-opt.jpg?table=block&id=97f445e8-2da1-41cd-996a-5ad0e73a1d79&spaceId=fde5ac74-eea3-4527-8f00-4482710e1af3&width=2000&userId=&cache=v2" width="600">
</a>
</p>
@@ -179,7 +133,7 @@ This table of contents uses the same logic that Notion uses for its built-in Tab
<p align="center">
<a href="https://transitivebullsh.it/free-resources-for-indie-saas-devs">
<img alt="Desktop Article Page" src="https://ssfy.io/https%3A%2F%2Fwww.notion.so%2Fimage%2Fhttps%253A%252F%252Fs3-us-west-2.amazonaws.com%252Fsecure.notion-static.com%252Fb564d13f-b71b-4473-8531-65b5dd9b995f%252Ftransitivebullsh.it__(4)-opt.jpg%3Ftable%3Dblock%26id%3D16e03de2-0df7-4232-a129-e1666505c4d2%26cache%3Dv2" width="600">
<img alt="Desktop Article Page" src="https://transitive-bs.notion.site/image/https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2Fb564d13f-b71b-4473-8531-65b5dd9b995f%2Ftransitivebullsh.it__(4)-opt.jpg?table=block&id=16e03de2-0df7-4232-a129-e1666505c4d2&spaceId=fde5ac74-eea3-4527-8f00-4482710e1af3&width=2000&userId=&cache=v2" width="600">
</a>
</p>

View File

@@ -27,18 +27,6 @@ module.exports = {
defaultPageCover: null,
defaultPageCoverPosition: 0.5,
// image CDN host to proxy all image requests through (optional)
// NOTE: this requires you to set up an external image proxy
imageCDNHost: null,
// Utteranc.es comments via GitHub issue comments (optional)
utterancesGitHubRepo: null,
// whether or not to enable support for LQIP preview images (optional)
// NOTE: this requires you to set up Google Firebase and add the environment
// variables specified in .env.example
isPreviewImageSupportEnabled: false,
// map of notion page IDs to URL paths (optional)
// any pages defined here will override their default URL paths
// example:

View File

@@ -19,10 +19,6 @@ body {
overflow-x: hidden;
}
.utterances {
max-width: 100% !important;
}
.static-tweet blockquote {
margin: 0;
margin-block-start: 0;

View File

@@ -15,7 +15,8 @@
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"typeRoots": ["./node_modules/@types"]
"typeRoots": ["./node_modules/@types"],
"incremental": true
},
"exclude": ["node_modules"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]

3012
yarn.lock

File diff suppressed because it is too large Load Diff