change next-notion-starter to railway-blog starter (#208)
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"presets": ["next/babel"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
**/node_modules/*
|
||||
**/out/*
|
||||
**/.next/*
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
// Uncomment the following lines to enable eslint-config-prettier
|
||||
// Is not enabled right now to avoid issues with the Next.js repo
|
||||
// "prettier",
|
||||
],
|
||||
"env": {
|
||||
"es6": true,
|
||||
"browser": true,
|
||||
"jest": true,
|
||||
"node": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": 0,
|
||||
"react/display-name": 0,
|
||||
"react/prop-types": 0,
|
||||
"@typescript-eslint/explicit-function-return-type": 0,
|
||||
"@typescript-eslint/explicit-member-accessibility": 0,
|
||||
"@typescript-eslint/indent": 0,
|
||||
"@typescript-eslint/member-delimiter-style": 0,
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/no-var-requires": 0,
|
||||
"@typescript-eslint/no-use-before-define": 0,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
2,
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"no-console": [
|
||||
2,
|
||||
{
|
||||
"allow": ["warn", "error"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
34
examples/next-notion-blog/.gitignore
vendored
@@ -1,34 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
public
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: NextJS Notion Blog
|
||||
description: A NextJS app using Notion as a CMS for a blog
|
||||
tags:
|
||||
- next
|
||||
- notion
|
||||
- tailwindcss
|
||||
- typescript
|
||||
---
|
||||
|
||||
# NextJS Notion blog example
|
||||
|
||||
This is an example [NextJS](https://nextjs.org/) app that uses [Notion](https://www.notion.so/) as a CMS for a blog.
|
||||
|
||||
[](https://railway.app/new?template=https%3A%2F%2Fgithub.com%2Frailwayapp%2Fexamples%2Ftree%2Fmaster%2Fexamples%2Fnext-notion-blog&envs=BLOG_INDEX_ID%2CNOTION_TOKEN)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- NextJS
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- Notion as a CMS
|
||||
|
||||
## 💁♀️ How to use
|
||||
|
||||
- When you deploy your application using the link above, we will ask you for the `NOTION_TOKEN` and the `BLOG_INDEX_ID`. This section will guide you on how to get those variables and deploy your blog to Railway.
|
||||
|
||||
- **Blog index ID**: If the URL of your page is https://www.notion.so/Blog-eb3df599cd9b4a8284c0f41bf5563966, then your BLOG_INDEX_ID would be eb3df599cd9b4a8284c0f41bf5563966. Basically, the part after your page title in the URL bar.
|
||||
|
||||

|
||||
|
||||
- **Notion token**: To get this, just look for the token_v2 cookie while on Notion.
|
||||
|
||||

|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Based on what your source for the images is, you will need to update the `images` key inside `next.config.js` otherwise your images will not render properly.
|
||||
@@ -1,7 +0,0 @@
|
||||
import React, { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
const Button: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => (
|
||||
<button className="btn-primary" {...props} />
|
||||
)
|
||||
|
||||
export default Button
|
||||
@@ -1,39 +0,0 @@
|
||||
import Prism from 'prismjs'
|
||||
import 'prismjs/components/prism-jsx'
|
||||
|
||||
const Code = ({ children, language = 'javascript' }) => {
|
||||
return (
|
||||
<>
|
||||
<pre>
|
||||
<code
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Prism.highlight(
|
||||
children,
|
||||
Prism.languages[language.toLowerCase()] ||
|
||||
Prism.languages.javascript,
|
||||
language.toLowerCase()
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
|
||||
<style jsx>{`
|
||||
pre {
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
code {
|
||||
overflow: auto;
|
||||
display: block;
|
||||
padding: 0.8rem;
|
||||
line-height: 1.5;
|
||||
background: #f5f5f5;
|
||||
font-size: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Code
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react'
|
||||
import { GitHub, Twitter } from 'react-feather'
|
||||
|
||||
import Link from '@components/Link'
|
||||
import Logo from '@components/Logo'
|
||||
|
||||
const Footer = () => (
|
||||
<footer className="py-4 bg-gray-900">
|
||||
<Link href="https://railway.app">
|
||||
<div className="max-w-4xl px-4 mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<Logo />
|
||||
<div className="pl-4 flex items-center text-sm text-gray-500 font-medium">
|
||||
<Link href="https://twitter.com/Railway_App" className="pr-4">
|
||||
<Twitter />
|
||||
</Link>
|
||||
|
||||
<Link href="https://github.com/railwayapp">
|
||||
<GitHub />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</footer>
|
||||
)
|
||||
|
||||
export default Footer
|
||||
@@ -1,29 +0,0 @@
|
||||
// TODO: fix types
|
||||
const collectText = (el: any, acc: any = []) => {
|
||||
if (el) {
|
||||
if (typeof el === 'string') acc.push(el)
|
||||
if (Array.isArray(el)) el.map((item) => collectText(item, acc))
|
||||
if (typeof el === 'object') collectText(el.props && el.props.children, acc)
|
||||
}
|
||||
return acc.join('').trim()
|
||||
}
|
||||
|
||||
const Heading = ({ children: component, id }: { children: any; id?: any }) => {
|
||||
const children = component.props.children || ''
|
||||
const text = children
|
||||
|
||||
if (null == id) {
|
||||
id = collectText(text)
|
||||
.toLowerCase()
|
||||
.replace(/\s/g, '-')
|
||||
.replace(/[?!:]/g, '')
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={`#${id}`} id={id} className="no-underline">
|
||||
{component}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default Heading
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import NLink from 'next/link'
|
||||
|
||||
export interface Props {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
external?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const isExternalLink = (href: string) =>
|
||||
href == null || href.startsWith('http://') || href.startsWith('https://')
|
||||
|
||||
const useIsExternalLink = (href: string) =>
|
||||
useMemo(() => isExternalLink(href), [href])
|
||||
|
||||
const Link = ({ href, external, children, ...props }: Props) => {
|
||||
const isExternal = (useIsExternalLink(href) || external) ?? false
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer noopener" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NLink href={href} passHref>
|
||||
<a {...props}>{children}</a>
|
||||
</NLink>
|
||||
)
|
||||
}
|
||||
|
||||
export default Link
|
||||
@@ -1,20 +0,0 @@
|
||||
const Logo = () => (
|
||||
<svg
|
||||
data-v-423bf9ae=""
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 59.99945071644884 60"
|
||||
className="logo w-10"
|
||||
>
|
||||
<g
|
||||
data-v-423bf9ae=""
|
||||
id="6f639fe5-02e1-4640-a1fb-f4b22b64e5ef"
|
||||
transform="matrix(0.5999641134746578,0,0,0.5999641134746578,-437.5538206497148,-266.68705118956507)"
|
||||
stroke="none"
|
||||
>
|
||||
<path d="M729.872 487.327a50.86 50.86 0 0 0-.464 5.033h75.879c-.265-.518-.621-.985-.98-1.442-12.972-16.769-19.95-15.315-29.932-15.741-3.328-.137-5.585-.192-18.832-.192-7.09 0-14.798.018-22.304.038.737-1.746 1.6-3.419 2.525-5.061-2.885 5.106-4.865 10.771-5.805 16.789l.891-4.397c.007-.031.018-.063.024-.094h38.883v5.067h-39.885zM805.885 497.432h-76.438c.08 1.352.206 2.686.388 4.002h70.571c3.146 0 4.907-1.786 5.479-4.002zM733.851 515.257a52.226 52.226 0 0 1-1.98-4.997c6.608 19.89 25.328 34.251 47.433 34.251 20.205 0 37.566-12.007 45.452-29.254h-90.905zM729.38 492.915c-.018.531-.08 1.055-.08 1.589 0 .538.063 1.059.08 1.59v-3.179zM824.77 515.229z"></path>
|
||||
<path d="M779.303 444.505c-18.682 0-34.939 10.265-43.524 25.439 6.709-.014 19.775-.022 19.775-.022h.003v-.005c15.444 0 16.018.069 19.035.195l1.868.069c6.507.217 14.505.916 20.798 5.68 3.416 2.584 8.348 8.287 11.288 12.35 2.718 3.758 3.5 8.078 1.652 12.217-1.701 3.804-5.361 6.073-9.793 6.073H730.85l-.884-4.201c.426 2.707 1.037 5.344 1.879 7.886h94.914a49.863 49.863 0 0 0 2.546-15.682c.001-27.611-22.386-49.999-50.002-49.999z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default Logo
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react'
|
||||
import Link from '@components/Link'
|
||||
import Logo from '@components/Logo'
|
||||
|
||||
const Nav = () => (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<nav className="py-4 px-4 flex justify-between items-center">
|
||||
<Link href="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
className="text-gray-500 hover:text-primary"
|
||||
href="https://railway.app"
|
||||
>
|
||||
Go to Railway
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default Nav
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import Image from 'next/image'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { Post } from '@lib/types'
|
||||
|
||||
import Link from '@components/Link'
|
||||
import { textBlock } from '@lib/notion/renderers'
|
||||
|
||||
export interface Props {
|
||||
post: Post
|
||||
}
|
||||
|
||||
const PostItem = ({ post }: Props) => {
|
||||
const formattedDate = useMemo(
|
||||
() => dayjs(new Date(post.Date)).format('MMM D, YYYY'),
|
||||
[post.Date]
|
||||
)
|
||||
|
||||
return (
|
||||
<Link href={`/p/${post.Slug}`} className="flex mb-16">
|
||||
<div className="post-item transform lg:hover:scale-105 transition-transform flex-1">
|
||||
<Image
|
||||
className="rounded hover:scale-50"
|
||||
src={post.Image}
|
||||
width={1440}
|
||||
height={720}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="post-info py-4 ml-20 flex flex-col justify-center flex-1">
|
||||
<header className="font-bold text-4xl">{post.Page}</header>
|
||||
<p className="text-gray-400 mt-3">
|
||||
{(!post.preview || post.preview.length === 0) &&
|
||||
'No preview available'}
|
||||
{(post.preview || []).map((block, idx) =>
|
||||
textBlock(block, true, `${post.Slug}${idx}`)
|
||||
)}
|
||||
</p>
|
||||
<div className="text-gray-600 mt-3">{formattedDate}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostItem
|
||||
@@ -1,59 +0,0 @@
|
||||
import { DefaultSeo, NextSeo, NextSeoProps } from 'next-seo'
|
||||
import Head from 'next/head'
|
||||
import { DefaultSeoProps } from 'next-seo'
|
||||
|
||||
export interface Props extends NextSeoProps {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
const title = 'Railway Blog'
|
||||
export const url = 'https://blog.railway.app'
|
||||
const description = 'Railway developer blog'
|
||||
const image = 'https://railway.app/og.png'
|
||||
|
||||
const config: DefaultSeoProps = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
url,
|
||||
site_name: title,
|
||||
images: [{ url: image }],
|
||||
},
|
||||
twitter: {
|
||||
handle: '@Railway_App',
|
||||
cardType: 'summary_large_image',
|
||||
},
|
||||
}
|
||||
|
||||
const SEO: React.FC<Props> = ({ image, ...props }) => {
|
||||
const title = props.title ?? config.title
|
||||
const description = props.description || config.description
|
||||
|
||||
return (
|
||||
<>
|
||||
<DefaultSeo {...config} />
|
||||
|
||||
<NextSeo
|
||||
{...props}
|
||||
{...(image == null
|
||||
? {}
|
||||
: {
|
||||
openGraph: {
|
||||
images: [{ url: image }],
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
|
||||
<meta name="description" content={description} />
|
||||
</Head>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SEO
|
||||
@@ -1,16 +0,0 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from '@components/Link'
|
||||
|
||||
const components = {
|
||||
// default tags
|
||||
ol: 'ol',
|
||||
ul: 'ul',
|
||||
li: 'li',
|
||||
p: 'p',
|
||||
blockquote: 'blockquote',
|
||||
a: Link,
|
||||
|
||||
Code: dynamic(() => import('@components/Code')),
|
||||
}
|
||||
|
||||
export default components
|
||||
@@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
roots: ['<rootDir>'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json', 'jsx'],
|
||||
testPathIgnorePatterns: ['<rootDir>[/\\\\](node_modules|.next)[/\\\\]'],
|
||||
transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$'],
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx)$': 'babel-jest',
|
||||
},
|
||||
watchPlugins: [
|
||||
'jest-watch-typeahead/filename',
|
||||
'jest-watch-typeahead/testname',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'\\.(css|less|sass|scss)$': 'identity-obj-proxy',
|
||||
'\\.(gif|ttf|eot|svg|png)$': '<rootDir>/test/__mocks__/fileMock.js',
|
||||
},
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react'
|
||||
import Footer from '@components/Footer'
|
||||
import Nav from '@components/Nav'
|
||||
import SEO, { Props as SEOProps } from '@components/Seo'
|
||||
import { GoogleFonts } from 'next-google-fonts'
|
||||
|
||||
export interface Props {
|
||||
seo?: SEOProps
|
||||
}
|
||||
|
||||
const Page: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<SEO {...props.seo} />
|
||||
<GoogleFonts href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" />
|
||||
|
||||
<Nav />
|
||||
|
||||
<div className="min-h-screen">{props.children}</div>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -1,52 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import React, { useMemo } from 'react'
|
||||
import { ArrowLeft } from 'react-feather'
|
||||
import { Post } from '@lib/types'
|
||||
|
||||
import Page from '@layouts/Page'
|
||||
import { url as baseUrl } from '@components/Seo'
|
||||
import Link from '@components/Link'
|
||||
|
||||
const getImageLinkFromImage = (path: string) => `${baseUrl}${path}`
|
||||
|
||||
export interface Props {
|
||||
post: Post
|
||||
}
|
||||
|
||||
export const PostPage: React.FC<Props> = ({ post, children }) => {
|
||||
const formattedDate = useMemo(() => dayjs(post.Date).format('MMM D, YYYY'), [
|
||||
post.Date,
|
||||
])
|
||||
|
||||
return (
|
||||
<Page
|
||||
seo={{
|
||||
title: post.Page,
|
||||
image: getImageLinkFromImage(post.Image),
|
||||
}}
|
||||
>
|
||||
<div className="wrapper">
|
||||
<div className="pb-20">
|
||||
<article>
|
||||
<header className="pt-20 pb-12">
|
||||
<Link href={`/p/${post.Slug}`}>
|
||||
<h1 className="text-6xl font-bold">{post.Page}</h1>
|
||||
</Link>
|
||||
|
||||
<div className="pt-8 text-gray-400">{formattedDate}</div>
|
||||
</header>
|
||||
|
||||
<section className="prose lg:prose-lg">{children}</section>
|
||||
</article>
|
||||
|
||||
<div className="pt-12">
|
||||
<Link href="/" className="flex text-gray-500 hover:text-primary">
|
||||
<ArrowLeft className="mr-4" />
|
||||
Back to posts
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Post } from '@lib/types'
|
||||
|
||||
export const getBlogLink = (slug: string) => {
|
||||
return `/p/${slug}`
|
||||
}
|
||||
|
||||
export const postIsPublished = (post: Post) => {
|
||||
return post.Published === 'Yes'
|
||||
}
|
||||
|
||||
export const normalizeSlug = (slug: string) => {
|
||||
if (typeof slug !== 'string') return slug
|
||||
|
||||
const startingSlash = slug.startsWith('/')
|
||||
const endingSlash = slug.endsWith('/')
|
||||
|
||||
if (startingSlash) {
|
||||
slug = slug.substr(1)
|
||||
}
|
||||
if (endingSlash) {
|
||||
slug = slug.substr(0, slug.length - 1)
|
||||
}
|
||||
return startingSlash || endingSlash ? normalizeSlug(slug) : slug
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { resolve } from 'path'
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import { writeFile } from '@lib/fs-helpers'
|
||||
import { textBlock } from '@lib/notion/renderers'
|
||||
import getBlogIndex from '@lib/notion/getBlogIndex'
|
||||
import getNotionUsers from '@lib/notion/getNotionUsers'
|
||||
import { postIsPublished, getBlogLink } from '@lib/blog-helpers'
|
||||
import serverConstants from '@lib/notion/server-constants'
|
||||
|
||||
// must use weird syntax to bypass auto replacing of NODE_ENV
|
||||
process.env['NODE' + '_ENV'] = 'production'
|
||||
process.env.USE_CACHE = 'true'
|
||||
|
||||
// constants
|
||||
const NOW = new Date().toJSON()
|
||||
|
||||
function mapToAuthor(author) {
|
||||
return `<author><name>${author.full_name}</name></author>`
|
||||
}
|
||||
|
||||
function decode(string) {
|
||||
return string
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function mapToEntry(post) {
|
||||
return `
|
||||
<entry>
|
||||
<id>${post.link}</id>
|
||||
<title>${decode(post.title)}</title>
|
||||
<link href="${post.link}"/>
|
||||
<updated>${new Date(post.date).toJSON()}</updated>
|
||||
<content type="xhtml">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">
|
||||
${renderToStaticMarkup(
|
||||
post.preview
|
||||
? (post.preview || []).map((block, idx) =>
|
||||
textBlock(block, false, post.title + idx)
|
||||
)
|
||||
: post.content
|
||||
)}
|
||||
<p class="more">
|
||||
<a href="${post.link}">Read more</a>
|
||||
</p>
|
||||
</div>
|
||||
</content>
|
||||
${(post.authors || []).map(mapToAuthor).join('\n ')}
|
||||
</entry>`
|
||||
}
|
||||
|
||||
function concat(total, item) {
|
||||
return total + item
|
||||
}
|
||||
|
||||
function createRSS(blogPosts = []) {
|
||||
const postsString = blogPosts.map(mapToEntry).reduce(concat, '')
|
||||
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>My Blog</title>
|
||||
<subtitle>Blog</subtitle>
|
||||
<link href="/atom" rel="self" type="application/rss+xml"/>
|
||||
<link href="/" />
|
||||
<updated>${NOW}</updated>
|
||||
<id>My Notion Blog</id>${postsString}
|
||||
</feed>`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await loadEnvConfig(process.cwd())
|
||||
serverConstants.NOTION_TOKEN = process.env.NOTION_TOKEN
|
||||
serverConstants.BLOG_INDEX_ID = serverConstants.normalizeId(
|
||||
process.env.BLOG_INDEX_ID
|
||||
)
|
||||
|
||||
const postsTable = await getBlogIndex(true)
|
||||
const neededAuthors = new Set<string>()
|
||||
|
||||
const blogPosts = Object.keys(postsTable)
|
||||
.map((slug) => {
|
||||
const post = postsTable[slug]
|
||||
if (!postIsPublished(post)) return
|
||||
|
||||
post.authors = post.Authors || []
|
||||
|
||||
for (const author of post.authors) {
|
||||
neededAuthors.add(author)
|
||||
}
|
||||
return post
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const { users } = await getNotionUsers([...neededAuthors])
|
||||
|
||||
blogPosts.forEach((post) => {
|
||||
post.authors = post.authors.map((id) => users[id])
|
||||
post.link = getBlogLink(post.Slug)
|
||||
post.title = post.Page
|
||||
post.date = post.Date
|
||||
})
|
||||
|
||||
const outputPath = './public/atom'
|
||||
await writeFile(resolve(outputPath), createRSS(blogPosts))
|
||||
}
|
||||
|
||||
main().catch((error) => console.error(error))
|
||||
@@ -1,5 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import { promisify } from 'util'
|
||||
|
||||
export const readFile = promisify(fs.readFile)
|
||||
export const writeFile = promisify(fs.writeFile)
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Sema } from 'async-sema'
|
||||
import rpc, { values } from '@lib/notion/rpc'
|
||||
import getTableData from '@lib/notion/getTableData'
|
||||
import { getPostPreview } from '@lib/notion/getPostPreview'
|
||||
import { readFile, writeFile } from '@lib/fs-helpers'
|
||||
import { BLOG_INDEX_ID, BLOG_INDEX_CACHE } from '@lib/notion/server-constants'
|
||||
|
||||
export default async function getBlogIndex(previews = true) {
|
||||
let postsTable: any = null
|
||||
const useCache = process.env.USE_CACHE === 'true'
|
||||
const cacheFile = `${BLOG_INDEX_CACHE}${previews ? '_previews' : ''}`
|
||||
|
||||
if (useCache) {
|
||||
try {
|
||||
postsTable = JSON.parse(await readFile(cacheFile, 'utf8'))
|
||||
} catch (_) {
|
||||
/* not fatal */
|
||||
}
|
||||
}
|
||||
|
||||
if (!postsTable) {
|
||||
try {
|
||||
const data = await rpc('loadPageChunk', {
|
||||
pageId: BLOG_INDEX_ID,
|
||||
limit: 100, // TODO: figure out Notion's way of handling pagination
|
||||
cursor: { stack: [] },
|
||||
chunkNumber: 0,
|
||||
verticalColumns: false,
|
||||
})
|
||||
|
||||
// Parse table with posts
|
||||
const tableBlock = values(data.recordMap.block).find(
|
||||
(block: any) => block.value.type === 'collection_view'
|
||||
)
|
||||
|
||||
postsTable = await getTableData(tableBlock, true)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to load Notion posts, have you run the create-table script?`
|
||||
)
|
||||
return {}
|
||||
}
|
||||
|
||||
// only get 10 most recent post's previews
|
||||
const postsKeys = Object.keys(postsTable).splice(0, 10)
|
||||
|
||||
const sema = new Sema(3, { capacity: postsKeys.length })
|
||||
|
||||
if (previews) {
|
||||
await Promise.all(
|
||||
postsKeys
|
||||
.sort((a, b) => {
|
||||
const postA = postsTable[a]
|
||||
const postB = postsTable[b]
|
||||
const timeA = postA.Date
|
||||
const timeB = postB.Date
|
||||
return Math.sign(timeB - timeA)
|
||||
})
|
||||
.map(async (postKey) => {
|
||||
await sema.acquire()
|
||||
const post = postsTable[postKey]
|
||||
post.preview = post.id
|
||||
? await getPostPreview(postsTable[postKey].id)
|
||||
: []
|
||||
sema.release()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (useCache) {
|
||||
writeFile(cacheFile, JSON.stringify(postsTable), 'utf8').catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return postsTable
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextApiResponse } from 'next'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { getError } from '@lib/notion/rpc'
|
||||
import { NOTION_TOKEN, API_ENDPOINT } from '@lib/notion/server-constants'
|
||||
|
||||
export default async function getNotionAsset(
|
||||
res: NextApiResponse,
|
||||
assetUrl: string,
|
||||
blockId: string
|
||||
): Promise<{
|
||||
signedUrls: string[]
|
||||
}> {
|
||||
const requestURL = `${API_ENDPOINT}/getSignedFileUrls`
|
||||
const assetRes = await fetch(requestURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
cookie: `token_v2=${NOTION_TOKEN}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
urls: [
|
||||
{
|
||||
url: assetUrl,
|
||||
permissionRecord: {
|
||||
table: 'block',
|
||||
id: blockId,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (assetRes.ok) {
|
||||
return assetRes.json()
|
||||
} else {
|
||||
console.error('bad request', assetRes.status)
|
||||
res.json({ status: 'error', message: 'failed to load Notion asset' })
|
||||
throw new Error(await getError(assetRes))
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import rpc from '@lib/notion/rpc'
|
||||
|
||||
export default async function getNotionUsers(ids: string[]) {
|
||||
const { results = [] } = await rpc('getRecordValues', {
|
||||
requests: ids.map((id: string) => ({
|
||||
id,
|
||||
table: 'notion_user',
|
||||
})),
|
||||
})
|
||||
|
||||
const users: any = {}
|
||||
|
||||
for (const result of results) {
|
||||
const { value } = result || { value: {} }
|
||||
const { given_name, family_name } = value
|
||||
let full_name = given_name || ''
|
||||
|
||||
if (family_name) {
|
||||
full_name = `${full_name} ${family_name}`
|
||||
}
|
||||
users[value.id] = { full_name }
|
||||
}
|
||||
|
||||
return { users }
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import rpc, { values } from '@lib/notion/rpc'
|
||||
|
||||
export default async function getPageData(pageId: string) {
|
||||
// a reasonable size limit for the largest blog post (1MB),
|
||||
// as one chunk is about 10KB
|
||||
const maximumChunckNumer = 100
|
||||
|
||||
try {
|
||||
let chunkNumber = 0
|
||||
let data = await loadPageChunk({ pageId, chunkNumber })
|
||||
let blocks = data.recordMap.block
|
||||
|
||||
while (data.cursor.stack.length !== 0 && chunkNumber < maximumChunckNumer) {
|
||||
chunkNumber = chunkNumber + 1
|
||||
data = await loadPageChunk({ pageId, chunkNumber, cursor: data.cursor })
|
||||
blocks = Object.assign(blocks, data.recordMap.block)
|
||||
}
|
||||
const blockArray = values(blocks)
|
||||
if (blockArray[0] && blockArray[0].value.content) {
|
||||
// remove table blocks
|
||||
blockArray.splice(0, 3)
|
||||
}
|
||||
return { blocks: blockArray }
|
||||
} catch (err) {
|
||||
console.error(`Failed to load pageData for ${pageId}`, err)
|
||||
return { blocks: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPageChunk({
|
||||
pageId,
|
||||
limit = 30,
|
||||
cursor = { stack: [] },
|
||||
chunkNumber = 0,
|
||||
verticalColumns = false,
|
||||
}: any) {
|
||||
return rpc('loadPageChunk', {
|
||||
pageId,
|
||||
limit,
|
||||
cursor,
|
||||
chunkNumber,
|
||||
verticalColumns,
|
||||
})
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { loadPageChunk } from '@lib/notion/getPageData'
|
||||
import { values } from '@lib/notion/rpc'
|
||||
|
||||
const nonPreviewTypes = new Set(['editor', 'page', 'collection_view'])
|
||||
|
||||
export async function getPostPreview(pageId: string) {
|
||||
let blocks
|
||||
let dividerIndex = 0
|
||||
|
||||
const data = await loadPageChunk({ pageId, limit: 10 })
|
||||
blocks = values(data.recordMap.block)
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (blocks[i].value.type === 'divider') {
|
||||
dividerIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
blocks = blocks
|
||||
.splice(0, dividerIndex)
|
||||
.filter(
|
||||
({ value: { type, properties } }: any) =>
|
||||
!nonPreviewTypes.has(type) && properties
|
||||
)
|
||||
.map((block: any) => block.value.properties.title)
|
||||
|
||||
return blocks
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { values } from '@lib/notion/rpc'
|
||||
import Slugger from 'github-slugger'
|
||||
import queryCollection from '@lib/notion/queryCollection'
|
||||
import { normalizeSlug } from '@lib/blog-helpers'
|
||||
|
||||
export default async function loadTable(collectionBlock: any, isPosts = false) {
|
||||
const slugger = new Slugger()
|
||||
|
||||
const { value } = collectionBlock
|
||||
let table: any = {}
|
||||
const col = await queryCollection({
|
||||
collectionId: value.collection_id,
|
||||
collectionViewId: value.view_ids[0],
|
||||
})
|
||||
const entries = values(col.recordMap.block).filter((block: any) => {
|
||||
return block.value && block.value.parent_id === value.collection_id
|
||||
})
|
||||
|
||||
const colId = Object.keys(col.recordMap.collection)[0]
|
||||
const schema = col.recordMap.collection[colId].value.schema
|
||||
const schemaKeys = Object.keys(schema)
|
||||
|
||||
for (const entry of entries) {
|
||||
const props = entry.value && entry.value.properties
|
||||
const row: any = {}
|
||||
|
||||
if (!props) continue
|
||||
if (entry.value.content) {
|
||||
row.id = entry.value.id
|
||||
}
|
||||
|
||||
schemaKeys.forEach((key) => {
|
||||
// might be undefined
|
||||
let val = props[key] && props[key][0][0]
|
||||
|
||||
// authors and blocks are centralized
|
||||
if (val && props[key][0][1]) {
|
||||
const type = props[key][0][1][0]
|
||||
|
||||
switch (type[0]) {
|
||||
case 'a': {
|
||||
// link
|
||||
val = type[1]
|
||||
break
|
||||
}
|
||||
case 'u': {
|
||||
// user
|
||||
val = props[key]
|
||||
.filter((arr: any[]) => arr.length > 1)
|
||||
.map((arr: any[]) => arr[1][0][1])
|
||||
break
|
||||
}
|
||||
case 'p': {
|
||||
// page (block)
|
||||
const page = col.recordMap.block[type[1]]
|
||||
row.id = page.value.id
|
||||
val = page.value.properties.title[0][0]
|
||||
break
|
||||
}
|
||||
case 'd': {
|
||||
// date
|
||||
// start_date: 2019-06-18
|
||||
// start_time: 07:00
|
||||
// time_zone: Europe/Berlin, America/Los_Angeles
|
||||
|
||||
if (!type[1].start_date) {
|
||||
break
|
||||
}
|
||||
// initial with provided date
|
||||
const providedDate = new Date(
|
||||
type[1].start_date + ' ' + (type[1].start_time || '')
|
||||
).getTime()
|
||||
|
||||
// calculate offset from provided time zone
|
||||
const timezoneOffset =
|
||||
new Date(
|
||||
new Date().toLocaleString('en-US', {
|
||||
timeZone: type[1].time_zone,
|
||||
})
|
||||
).getTime() - new Date().getTime()
|
||||
|
||||
// initialize subtracting time zone offset
|
||||
val = new Date(providedDate - timezoneOffset).getTime()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.error('unknown type', type[0], type)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof val === 'string') {
|
||||
val = val.trim()
|
||||
}
|
||||
row[schema[key].name] = val || null
|
||||
})
|
||||
|
||||
// auto-generate slug from title
|
||||
row.Slug = normalizeSlug(row.Slug || slugger.slug(row.Page || ''))
|
||||
|
||||
const key = row.Slug
|
||||
if (isPosts && !key) continue
|
||||
|
||||
if (key) {
|
||||
table[key] = row
|
||||
} else {
|
||||
if (!Array.isArray(table)) table = []
|
||||
table.push(row)
|
||||
}
|
||||
}
|
||||
return table
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import rpc from './rpc'
|
||||
|
||||
export default function queryCollection({
|
||||
collectionId,
|
||||
collectionViewId,
|
||||
loader = {},
|
||||
query = {},
|
||||
}: any) {
|
||||
const {
|
||||
limit = 999, // TODO: figure out Notion's way of handling pagination
|
||||
loadContentCover = true,
|
||||
type = 'table',
|
||||
userLocale = 'en',
|
||||
userTimeZone = 'America/Phoenix',
|
||||
} = loader
|
||||
|
||||
const {
|
||||
aggregate = [
|
||||
{
|
||||
aggregation_type: 'count',
|
||||
id: 'count',
|
||||
property: 'title',
|
||||
type: 'title',
|
||||
view_type: 'table',
|
||||
},
|
||||
],
|
||||
filter = [],
|
||||
filter_operator = 'and',
|
||||
sort = [],
|
||||
} = query
|
||||
|
||||
return rpc('queryCollection', {
|
||||
collectionId,
|
||||
collectionViewId,
|
||||
loader: {
|
||||
limit,
|
||||
loadContentCover,
|
||||
type,
|
||||
userLocale,
|
||||
userTimeZone,
|
||||
},
|
||||
query: {
|
||||
aggregate,
|
||||
filter,
|
||||
filter_operator,
|
||||
sort,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react'
|
||||
import components from '@components/dynamic'
|
||||
|
||||
function applyTags(tags = [], children: any, noPTag = false, key) {
|
||||
let child = children
|
||||
|
||||
for (const tag of tags) {
|
||||
const props: { [key: string]: any } = { key }
|
||||
let tagName = tag[0]
|
||||
|
||||
if (noPTag && tagName === 'p') tagName = React.Fragment
|
||||
if (tagName === 'c') tagName = 'code'
|
||||
if (tagName === '_') {
|
||||
tagName = 'span'
|
||||
props.className = 'underline'
|
||||
}
|
||||
if (tagName === 'a') {
|
||||
props.href = tag[1]
|
||||
}
|
||||
|
||||
child = React.createElement(components[tagName] || tagName, props, child)
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
export function textBlock(text = [], noPTag = false, mainKey) {
|
||||
const children = []
|
||||
let key = 0
|
||||
|
||||
for (const textItem of text) {
|
||||
key++
|
||||
if (textItem.length === 1) {
|
||||
children.push(textItem)
|
||||
continue
|
||||
}
|
||||
children.push(applyTags(textItem[1], textItem[0], noPTag, key))
|
||||
}
|
||||
return React.createElement(
|
||||
noPTag ? React.Fragment : components.p,
|
||||
{ key: mainKey },
|
||||
...children,
|
||||
noPTag
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import fetch, { Response } from 'node-fetch'
|
||||
import { API_ENDPOINT, NOTION_TOKEN } from './server-constants'
|
||||
|
||||
export default async function rpc(fnName: string, body: any) {
|
||||
if (!NOTION_TOKEN) {
|
||||
throw new Error('NOTION_TOKEN is not set in env')
|
||||
}
|
||||
const res = await fetch(`${API_ENDPOINT}/${fnName}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
cookie: `token_v2=${NOTION_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
return res.json()
|
||||
} else {
|
||||
throw new Error(await getError(res))
|
||||
}
|
||||
}
|
||||
|
||||
export async function getError(res: Response) {
|
||||
return `Notion API error (${res.status}) \n${getJSONHeaders(
|
||||
res
|
||||
)}\n ${await getBodyOrNull(res)}`
|
||||
}
|
||||
|
||||
export function getJSONHeaders(res: Response) {
|
||||
return JSON.stringify(res.headers.raw())
|
||||
}
|
||||
|
||||
export function getBodyOrNull(res: Response) {
|
||||
try {
|
||||
return res.text()
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function values(obj: any) {
|
||||
const vals: any = []
|
||||
|
||||
Object.keys(obj).forEach((key) => {
|
||||
vals.push(obj[key])
|
||||
})
|
||||
return vals
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// use commonjs so it can be required without transpiling
|
||||
const path = require('path')
|
||||
|
||||
const normalizeId = (id) => {
|
||||
if (!id) return id
|
||||
if (id.length === 36) return id
|
||||
if (id.length !== 32) {
|
||||
throw new Error(
|
||||
`Invalid blog-index-id: ${id} should be 32 characters long. Info here https://github.com/ijjk/notion-blog#getting-blog-index-and-token`
|
||||
)
|
||||
}
|
||||
return `${id.substr(0, 8)}-${id.substr(8, 4)}-${id.substr(12, 4)}-${id.substr(
|
||||
16,
|
||||
4
|
||||
)}-${id.substr(20)}`
|
||||
}
|
||||
|
||||
const NOTION_TOKEN = process.env.NOTION_TOKEN
|
||||
const BLOG_INDEX_ID = normalizeId(process.env.BLOG_INDEX_ID)
|
||||
const API_ENDPOINT = 'https://www.notion.so/api/v3'
|
||||
const BLOG_INDEX_CACHE = path.resolve('.blog_index_data')
|
||||
|
||||
module.exports = {
|
||||
NOTION_TOKEN,
|
||||
BLOG_INDEX_ID,
|
||||
API_ENDPOINT,
|
||||
BLOG_INDEX_CACHE,
|
||||
normalizeId,
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export function setHeaders(req: NextApiRequest, res: NextApiResponse): boolean {
|
||||
// set SPR/CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Cache-Control', 's-maxage=1, stale-while-revalidate')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'pragma')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200)
|
||||
res.end()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function handleData(res: NextApiResponse, data: any) {
|
||||
data = data || { status: 'error', message: 'unhandled request' }
|
||||
res.status(data.status !== 'error' ? 200 : 500)
|
||||
res.json(data)
|
||||
}
|
||||
|
||||
export function handleError(res: NextApiResponse, error: string | Error) {
|
||||
console.error(error)
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
message: 'an error occurred processing request',
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface Post {
|
||||
id: string
|
||||
Page: string
|
||||
Slug: string
|
||||
Image: string
|
||||
Date: number
|
||||
Authors: string[]
|
||||
Published: 'Yes' | 'No'
|
||||
preview?: any[]
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
role: string
|
||||
value: {
|
||||
id: string
|
||||
version: number
|
||||
type: string
|
||||
created_time: number
|
||||
last_edited_time: number
|
||||
properties: any
|
||||
parent_id: string
|
||||
file_ids?: string
|
||||
format?: any
|
||||
}
|
||||
}
|
||||
2
examples/next-notion-blog/next-env.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
@@ -1,63 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { NOTION_TOKEN, BLOG_INDEX_ID } = require('./lib/notion/server-constants')
|
||||
|
||||
try {
|
||||
fs.unlinkSync(path.resolve('.blog_index_data'))
|
||||
} catch (_) {
|
||||
/* non fatal */
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(path.resolve('.blog_index_data_previews'))
|
||||
} catch (_) {
|
||||
/* non fatal */
|
||||
}
|
||||
|
||||
const warnOrError =
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? console.warn
|
||||
: (msg) => {
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
if (!NOTION_TOKEN) {
|
||||
// We aren't able to build or serve images from Notion without the
|
||||
// NOTION_TOKEN being populated
|
||||
warnOrError(
|
||||
`\nNOTION_TOKEN is missing from env, this will result in an error\n` +
|
||||
`Make sure to provide one before starting Next.js`
|
||||
)
|
||||
}
|
||||
|
||||
if (!BLOG_INDEX_ID) {
|
||||
// We aren't able to build or serve images from Notion without the
|
||||
// NOTION_TOKEN being populated
|
||||
warnOrError(
|
||||
`\nBLOG_INDEX_ID is missing from env, this will result in an error\n` +
|
||||
`Make sure to provide one before starting Next.js`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
future: {
|
||||
webpack5: true,
|
||||
},
|
||||
images: {
|
||||
domains: ['user-images.githubusercontent.com'],
|
||||
},
|
||||
webpack(cfg, { dev, isServer }) {
|
||||
// only compile build-rss in production server build
|
||||
if (dev || !isServer) return cfg
|
||||
|
||||
// we're in build mode so enable shared caching for Notion data
|
||||
process.env.USE_CACHE = 'true'
|
||||
|
||||
const originalEntry = cfg.entry
|
||||
cfg.entry = async () => {
|
||||
const entries = { ...(await originalEntry()) }
|
||||
entries['build-rss.js'] = './lib/build-rss.ts'
|
||||
return entries
|
||||
}
|
||||
return cfg
|
||||
},
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"name": "with-typescript-eslint-jest",
|
||||
"author": "@erikdstock",
|
||||
"license": "MIT",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start --port ${PORT-3000}",
|
||||
"type-check": "tsc --pretty --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext ts --ext tsx --ext js",
|
||||
"test": "jest",
|
||||
"test-all": "yarn lint && yarn type-check && yarn test"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged",
|
||||
"pre-push": "yarn run type-check"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.@(ts|tsx)": [
|
||||
"yarn lint",
|
||||
"yarn format"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.4.0",
|
||||
"async-sema": "^3.1.0",
|
||||
"dayjs": "^1.10.4",
|
||||
"github-slugger": "^1.3.0",
|
||||
"next": "10.x",
|
||||
"next-google-fonts": "^2.2.0",
|
||||
"next-seo": "^4.24.0",
|
||||
"prismjs": "^1.23.0",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-feather": "^2.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@types/github-slugger": "^1.3.0",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/node": "^14.14.25",
|
||||
"@types/prismjs": "^1.16.5",
|
||||
"@types/react": "^17.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.14.2",
|
||||
"@typescript-eslint/parser": "^4.14.2",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"babel-jest": "^26.6.3",
|
||||
"eslint": "^7.19.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint-plugin-react": "^7.19.0",
|
||||
"husky": "^4.2.3",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"jest-watch-typeahead": "^0.6.1",
|
||||
"lint-staged": "^10.0.10",
|
||||
"postcss": "^8.2.13",
|
||||
"prettier": "^2.0.2",
|
||||
"tailwindcss": "^2.1.2",
|
||||
"typescript": "^4.1.3"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import '@styles/globals.css'
|
||||
|
||||
import type { AppProps } from 'next/app'
|
||||
|
||||
const RailwayBlog = ({ Component, pageProps }: AppProps) => (
|
||||
<Component {...pageProps} />
|
||||
)
|
||||
|
||||
export default RailwayBlog
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import getNotionAssetUrls from '@lib/notion/getNotionAssetUrls'
|
||||
import { setHeaders, handleData, handleError } from '@lib/notion/utils'
|
||||
|
||||
export default async function notionApi(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (setHeaders(req, res)) return
|
||||
try {
|
||||
const { assetUrl, blockId } = req.query as { [k: string]: string }
|
||||
|
||||
if (!assetUrl || !blockId) {
|
||||
handleData(res, {
|
||||
status: 'error',
|
||||
message: 'asset url or blockId missing',
|
||||
})
|
||||
} else {
|
||||
// we need to re-encode it since it's decoded when added to req.query
|
||||
const { signedUrls = [], ...urlsResponse } = await getNotionAssetUrls(
|
||||
res,
|
||||
assetUrl,
|
||||
blockId
|
||||
)
|
||||
|
||||
if (signedUrls.length === 0) {
|
||||
console.error('Failed to get signedUrls', urlsResponse)
|
||||
return handleData(res, {
|
||||
status: 'error',
|
||||
message: 'Failed to get asset URL',
|
||||
})
|
||||
}
|
||||
|
||||
res.status(307)
|
||||
res.setHeader('Location', signedUrls.pop())
|
||||
res.end()
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(res, error)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { GetStaticProps, NextPage } from 'next'
|
||||
|
||||
import getBlogIndex from '@lib/notion/getBlogIndex'
|
||||
import getNotionUsers from '@lib/notion/getNotionUsers'
|
||||
import { postIsPublished } from '@lib/blog-helpers'
|
||||
import { Post } from '@lib/types'
|
||||
|
||||
import Page from '@layouts/Page'
|
||||
import PostItem from '@components/PostItem'
|
||||
|
||||
export interface Props {
|
||||
posts: Post[]
|
||||
preview?: boolean
|
||||
}
|
||||
const Home: NextPage<Props> = ({ posts = [] }) => {
|
||||
return (
|
||||
<Page>
|
||||
<div className="max-w-4xl px-4 mx-auto">
|
||||
<header className="py-20">
|
||||
<h1 className="text-6xl font-bold text-center">Railway Blog</h1>
|
||||
</header>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center text-gray-500">Pretty empty here</div>
|
||||
) : (
|
||||
<div className="posts max-w-4xl">
|
||||
{posts.map((p) => (
|
||||
<PostItem key={p.id} post={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({ preview }) => {
|
||||
const postsTable = await getBlogIndex()
|
||||
|
||||
const authorsToGet: Set<string> = new Set()
|
||||
|
||||
const posts: any[] = Object.keys(postsTable)
|
||||
.map((slug) => {
|
||||
const post = postsTable[slug]
|
||||
// remove draft posts in production
|
||||
if (!preview && !postIsPublished(post)) {
|
||||
return null
|
||||
}
|
||||
post.Authors = post.Authors || []
|
||||
for (const author of post.Authors) {
|
||||
authorsToGet.add(author)
|
||||
}
|
||||
return post
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const { users } = await getNotionUsers([...authorsToGet])
|
||||
|
||||
posts.map((post) => {
|
||||
post.Authors = post.Authors.map((id) => users[id].full_name)
|
||||
})
|
||||
return {
|
||||
props: {
|
||||
preview: preview || false,
|
||||
posts,
|
||||
},
|
||||
revalidate: 10,
|
||||
}
|
||||
}
|
||||
|
||||
export default Home
|
||||
@@ -1,381 +0,0 @@
|
||||
import { GetStaticPaths, GetStaticProps, NextPage } from 'next'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { CSSProperties, useEffect } from 'react'
|
||||
import components from '@components/dynamic'
|
||||
import Heading from '@components/Heading'
|
||||
import Link from '@components/Link'
|
||||
import Page from '@layouts/Page'
|
||||
import { PostPage } from '@layouts/PostPage'
|
||||
import { getBlogLink } from '@lib/blog-helpers'
|
||||
import getBlogIndex from '@lib/notion/getBlogIndex'
|
||||
import getNotionUsers from '@lib/notion/getNotionUsers'
|
||||
import getPageData from '@lib/notion/getPageData'
|
||||
import { textBlock } from '@lib/notion/renderers'
|
||||
import { Block, Post } from '@lib/types'
|
||||
|
||||
export interface DetailedPost extends Post {
|
||||
content: Block[]
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
post: DetailedPost | null
|
||||
redirect?: string
|
||||
preview: boolean
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const postsTable = await getBlogIndex()
|
||||
|
||||
const paths = Object.keys(postsTable)
|
||||
.filter((post) => postsTable[post].Published === 'Yes')
|
||||
.map((slug) => getBlogLink(slug))
|
||||
|
||||
// we fallback for any unpublished posts to save build time
|
||||
// for actually published ones
|
||||
return {
|
||||
paths,
|
||||
fallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps<Props> = async ({
|
||||
params,
|
||||
preview,
|
||||
}) => {
|
||||
const slug = (params ?? {}).slug as string
|
||||
|
||||
// load the postsTable so that we can get the page's ID
|
||||
const postsTable = await getBlogIndex()
|
||||
const post: DetailedPost = postsTable[slug]
|
||||
|
||||
// if we can't find the post or if it is unpublished and
|
||||
// viewed without preview mode then we just redirect to /blog
|
||||
if (!post) {
|
||||
return {
|
||||
props: {
|
||||
post: null,
|
||||
redirect: '/',
|
||||
preview: false,
|
||||
},
|
||||
revalidate: 5,
|
||||
}
|
||||
}
|
||||
const postData = await getPageData(post.id)
|
||||
post.content = postData.blocks
|
||||
|
||||
const { users } = await getNotionUsers(post.Authors || [])
|
||||
post.Authors = Object.keys(users).map((id) => users[id].full_name)
|
||||
|
||||
return {
|
||||
props: {
|
||||
post,
|
||||
preview: preview ?? false,
|
||||
},
|
||||
revalidate: 10,
|
||||
}
|
||||
}
|
||||
|
||||
const listTypes = new Set(['bulleted_list', 'numbered_list'])
|
||||
|
||||
const RenderPost: React.FC<Props> = ({ post, preview }) => {
|
||||
const router = useRouter()
|
||||
|
||||
let listTagName: string | null = null
|
||||
let listLastId: string | null = null
|
||||
let listMap: {
|
||||
[id: string]: {
|
||||
key: string
|
||||
isNested?: boolean
|
||||
nested: string[]
|
||||
children: React.ReactFragment
|
||||
}
|
||||
} = {}
|
||||
|
||||
// If the page is not yet generated, this will be displayed
|
||||
// initially until getStaticProps() finishes running
|
||||
if (router.isFallback) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
// if you don't have a post at this point, and are not
|
||||
// loading one from fallback then redirect back to the index
|
||||
if (!post) {
|
||||
return (
|
||||
<div className={''}>
|
||||
<p>
|
||||
Woops! did not find that post, redirecting you back to the blog index
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{preview && (
|
||||
<div>
|
||||
<div>
|
||||
<b>Note:</b>
|
||||
{` `}Viewing in preview mode{' '}
|
||||
<Link href={`/api/clear-preview?slug=${post.Slug}`}>
|
||||
<button>Exit Preview</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!post.content || post.content.length === 0) && (
|
||||
<p>This post has no content</p>
|
||||
)}
|
||||
|
||||
{(post.content || []).map((block, blockIdx) => {
|
||||
const { value } = block
|
||||
const { type, properties, id, parent_id } = value
|
||||
const isLast = blockIdx === post.content.length - 1
|
||||
const isList = listTypes.has(type)
|
||||
const toRender: Array<React.ReactElement | null> = []
|
||||
|
||||
if (isList) {
|
||||
listTagName = components[type === 'bulleted_list' ? 'ul' : 'ol']
|
||||
listLastId = `list${id}`
|
||||
|
||||
listMap[id] = {
|
||||
key: id,
|
||||
nested: [],
|
||||
children: textBlock(properties.title, true, id),
|
||||
}
|
||||
|
||||
if (listMap[parent_id]) {
|
||||
listMap[id].isNested = true
|
||||
listMap[parent_id].nested.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (listTagName && (isLast || !isList)) {
|
||||
toRender.push(
|
||||
React.createElement(
|
||||
listTagName,
|
||||
{ key: listLastId! },
|
||||
Object.keys(listMap).map((itemId) => {
|
||||
if (listMap[itemId].isNested) return null
|
||||
|
||||
const createEl = (item) =>
|
||||
React.createElement(
|
||||
components.li || 'ul',
|
||||
{ key: item.key },
|
||||
item.children,
|
||||
item.nested.length > 0
|
||||
? React.createElement(
|
||||
components.ul || 'ul',
|
||||
{ key: item + 'sub-list' },
|
||||
item.nested.map((nestedId) =>
|
||||
createEl(listMap[nestedId])
|
||||
)
|
||||
)
|
||||
: null
|
||||
)
|
||||
return createEl(listMap[itemId])
|
||||
})
|
||||
)
|
||||
)
|
||||
listMap = {}
|
||||
listLastId = null
|
||||
listTagName = null
|
||||
}
|
||||
|
||||
const renderHeading = (Type: string | React.ComponentType) => {
|
||||
toRender.push(
|
||||
<Heading key={id}>
|
||||
<Type key={id}>{textBlock(properties.title, true, id)}</Type>
|
||||
</Heading>
|
||||
)
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'page':
|
||||
case 'divider':
|
||||
break
|
||||
case 'text':
|
||||
if (properties) {
|
||||
toRender.push(textBlock(properties.title, false, id))
|
||||
}
|
||||
break
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'embed': {
|
||||
const { format = {} } = value
|
||||
const {
|
||||
block_width,
|
||||
block_height,
|
||||
display_source,
|
||||
block_aspect_ratio,
|
||||
} = format
|
||||
const baseBlockWidth = 768
|
||||
const roundFactor = Math.pow(10, 2)
|
||||
// calculate percentages
|
||||
const width = block_width
|
||||
? `${
|
||||
Math.round(
|
||||
(block_width / baseBlockWidth) * 100 * roundFactor
|
||||
) / roundFactor
|
||||
}%`
|
||||
: block_height || '100%'
|
||||
|
||||
const isImage = type === 'image'
|
||||
const Comp = isImage ? 'img' : 'video'
|
||||
const useWrapper = block_aspect_ratio && !block_height
|
||||
const childStyle: CSSProperties = useWrapper
|
||||
? {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
}
|
||||
: {
|
||||
width,
|
||||
border: 'none',
|
||||
height: block_height,
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
}
|
||||
|
||||
let child: React.ReactElement | null = null
|
||||
|
||||
if (!isImage && !value.file_ids) {
|
||||
// external resource use iframe
|
||||
child = (
|
||||
<iframe
|
||||
style={childStyle}
|
||||
src={display_source}
|
||||
key={!useWrapper ? id : undefined}
|
||||
className={!useWrapper ? 'asset-wrapper' : undefined}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
// notion resource
|
||||
child = (
|
||||
<Comp
|
||||
key={!useWrapper ? id : undefined}
|
||||
src={`/api/asset?assetUrl=${encodeURIComponent(
|
||||
display_source as any
|
||||
)}&blockId=${id}`}
|
||||
controls={!isImage}
|
||||
alt={`An ${isImage ? 'image' : 'video'} from Notion`}
|
||||
loop={!isImage}
|
||||
muted={!isImage}
|
||||
autoPlay={!isImage}
|
||||
style={childStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
toRender.push(
|
||||
useWrapper ? (
|
||||
<div
|
||||
style={{
|
||||
paddingTop: `${Math.round(block_aspect_ratio * 100)}%`,
|
||||
position: 'relative',
|
||||
}}
|
||||
className="asset-wrapper"
|
||||
key={id}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
) : (
|
||||
child
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'header':
|
||||
renderHeading('h1')
|
||||
break
|
||||
case 'sub_header':
|
||||
renderHeading('h2')
|
||||
break
|
||||
case 'sub_sub_header':
|
||||
renderHeading('h3')
|
||||
break
|
||||
case 'code': {
|
||||
if (properties.title) {
|
||||
const content = properties.title[0][0]
|
||||
const language = properties.language[0][0]
|
||||
|
||||
toRender.push(
|
||||
<components.Code key={id} language={language || ''}>
|
||||
{content}
|
||||
</components.Code>
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'quote': {
|
||||
if (properties.title) {
|
||||
toRender.push(
|
||||
React.createElement(
|
||||
components.blockquote,
|
||||
{ key: id },
|
||||
properties.title
|
||||
)
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'callout': {
|
||||
toRender.push(
|
||||
<div className="callout" key={id}>
|
||||
{value.format?.page_icon && (
|
||||
<div>{value.format?.page_icon}</div>
|
||||
)}
|
||||
<div className="text">
|
||||
{textBlock(properties.title, true, id)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'tweet': {
|
||||
if (properties.html) {
|
||||
toRender.push(
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: properties.html }}
|
||||
key={id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
if (process.env.NODE_ENV !== 'production' && !listTypes.has(type)) {
|
||||
console.warn('unknown type', type)
|
||||
}
|
||||
break
|
||||
}
|
||||
return toRender
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SlugPage: NextPage<Props> = (props) => {
|
||||
const router = useRouter()
|
||||
const { redirect, post } = props
|
||||
|
||||
useEffect(() => {
|
||||
if (redirect && !post) {
|
||||
router.replace(redirect)
|
||||
}
|
||||
}, [redirect, post])
|
||||
|
||||
if (post == null) {
|
||||
return <Page />
|
||||
}
|
||||
|
||||
return (
|
||||
<PostPage post={post}>
|
||||
<RenderPost {...props} />
|
||||
</PostPage>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlugPage
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 731 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 30 KiB |
@@ -1 +0,0 @@
|
||||
<svg data-v-423bf9ae="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 59.99945071644884 60" class="icon"><!----><!----><!----><!----><g data-v-423bf9ae="" id="6f639fe5-02e1-4640-a1fb-f4b22b64e5ef" transform="matrix(0.5999641134746578,0,0,0.5999641134746578,-437.5538206497148,-266.68705118956507)" stroke="none" ><path d="M729.872 487.327a50.86 50.86 0 0 0-.464 5.033h75.879c-.265-.518-.621-.985-.98-1.442-12.972-16.769-19.95-15.315-29.932-15.741-3.328-.137-5.585-.192-18.832-.192-7.09 0-14.798.018-22.304.038.737-1.746 1.6-3.419 2.525-5.061-2.885 5.106-4.865 10.771-5.805 16.789l.891-4.397c.007-.031.018-.063.024-.094h38.883v5.067h-39.885zM805.885 497.432h-76.438c.08 1.352.206 2.686.388 4.002h70.571c3.146 0 4.907-1.786 5.479-4.002zM733.851 515.257a52.226 52.226 0 0 1-1.98-4.997c6.608 19.89 25.328 34.251 47.433 34.251 20.205 0 37.566-12.007 45.452-29.254h-90.905zM729.38 492.915c-.018.531-.08 1.055-.08 1.589 0 .538.063 1.059.08 1.59v-3.179zM824.77 515.229z"></path><path d="M779.303 444.505c-18.682 0-34.939 10.265-43.524 25.439 6.709-.014 19.775-.022 19.775-.022h.003v-.005c15.444 0 16.018.069 19.035.195l1.868.069c6.507.217 14.505.916 20.798 5.68 3.416 2.584 8.348 8.287 11.288 12.35 2.718 3.758 3.5 8.078 1.652 12.217-1.701 3.804-5.361 6.073-9.793 6.073H730.85l-.884-4.201c.426 2.707 1.037 5.344 1.879 7.886h94.914a49.863 49.863 0 0 0 2.546-15.682c.001-27.611-22.386-49.999-50.002-49.999z"></path></g><!----></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "Railway",
|
||||
"short_name": "Railway",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#100F13",
|
||||
"background_color": "#100F13",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
@tailwind base;
|
||||
|
||||
/* Write your own custom base styles here */
|
||||
|
||||
/* Start purging... */
|
||||
@tailwind components;
|
||||
/* Stop purging. */
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply antialiased;
|
||||
@apply bg-background text-white;
|
||||
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Write your own custom component styles here */
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 font-bold text-white bg-primary rounded;
|
||||
@apply hover:text-primary hover:bg-white;
|
||||
}
|
||||
|
||||
/* Start purging... */
|
||||
@tailwind utilities;
|
||||
/* Stop purging. */
|
||||
|
||||
/* Your own custom utilities */
|
||||
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min(65ch, 100%) 1fr;
|
||||
@apply px-8;
|
||||
}
|
||||
|
||||
.wrapper > * {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.full-bleed {
|
||||
width: 100%;
|
||||
grid-column: 1 / 4;
|
||||
}
|
||||
|
||||
.logo {
|
||||
fill: currentColor;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
module.exports = {
|
||||
purge: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx}',
|
||||
'./layouts/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
darkMode: 'class', // 'media' or 'class'
|
||||
theme: {
|
||||
fontFamily: {
|
||||
sans: `Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"`,
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
background: '#100f13',
|
||||
text: '#FFFFFF',
|
||||
primary: '#C049FF',
|
||||
secondary: '#618DFF',
|
||||
},
|
||||
typography: (theme) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
color: theme('colors.text'),
|
||||
|
||||
a: {
|
||||
color: theme('colors.text'),
|
||||
textDecoration: 'none',
|
||||
|
||||
'&:hover': {
|
||||
color: theme('colors.primary'),
|
||||
},
|
||||
},
|
||||
|
||||
p: {
|
||||
a: {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
|
||||
h1: {
|
||||
color: theme('colors.pink.50'),
|
||||
},
|
||||
h2: {
|
||||
color: theme('colors.text'),
|
||||
},
|
||||
h3: {
|
||||
color: theme('colors.text'),
|
||||
},
|
||||
h4: {
|
||||
color: theme('colors.text'),
|
||||
},
|
||||
img: {
|
||||
borderRadius: '10px',
|
||||
},
|
||||
code: {
|
||||
background: theme('colors.gray.800'),
|
||||
color: theme('colors.gray.200'),
|
||||
padding: '2px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = 'test-file-stub'
|
||||
@@ -1,137 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Home page matches snapshot 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="jsx-1276654382 container"
|
||||
>
|
||||
<main
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
<h1
|
||||
class="jsx-1276654382 title"
|
||||
>
|
||||
Welcome to
|
||||
<a
|
||||
class="jsx-1276654382"
|
||||
href="https://nextjs.org"
|
||||
>
|
||||
Next.js!
|
||||
</a>
|
||||
</h1>
|
||||
<p
|
||||
class="jsx-1276654382 description"
|
||||
>
|
||||
Get started by editing
|
||||
<code
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
pages/index.tsx
|
||||
</code>
|
||||
</p>
|
||||
<button
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Test Button
|
||||
</button>
|
||||
<div
|
||||
class="jsx-1276654382 grid"
|
||||
>
|
||||
<a
|
||||
class="jsx-1276654382 card"
|
||||
href="https://nextjs.org/docs"
|
||||
>
|
||||
<h3
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Documentation →
|
||||
</h3>
|
||||
<p
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Find in-depth information about Next.js features and API.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
class="jsx-1276654382 card"
|
||||
href="https://nextjs.org/learn"
|
||||
>
|
||||
<h3
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Learn →
|
||||
</h3>
|
||||
<p
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Learn about Next.js in an interactive course with quizzes!
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
class="jsx-1276654382 card"
|
||||
href="https://github.com/vercel/next.js/tree/master/examples"
|
||||
>
|
||||
<h3
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Examples →
|
||||
</h3>
|
||||
<p
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Discover and deploy boilerplate example Next.js projects.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
class="jsx-1276654382 card"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
>
|
||||
<h3
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Deploy →
|
||||
</h3>
|
||||
<p
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
class="jsx-1276654382"
|
||||
>
|
||||
<a
|
||||
class="jsx-1276654382"
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Powered by
|
||||
<div
|
||||
style="display: inline-block; max-width: 100%; overflow: hidden; position: relative; box-sizing: border-box; margin: 0px;"
|
||||
>
|
||||
<div
|
||||
style="box-sizing: border-box; display: block; max-width: 100%;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjQiIGhlaWdodD0iMzIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIi8+"
|
||||
style="max-width: 100%; display: block; margin: 0px; padding: 0px;"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
alt="Vercel Logo"
|
||||
decoding="async"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
style="visibility: hidden; position: absolute; top: 0px; left: 0px; bottom: 0px; right: 0px; box-sizing: border-box; padding: 0px; margin: auto; display: block; width: 0px; height: 0px; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent } from '../testUtils'
|
||||
import Home from '../../pages/index'
|
||||
|
||||
describe('Home page', () => {
|
||||
it('matches snapshot', () => {
|
||||
const { asFragment } = render(<Home posts={[]} />, {})
|
||||
expect(asFragment()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('clicking button triggers alert', () => {
|
||||
const { getByText } = render(<Home posts={[]} />, {})
|
||||
window.alert = jest.fn()
|
||||
fireEvent.click(getByText('Test Button'))
|
||||
expect(window.alert).toHaveBeenCalledWith('With typescript and Jest')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
// import { ThemeProvider } from "my-ui-lib"
|
||||
// import { TranslationProvider } from "my-i18n-lib"
|
||||
// import defaultStrings from "i18n/en-x-default"
|
||||
|
||||
const Providers = ({ children }) => {
|
||||
return children
|
||||
// return (
|
||||
// <ThemeProvider theme="light">
|
||||
// <TranslationProvider messages={defaultStrings}>
|
||||
// {children}
|
||||
// </TranslationProvider>
|
||||
// </ThemeProvider>
|
||||
// )
|
||||
}
|
||||
|
||||
const customRender = (ui, options = {}) =>
|
||||
render(ui, { wrapper: Providers, ...options })
|
||||
|
||||
// re-export everything
|
||||
export * from '@testing-library/react'
|
||||
|
||||
// override render method
|
||||
export { customRender as render }
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@layouts/*": ["layouts/*"],
|
||||
"@lib/*": ["lib/*"],
|
||||
"@styles/*": ["styles/*"]
|
||||
},
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"downlevelIteration": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"exclude": ["node_modules", ".next", "out"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"]
|
||||
}
|
||||
15
examples/railway-blog/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Railway blog
|
||||
description: A starter template to deploy a blog exactly like the Railway blog
|
||||
buttonSource: https://github.com/railwayapp/blog/blob/main/README.md
|
||||
tags:
|
||||
- nextjs
|
||||
- typescript
|
||||
- notion
|
||||
- tailwindcss
|
||||
- blog
|
||||
---
|
||||
|
||||
# Railway blog example
|
||||
|
||||
This starter points to the source code for the official Railway blog which can be found [here](https://github.com/railwayapp/blog).
|
||||