change next-notion-starter to railway-blog starter (#208)

This commit is contained in:
Faraz Patankar
2021-10-08 23:37:52 +04:00
committed by GitHub
parent 6b666c8c85
commit 0aca41a07e
62 changed files with 15 additions and 8864 deletions

View File

@@ -1,3 +0,0 @@
{
"presets": ["next/babel"]
}

View File

@@ -1,3 +0,0 @@
**/node_modules/*
**/out/*
**/.next/*

View File

@@ -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"]
}
]
}
}

View File

@@ -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

View File

@@ -1,5 +0,0 @@
node_modules
.next
yarn.lock
package-lock.json
public

View File

@@ -1,4 +0,0 @@
{
"semi": false,
"singleQuote": true
}

View File

@@ -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.
[![Deploy on Railway](https://railway.app/button.svg)](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.
![Blog index ID](https://user-images.githubusercontent.com/10681116/116751615-4a514b00-a9d2-11eb-86ed-5780e8f3c54c.jpeg)
- **Notion token**: To get this, just look for the token_v2 cookie while on Notion.
![Notion token](https://user-images.githubusercontent.com/10681116/116751809-94d2c780-a9d2-11eb-8ae0-ed8c58ff75b3.jpeg)
## 📝 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.

View File

@@ -1,7 +0,0 @@
import React, { ButtonHTMLAttributes } from 'react'
const Button: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => (
<button className="btn-primary" {...props} />
)
export default Button

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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',
},
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
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))

View File

@@ -1,5 +0,0 @@
import fs from 'fs'
import { promisify } from 'util'
export const readFile = promisify(fs.readFile)
export const writeFile = promisify(fs.writeFile)

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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 }
}

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
},
})
}

View File

@@ -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
)
}

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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',
})
}

View File

@@ -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
}
}

View File

@@ -1,2 +0,0 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

View File

@@ -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
},
}

View File

@@ -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"
}
}

View File

@@ -1,9 +0,0 @@
import '@styles/globals.css'
import type { AppProps } from 'next/app'
const RailwayBlog = ({ Component, pageProps }: AppProps) => (
<Component {...pageProps} />
)
export default RailwayBlog

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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;
}

View File

@@ -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')],
}

View File

@@ -1 +0,0 @@
module.exports = 'test-file-stub'

View File

@@ -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>
`;

View File

@@ -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')
})
})

View File

@@ -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 }

View File

@@ -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"]
}

File diff suppressed because it is too large Load Diff

View 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).