refactor: migrate to rsc and app dir
This commit is contained in:
		| @@ -1,3 +1,4 @@ | ||||
| # visit https://giscus.app to get your Giscus ids | ||||
| NEXT_PUBLIC_GISCUS_REPO= | ||||
| NEXT_PUBLIC_GISCUS_REPOSITORY_ID= | ||||
| NEXT_PUBLIC_GISCUS_CATEGORY= | ||||
| @@ -10,20 +11,16 @@ MAILCHIMP_API_KEY= | ||||
| MAILCHIMP_API_SERVER= | ||||
| MAILCHIMP_AUDIENCE_ID= | ||||
|  | ||||
| BUTTONDOWN_API_URL=https://api.buttondown.email/v1/ | ||||
| BUTTONDOWN_API_KEY= | ||||
|  | ||||
| CONVERTKIT_API_URL=https://api.convertkit.com/v3/ | ||||
| CONVERTKIT_API_KEY= | ||||
| // curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID  | ||||
| # curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID | ||||
| CONVERTKIT_FORM_ID= | ||||
|  | ||||
| KLAVIYO_API_KEY= | ||||
| KLAVIYO_LIST_ID= | ||||
|  | ||||
| REVUE_API_URL=https://www.getrevue.co/api/v2/ | ||||
| REVUE_API_KEY= | ||||
|  | ||||
| EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/ | ||||
| EMAILOCTOPUS_API_KEY= | ||||
| EMAILOCTOPUS_LIST_ID= | ||||
							
								
								
									
										23
									
								
								.eslintrc.js
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								.eslintrc.js
									
									
									
									
									
								
							| @@ -1,17 +1,38 @@ | ||||
| module.exports = { | ||||
|   root: true, | ||||
|   parser: '@typescript-eslint/parser', | ||||
|   env: { | ||||
|     browser: true, | ||||
|     amd: true, | ||||
|     node: true, | ||||
|     es6: true, | ||||
|   }, | ||||
|   extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'], | ||||
|   plugins: ['@typescript-eslint'], | ||||
|   extends: [ | ||||
|     'eslint:recommended', | ||||
|     'plugin:@typescript-eslint/eslint-recommended', | ||||
|     'plugin:@typescript-eslint/recommended', | ||||
|     'plugin:jsx-a11y/recommended', | ||||
|     'plugin:prettier/recommended', | ||||
|     'next', | ||||
|     'next/core-web-vitals', | ||||
|   ], | ||||
|   rules: { | ||||
|     'prettier/prettier': 'error', | ||||
|     'react/react-in-jsx-scope': 'off', | ||||
|     'jsx-a11y/anchor-is-valid': [ | ||||
|       'error', | ||||
|       { | ||||
|         components: ['Link'], | ||||
|         specialLink: ['hrefLeft', 'hrefRight'], | ||||
|         aspects: ['invalidHref', 'preferButton'], | ||||
|       }, | ||||
|     ], | ||||
|     'react/prop-types': 0, | ||||
|     'no-unused-vars': 0, | ||||
|     'react/no-unescaped-entities': 0, | ||||
|     '@typescript-eslint/explicit-module-boundary-types': 'off', | ||||
|     '@typescript-eslint/no-var-requires': 'off', | ||||
|     '@typescript-eslint/ban-ts-comment': 'off', | ||||
|   }, | ||||
| } | ||||
|   | ||||
							
								
								
									
										2
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +1,5 @@ | ||||
| ## Source: https://github.com/alexkaratarakis/gitattributes | ||||
| ## Modified * text=auto to * text=auto eol=lf to force LF endings. | ||||
| ## Modified * text=auto to * text=auto eol=lf eol=lf to force LF endings. | ||||
|  | ||||
| ## GITATTRIBUTES FOR WEB PROJECTS | ||||
| # | ||||
|   | ||||
							
								
								
									
										11
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -4,6 +4,10 @@ | ||||
| /node_modules | ||||
| /.pnp | ||||
| .pnp.js | ||||
| /.yarn/* | ||||
| !/.yarn/releases | ||||
| !/.yarn/plugins | ||||
| !/.yarn/sdks | ||||
|  | ||||
| # testing | ||||
| /coverage | ||||
| @@ -17,9 +21,13 @@ public/sitemap.xml | ||||
| # production | ||||
| /build | ||||
| *.xml | ||||
|  | ||||
| # rss feed | ||||
| /public/feed.xml | ||||
|  | ||||
| # search | ||||
| /public/search.json | ||||
|  | ||||
| # misc | ||||
| .DS_Store | ||||
|  | ||||
| @@ -34,3 +42,6 @@ yarn-error.log* | ||||
| .env.development.local | ||||
| .env.test.local | ||||
| .env.production.local | ||||
|  | ||||
| # Contentlayer | ||||
| .contentlayer | ||||
|   | ||||
							
								
								
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| { | ||||
|   "typescript.tsdk": "node_modules/typescript/lib", | ||||
|   "typescript.enablePromptUseWorkspaceTsdk": true | ||||
| } | ||||
							
								
								
									
										1
									
								
								.yarnrc.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.yarnrc.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| nodeLinker: node-modules | ||||
| @@ -1,26 +1,20 @@ | ||||
| 'use client' | ||||
| 
 | ||||
| import Link from '@/components/Link' | ||||
| import { PageSEO } from '@/components/SEO' | ||||
| // import { PageSEO } from '@/components/SEO'
 | ||||
| import Tag from '@/components/Tag' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import { getAllFilesFrontMatter } from '@/lib/mdx' | ||||
| import formatDate from '@/lib/utils/formatDate' | ||||
| 
 | ||||
| import NewsletterForm from '@/components/NewsletterForm' | ||||
| import { formatDate } from 'pliny/utils/formatDate' | ||||
| import { NewsletterForm } from 'pliny/ui/NewsletterForm' | ||||
| 
 | ||||
| const MAX_DISPLAY = 5 | ||||
| 
 | ||||
| export async function getStaticProps() { | ||||
|   const posts = await getAllFilesFrontMatter('blog') | ||||
| 
 | ||||
|   return { props: { posts } } | ||||
| } | ||||
| 
 | ||||
| export default function Home({ posts }) { | ||||
|   return ( | ||||
|     <> | ||||
|       <PageSEO title={siteMetadata.title} description={siteMetadata.description} /> | ||||
|       {/* <PageSEO title={siteMetadata.title} description={siteMetadata.description} /> */} | ||||
|       <div className="divide-y divide-gray-200 dark:divide-gray-700"> | ||||
|         <div className="space-y-2 pt-6 pb-8 md:space-y-5"> | ||||
|         <div className="space-y-2 pb-8 pt-6 md:space-y-5"> | ||||
|           <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14"> | ||||
|             Latest | ||||
|           </h1> | ||||
| @@ -30,8 +24,8 @@ export default function Home({ posts }) { | ||||
|         </div> | ||||
|         <ul className="divide-y divide-gray-200 dark:divide-gray-700"> | ||||
|           {!posts.length && 'No posts found.'} | ||||
|           {posts.slice(0, MAX_DISPLAY).map((frontMatter) => { | ||||
|             const { slug, date, title, summary, tags } = frontMatter | ||||
|           {posts.slice(0, MAX_DISPLAY).map((post) => { | ||||
|             const { slug, date, title, summary, tags } = post | ||||
|             return ( | ||||
|               <li key={slug} className="py-12"> | ||||
|                 <article> | ||||
| @@ -39,7 +33,7 @@ export default function Home({ posts }) { | ||||
|                     <dl> | ||||
|                       <dt className="sr-only">Published on</dt> | ||||
|                       <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400"> | ||||
|                         <time dateTime={date}>{formatDate(date)}</time> | ||||
|                         <time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time> | ||||
|                       </dd> | ||||
|                     </dl> | ||||
|                     <div className="space-y-5 xl:col-span-3"> | ||||
| @@ -85,13 +79,13 @@ export default function Home({ posts }) { | ||||
|           <Link | ||||
|             href="/blog" | ||||
|             className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" | ||||
|             aria-label="all posts" | ||||
|             aria-label="All posts" | ||||
|           > | ||||
|             All Posts → | ||||
|           </Link> | ||||
|         </div> | ||||
|       )} | ||||
|       {siteMetadata.newsletter.provider !== '' && ( | ||||
|       {siteMetadata.newsletter?.provider && ( | ||||
|         <div className="flex items-center justify-center pt-4"> | ||||
|           <NewsletterForm /> | ||||
|         </div> | ||||
							
								
								
									
										16
									
								
								app/about/About.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/about/About.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { MDXLayoutRenderer } from 'pliny/mdx-components' | ||||
| import { MDXComponents } from '@/components/MDXComponents' | ||||
|  | ||||
| const DEFAULT_LAYOUT = 'AuthorLayout' | ||||
|  | ||||
| export default function About({ author }) { | ||||
|   return ( | ||||
|     <MDXLayoutRenderer | ||||
|       layout={author.layout || DEFAULT_LAYOUT} | ||||
|       content={author} | ||||
|       MDXComponents={MDXComponents} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										7
									
								
								app/about/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/about/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| import { allAuthors } from 'contentlayer/generated' | ||||
| import About from './About' | ||||
|  | ||||
| export default function Page() { | ||||
|   const author = allAuthors.find((p) => p.slug === 'default') | ||||
|   return <About author={author} /> | ||||
| } | ||||
							
								
								
									
										14
									
								
								app/api/newsletter2/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/api/newsletter2/route.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { NextResponse } from 'next/server' | ||||
| import { NewsletterAPI } from 'pliny/newsletter' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| export async function POST(request: Request) { | ||||
|   const res = await request.json() | ||||
|   console.log(res) // { email: 'test@example.com' } | ||||
|  | ||||
|   return NextResponse.json({ res }) | ||||
|   // return NewsletterAPI({ | ||||
|   //   // @ts-ignore | ||||
|   //   provider: siteMetadata.newsletter.provider, | ||||
|   // }) | ||||
| } | ||||
							
								
								
									
										21
									
								
								app/blog/[...slug]/Blog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/blog/[...slug]/Blog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { MDXLayoutRenderer } from 'pliny/mdx-components' | ||||
| import { MDXComponents } from '@/components/MDXComponents' | ||||
| import type { Blog } from 'contentlayer/generated' | ||||
|  | ||||
| const DEFAULT_LAYOUT = 'PostLayout' | ||||
|  | ||||
| export default function Blog({ post, authorDetails, prev, next }) { | ||||
|   return ( | ||||
|     <MDXLayoutRenderer | ||||
|       layout={post.layout || DEFAULT_LAYOUT} | ||||
|       content={post} | ||||
|       MDXComponents={MDXComponents} | ||||
|       toc={post.toc} | ||||
|       authorDetails={authorDetails} | ||||
|       prev={prev} | ||||
|       next={next} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										44
									
								
								app/blog/[...slug]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								app/blog/[...slug]/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| import PageTitle from '@/components/PageTitle' | ||||
| import { sortedBlogPost, coreContent } from 'pliny/utils/contentlayer' | ||||
| import { allBlogs, allAuthors } from 'contentlayer/generated' | ||||
| import type { Authors, Blog } from 'contentlayer/generated' | ||||
| import BlogPage from './Blog' | ||||
|  | ||||
| export const generateStaticParams = async () => { | ||||
|   const paths = allBlogs.map((p) => ({ slug: p.slug.split('/') })) | ||||
|  | ||||
|   return paths | ||||
| } | ||||
|  | ||||
| export default function Page({ params }: { params: { slug: string[] } }) { | ||||
|   const slug = params.slug.join('/') | ||||
|   const sortedPosts = sortedBlogPost(allBlogs) as Blog[] | ||||
|   const postIndex = sortedPosts.findIndex((p) => p.slug === slug) | ||||
|   const prevContent = sortedPosts[postIndex + 1] || null | ||||
|   const prev = prevContent ? coreContent(prevContent) : null | ||||
|   const nextContent = sortedPosts[postIndex - 1] || null | ||||
|   const next = nextContent ? coreContent(nextContent) : null | ||||
|   const post = sortedPosts.find((p) => p.slug === slug) | ||||
|   const authorList = post?.authors || ['default'] | ||||
|   const authorDetails = authorList.map((author) => { | ||||
|     const authorResults = allAuthors.find((p) => p.slug === author) | ||||
|     return coreContent(authorResults as Authors) | ||||
|   }) | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {post && 'draft' in post && post.draft === true ? ( | ||||
|         <div className="mt-24 text-center"> | ||||
|           <PageTitle> | ||||
|             Under Construction{' '} | ||||
|             <span role="img" aria-label="roadwork sign"> | ||||
|               🚧 | ||||
|             </span> | ||||
|           </PageTitle> | ||||
|         </div> | ||||
|       ) : ( | ||||
|         <BlogPage post={post} authorDetails={authorDetails} prev={prev} next={next} /> | ||||
|       )} | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										31
									
								
								app/blog/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/blog/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import ListLayout from '@/layouts/ListLayout' | ||||
| import { sortedBlogPost } from 'pliny/utils/contentlayer' | ||||
| import { allBlogs } from 'contentlayer/generated' | ||||
| import type { Blog } from 'contentlayer/generated' | ||||
|  | ||||
| const POSTS_PER_PAGE = 5 | ||||
|  | ||||
| export default function BlogPage() { | ||||
|   const posts = sortedBlogPost(allBlogs) as Blog[] | ||||
|   const pageNumber = 1 | ||||
|   const initialDisplayPosts = posts.slice( | ||||
|     POSTS_PER_PAGE * (pageNumber - 1), | ||||
|     POSTS_PER_PAGE * pageNumber | ||||
|   ) | ||||
|   const pagination = { | ||||
|     currentPage: pageNumber, | ||||
|     totalPages: Math.ceil(posts.length / POSTS_PER_PAGE), | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* <PageSEO title={`Blog - ${siteMetadata.author}`} description={siteMetadata.description} /> */} | ||||
|       <ListLayout | ||||
|         posts={posts} | ||||
|         initialDisplayPosts={initialDisplayPosts} | ||||
|         pagination={pagination} | ||||
|         title="All Posts" | ||||
|       /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										41
									
								
								app/blog/page/[page]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								app/blog/page/[page]/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| // import { PageSEO } from '@/components/SEO' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import ListLayout from '@/layouts/ListLayout' | ||||
| import { allCoreContent, sortedBlogPost } from 'pliny/utils/contentlayer' | ||||
| import { allBlogs } from 'contentlayer/generated' | ||||
| import type { Blog } from 'contentlayer/generated' | ||||
|  | ||||
| const POSTS_PER_PAGE = 5 | ||||
|  | ||||
| export const generateStaticParams = async () => { | ||||
|   const totalPosts = allBlogs | ||||
|   const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE) | ||||
|   const paths = Array.from({ length: totalPages }, (_, i) => ({ page: (i + 1).toString() })) | ||||
|  | ||||
|   return paths | ||||
| } | ||||
|  | ||||
| export default function Page({ params }: { params: { page: string } }) { | ||||
|   const posts = sortedBlogPost(allBlogs) as Blog[] | ||||
|   const pageNumber = parseInt(params.page as string) | ||||
|   const initialDisplayPosts = posts.slice( | ||||
|     POSTS_PER_PAGE * (pageNumber - 1), | ||||
|     POSTS_PER_PAGE * pageNumber | ||||
|   ) | ||||
|   const pagination = { | ||||
|     currentPage: pageNumber, | ||||
|     totalPages: Math.ceil(posts.length / POSTS_PER_PAGE), | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {/* <PageSEO title={siteMetadata.title} description={siteMetadata.description} /> */} | ||||
|       <ListLayout | ||||
|         posts={allCoreContent(posts)} | ||||
|         initialDisplayPosts={allCoreContent(initialDisplayPosts)} | ||||
|         pagination={pagination} | ||||
|         title="All Posts" | ||||
|       /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										29
									
								
								app/globals.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/globals.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| @tailwind base; | ||||
| @tailwind components; | ||||
| @tailwind utilities; | ||||
|  | ||||
| .task-list-item::before { | ||||
|     @apply hidden; | ||||
| } | ||||
|  | ||||
| .task-list-item { | ||||
|     @apply list-none; | ||||
| } | ||||
|  | ||||
| .footnotes { | ||||
|     @apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700; | ||||
| } | ||||
|  | ||||
| .data-footnote-backref { | ||||
|     @apply no-underline; | ||||
| } | ||||
|  | ||||
| .csl-entry { | ||||
|     @apply my-5; | ||||
| } | ||||
|  | ||||
| /* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */ | ||||
| input:-webkit-autofill, | ||||
| input:-webkit-autofill:focus { | ||||
|     transition: background-color 600000s 0s, color 600000s 0s; | ||||
| } | ||||
							
								
								
									
										16
									
								
								app/head.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/head.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| export default function Head() { | ||||
|   return ( | ||||
|     <> | ||||
|       <link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" /> | ||||
|       <link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png" /> | ||||
|       <link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png" /> | ||||
|       <link rel="manifest" href="/static/favicons/site.webmanifest" /> | ||||
|       <link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" /> | ||||
|       <meta name="msapplication-TileColor" content="#000000" /> | ||||
|       <meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" /> | ||||
|       <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" /> | ||||
|       <link rel="alternate" type="application/rss+xml" href="/feed.xml" /> | ||||
|       <meta content="width=device-width, initial-scale=1" name="viewport" /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										35
									
								
								app/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/layout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import './globals.css' | ||||
|  | ||||
| import { Inter } from 'next/font/google' | ||||
| import { Analytics } from 'pliny/analytics' | ||||
| import { SearchProvider } from 'pliny/search' | ||||
| import Header from '@/components/Header' | ||||
| import SectionContainer from '@/components/SectionContainer' | ||||
| import Footer from '@/components/Footer' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import { ThemeProviders } from './theme-providers' | ||||
|  | ||||
| const inter = Inter({ | ||||
|   subsets: ['latin'], | ||||
| }) | ||||
|  | ||||
| export default function RootLayout({ children }: { children: React.ReactNode }) { | ||||
|   return ( | ||||
|     <html lang={siteMetadata.language} className={`${inter.className} scroll-smooth`}> | ||||
|       <body className="bg-white text-black antialiased dark:bg-gray-900 dark:text-white"> | ||||
|         <ThemeProviders> | ||||
|           {/* <Analytics analyticsConfig={siteMetadata.analytics} /> */} | ||||
|           <SectionContainer> | ||||
|             <div className="flex h-screen flex-col justify-between font-sans"> | ||||
|               {/* <SearchProvider searchConfig={siteMetadata.search}> */} | ||||
|               <Header /> | ||||
|               <main className="mb-auto">{children}</main> | ||||
|               {/* </SearchProvider> */} | ||||
|               <Footer /> | ||||
|             </div> | ||||
|           </SectionContainer> | ||||
|         </ThemeProviders> | ||||
|       </body> | ||||
|     </html> | ||||
|   ) | ||||
| } | ||||
| @@ -1,13 +1,12 @@ | ||||
| import Link from '@/components/Link' | ||||
| import { PageSEO } from '@/components/SEO' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| // import { PageSEO } from '@/components/SEO'
 | ||||
| 
 | ||||
| export default function FourZeroFour() { | ||||
| export default function NotFound() { | ||||
|   return ( | ||||
|     <> | ||||
|       <PageSEO title={`Page Not Found - ${siteMetadata.title}`} /> | ||||
|       {/* <PageSEO title="Page Not Found" description="Sorry we couldn't find this page :(" /> */} | ||||
|       <div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6"> | ||||
|         <div className="space-x-2 pt-6 pb-8 md:space-y-5"> | ||||
|         <div className="space-x-2 pb-8 pt-6 md:space-y-5"> | ||||
|           <h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:border-r-2 md:px-6 md:text-8xl md:leading-14"> | ||||
|             404 | ||||
|           </h1> | ||||
| @@ -19,10 +18,11 @@ export default function FourZeroFour() { | ||||
|           <p className="mb-8"> | ||||
|             But dont worry, you can find plenty of other things on our homepage. | ||||
|           </p> | ||||
|           <Link href="/"> | ||||
|             <button className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500"> | ||||
|               Back to homepage | ||||
|             </button> | ||||
|           <Link | ||||
|             href="/" | ||||
|             className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500" | ||||
|           > | ||||
|             Back to homepage | ||||
|           </Link> | ||||
|         </div> | ||||
|       </div> | ||||
							
								
								
									
										10
									
								
								app/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								app/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import { sortedBlogPost, allCoreContent } from 'pliny/utils/contentlayer' | ||||
| import { allBlogs } from 'contentlayer/generated' | ||||
| import type { Blog } from 'contentlayer/generated' | ||||
| import Main from './Main' | ||||
|  | ||||
| export default async function Page() { | ||||
|   const sortedPosts = sortedBlogPost(allBlogs) as Blog[] | ||||
|   const posts = allCoreContent(sortedPosts) | ||||
|   return <Main posts={posts} /> | ||||
| } | ||||
| @@ -1,14 +1,12 @@ | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import projectsData from '@/data/projectsData' | ||||
| import Card from '@/components/Card' | ||||
| import { PageSEO } from '@/components/SEO' | ||||
| 
 | ||||
| export default function Projects() { | ||||
|   return ( | ||||
|     <> | ||||
|       <PageSEO title={`Projects - ${siteMetadata.author}`} description={siteMetadata.description} /> | ||||
|       <div className="divide-y divide-gray-200 dark:divide-gray-700"> | ||||
|         <div className="space-y-2 pt-6 pb-8 md:space-y-5"> | ||||
|         <div className="space-y-2 pb-8 pt-6 md:space-y-5"> | ||||
|           <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14"> | ||||
|             Projects | ||||
|           </h1> | ||||
| @@ -1,23 +1,19 @@ | ||||
| import { getAllTags } from 'pliny/utils/contentlayer' | ||||
| import Link from '@/components/Link' | ||||
| import { PageSEO } from '@/components/SEO' | ||||
| // import { PageSEO } from '@/components/SEO'
 | ||||
| import Tag from '@/components/Tag' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import { getAllTags } from '@/lib/tags' | ||||
| import kebabCase from '@/lib/utils/kebabCase' | ||||
| import { kebabCase } from 'pliny/utils/kebabCase' | ||||
| import { allBlogs } from 'contentlayer/generated' | ||||
| 
 | ||||
| export async function getStaticProps() { | ||||
|   const tags = await getAllTags('blog') | ||||
| 
 | ||||
|   return { props: { tags } } | ||||
| } | ||||
| 
 | ||||
| export default function Tags({ tags }) { | ||||
| export default async function Page() { | ||||
|   const tags = await getAllTags(allBlogs) | ||||
|   const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a]) | ||||
|   return ( | ||||
|     <> | ||||
|       <PageSEO title={`Tags - ${siteMetadata.author}`} description="Things I blog about" /> | ||||
|       {/* <PageSEO title={`Tags - ${siteMetadata.author}`} description="Things I blog about" /> */} | ||||
|       <div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6 md:divide-y-0"> | ||||
|         <div className="space-x-2 pt-6 pb-8 md:space-y-5"> | ||||
|         <div className="space-x-2 pb-8 pt-6 md:space-y-5"> | ||||
|           <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:border-r-2 md:px-6 md:text-6xl md:leading-14"> | ||||
|             Tags | ||||
|           </h1> | ||||
| @@ -26,11 +22,12 @@ export default function Tags({ tags }) { | ||||
|           {Object.keys(tags).length === 0 && 'No tags found.'} | ||||
|           {sortedTags.map((t) => { | ||||
|             return ( | ||||
|               <div key={t} className="mt-2 mb-2 mr-5"> | ||||
|               <div key={t} className="mb-2 mr-5 mt-2"> | ||||
|                 <Tag text={t} /> | ||||
|                 <Link | ||||
|                   href={`/tags/${kebabCase(t)}`} | ||||
|                   className="-ml-2 text-sm font-semibold uppercase text-gray-600 dark:text-gray-300" | ||||
|                   aria-label={`View posts tagged ${t}`} | ||||
|                 > | ||||
|                   {` (${tags[t]})`} | ||||
|                 </Link> | ||||
							
								
								
									
										12
									
								
								app/theme-providers.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								app/theme-providers.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { ThemeProvider } from 'next-themes' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| export function ThemeProviders({ children }: { children: React.ReactNode }) { | ||||
|   return ( | ||||
|     <ThemeProvider attribute="class" defaultTheme={siteMetadata.theme} enableSystem> | ||||
|       {children} | ||||
|     </ThemeProvider> | ||||
|   ) | ||||
| } | ||||
| @@ -2,7 +2,7 @@ import Image from './Image' | ||||
| import Link from './Link' | ||||
| 
 | ||||
| const Card = ({ title, description, imgSrc, href }) => ( | ||||
|   <div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}> | ||||
|   <div className="md max-w-[544px] p-4 md:w-1/2"> | ||||
|     <div | ||||
|       className={`${ | ||||
|         imgSrc && 'h-full' | ||||
| @@ -1,23 +0,0 @@ | ||||
| import { useEffect } from 'react' | ||||
| import Router from 'next/router' | ||||
|  | ||||
| /** | ||||
|  * Client-side complement to next-remote-watch | ||||
|  * Re-triggers getStaticProps when watched mdx files change | ||||
|  * | ||||
|  */ | ||||
| export const ClientReload = () => { | ||||
|   // Exclude socket.io from prod bundle | ||||
|   useEffect(() => { | ||||
|     import('socket.io-client').then((module) => { | ||||
|       const socket = module.io() | ||||
|       socket.on('reload', (data) => { | ||||
|         Router.replace(Router.asPath, undefined, { | ||||
|           scroll: false, | ||||
|         }) | ||||
|       }) | ||||
|     }) | ||||
|   }, []) | ||||
|  | ||||
|   return null | ||||
| } | ||||
| @@ -7,12 +7,12 @@ export default function Footer() { | ||||
|     <footer> | ||||
|       <div className="mt-16 flex flex-col items-center"> | ||||
|         <div className="mb-3 flex space-x-4"> | ||||
|           <SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size="6" /> | ||||
|           <SocialIcon kind="github" href={siteMetadata.github} size="6" /> | ||||
|           <SocialIcon kind="facebook" href={siteMetadata.facebook} size="6" /> | ||||
|           <SocialIcon kind="youtube" href={siteMetadata.youtube} size="6" /> | ||||
|           <SocialIcon kind="linkedin" href={siteMetadata.linkedin} size="6" /> | ||||
|           <SocialIcon kind="twitter" href={siteMetadata.twitter} size="6" /> | ||||
|           <SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size={6} /> | ||||
|           <SocialIcon kind="github" href={siteMetadata.github} size={6} /> | ||||
|           <SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} /> | ||||
|           <SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} /> | ||||
|           <SocialIcon kind="linkedin" href={siteMetadata.linkedin} size={6} /> | ||||
|           <SocialIcon kind="twitter" href={siteMetadata.twitter} size={6} /> | ||||
|         </div> | ||||
|         <div className="mb-2 flex space-x-2 text-sm text-gray-500 dark:text-gray-400"> | ||||
|           <div>{siteMetadata.author}</div> | ||||
							
								
								
									
										46
									
								
								components/Header.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								components/Header.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import headerNavLinks from '@/data/headerNavLinks' | ||||
| import Logo from '@/data/logo.svg' | ||||
| import Link from './Link' | ||||
| import MobileNav from './MobileNav' | ||||
| import ThemeSwitch from './ThemeSwitch' | ||||
|  | ||||
| const Header = () => { | ||||
|   return ( | ||||
|     <header className="flex items-center justify-between py-10"> | ||||
|       <div> | ||||
|         <Link href="/" aria-label={siteMetadata.headerTitle}> | ||||
|           <div className="flex items-center justify-between"> | ||||
|             <div className="mr-3"> | ||||
|               <Logo /> | ||||
|             </div> | ||||
|             {typeof siteMetadata.headerTitle === 'string' ? ( | ||||
|               <div className="hidden h-6 text-2xl font-semibold sm:block"> | ||||
|                 {siteMetadata.headerTitle} | ||||
|               </div> | ||||
|             ) : ( | ||||
|               siteMetadata.headerTitle | ||||
|             )} | ||||
|           </div> | ||||
|         </Link> | ||||
|       </div> | ||||
|       <div className="flex items-center text-base leading-5"> | ||||
|         <div className="hidden sm:block"> | ||||
|           {headerNavLinks.map((link) => ( | ||||
|             <Link | ||||
|               key={link.title} | ||||
|               href={link.href} | ||||
|               className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4" | ||||
|             > | ||||
|               {link.title} | ||||
|             </Link> | ||||
|           ))} | ||||
|         </div> | ||||
|         <ThemeSwitch /> | ||||
|         <MobileNav /> | ||||
|       </div> | ||||
|     </header> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Header | ||||
| @@ -1,6 +0,0 @@ | ||||
| import NextImage from 'next/image' | ||||
|  | ||||
| // eslint-disable-next-line jsx-a11y/alt-text | ||||
| const Image = ({ ...rest }) => <NextImage {...rest} /> | ||||
|  | ||||
| export default Image | ||||
							
								
								
									
										5
									
								
								components/Image.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								components/Image.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| import NextImage, { ImageProps } from 'next/image' | ||||
|  | ||||
| const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} /> | ||||
|  | ||||
| export default Image | ||||
| @@ -1,54 +0,0 @@ | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import headerNavLinks from '@/data/headerNavLinks' | ||||
| import Logo from '@/data/logo.svg' | ||||
| import Link from './Link' | ||||
| import SectionContainer from './SectionContainer' | ||||
| import Footer from './Footer' | ||||
| import MobileNav from './MobileNav' | ||||
| import ThemeSwitch from './ThemeSwitch' | ||||
|  | ||||
| const LayoutWrapper = ({ children }) => { | ||||
|   return ( | ||||
|     <SectionContainer> | ||||
|       <div className="flex h-screen flex-col justify-between"> | ||||
|         <header className="flex items-center justify-between py-10"> | ||||
|           <div> | ||||
|             <Link href="/" aria-label={siteMetadata.headerTitle}> | ||||
|               <div className="flex items-center justify-between"> | ||||
|                 <div className="mr-3"> | ||||
|                   <Logo /> | ||||
|                 </div> | ||||
|                 {typeof siteMetadata.headerTitle === 'string' ? ( | ||||
|                   <div className="hidden h-6 text-2xl font-semibold sm:block"> | ||||
|                     {siteMetadata.headerTitle} | ||||
|                   </div> | ||||
|                 ) : ( | ||||
|                   siteMetadata.headerTitle | ||||
|                 )} | ||||
|               </div> | ||||
|             </Link> | ||||
|           </div> | ||||
|           <div className="flex items-center text-base leading-5"> | ||||
|             <div className="hidden sm:block"> | ||||
|               {headerNavLinks.map((link) => ( | ||||
|                 <Link | ||||
|                   key={link.title} | ||||
|                   href={link.href} | ||||
|                   className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4" | ||||
|                 > | ||||
|                   {link.title} | ||||
|                 </Link> | ||||
|               ))} | ||||
|             </div> | ||||
|             <ThemeSwitch /> | ||||
|             <MobileNav /> | ||||
|           </div> | ||||
|         </header> | ||||
|         <main className="mb-auto">{children}</main> | ||||
|         <Footer /> | ||||
|       </div> | ||||
|     </SectionContainer> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default LayoutWrapper | ||||
							
								
								
									
										27
									
								
								components/LayoutWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								components/LayoutWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import { Inter } from 'next/font/google' | ||||
| import SectionContainer from './SectionContainer' | ||||
| import Footer from './Footer' | ||||
| import { ReactNode } from 'react' | ||||
| import Header from './Header' | ||||
|  | ||||
| interface Props { | ||||
|   children: ReactNode | ||||
| } | ||||
|  | ||||
| const inter = Inter({ | ||||
|   subsets: ['latin'], | ||||
| }) | ||||
|  | ||||
| const LayoutWrapper = ({ children }: Props) => { | ||||
|   return ( | ||||
|     <SectionContainer> | ||||
|       <div className={`${inter.className} flex h-screen flex-col justify-between font-sans`}> | ||||
|         <Header /> | ||||
|         <main className="mb-auto">{children}</main> | ||||
|         <Footer /> | ||||
|       </div> | ||||
|     </SectionContainer> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default LayoutWrapper | ||||
| @@ -1,16 +1,17 @@ | ||||
| /* eslint-disable jsx-a11y/anchor-has-content */ | ||||
| import Link from 'next/link' | ||||
| import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react' | ||||
| 
 | ||||
| const CustomLink = ({ href, ...rest }) => { | ||||
| const CustomLink = ({ | ||||
|   href, | ||||
|   ...rest | ||||
| }: DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>) => { | ||||
|   const isInternalLink = href && href.startsWith('/') | ||||
|   const isAnchorLink = href && href.startsWith('#') | ||||
| 
 | ||||
|   if (isInternalLink) { | ||||
|     return ( | ||||
|       <Link href={href}> | ||||
|         <a {...rest} /> | ||||
|       </Link> | ||||
|     ) | ||||
|     // @ts-ignore
 | ||||
|     return <Link href={href} {...rest} /> | ||||
|   } | ||||
| 
 | ||||
|   if (isAnchorLink) { | ||||
| @@ -1,26 +0,0 @@ | ||||
| /* eslint-disable react/display-name */ | ||||
| import { useMemo } from 'react' | ||||
| import { getMDXComponent } from 'mdx-bundler/client' | ||||
| import Image from './Image' | ||||
| import CustomLink from './Link' | ||||
| import TOCInline from './TOCInline' | ||||
| import Pre from './Pre' | ||||
| import { BlogNewsletterForm } from './NewsletterForm' | ||||
|  | ||||
| export const MDXComponents = { | ||||
|   Image, | ||||
|   TOCInline, | ||||
|   a: CustomLink, | ||||
|   pre: Pre, | ||||
|   BlogNewsletterForm: BlogNewsletterForm, | ||||
|   wrapper: ({ components, layout, ...rest }) => { | ||||
|     const Layout = require(`../layouts/${layout}`).default | ||||
|     return <Layout {...rest} /> | ||||
|   }, | ||||
| } | ||||
|  | ||||
| export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => { | ||||
|   const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]) | ||||
|  | ||||
|   return <MDXLayout layout={layout} components={MDXComponents} {...rest} /> | ||||
| } | ||||
							
								
								
									
										23
									
								
								components/MDXComponents.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								components/MDXComponents.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| /* eslint-disable react/display-name */ | ||||
| import React from 'react' | ||||
| import { MDXLayout, ComponentMap } from 'pliny/mdx-components' | ||||
| import { TOCInline } from 'pliny/ui/TOCInline' | ||||
| import { Pre } from 'pliny/ui/Pre' | ||||
| import { BlogNewsletterForm } from 'pliny/ui/NewsletterForm' | ||||
|  | ||||
| import Image from './Image' | ||||
| import CustomLink from './Link' | ||||
|  | ||||
| export const Wrapper = ({ layout, content, ...rest }: MDXLayout) => { | ||||
|   const Layout = require(`../layouts/${layout}`).default | ||||
|   return <Layout content={content} {...rest} /> | ||||
| } | ||||
|  | ||||
| export const MDXComponents: ComponentMap = { | ||||
|   Image, | ||||
|   TOCInline, | ||||
|   a: CustomLink, | ||||
|   pre: Pre, | ||||
|   wrapper: Wrapper, | ||||
|   BlogNewsletterForm, | ||||
| } | ||||
| @@ -1,3 +1,5 @@ | ||||
| 'use client' | ||||
| 
 | ||||
| import { useState } from 'react' | ||||
| import Link from './Link' | ||||
| import headerNavLinks from '@/data/headerNavLinks' | ||||
| @@ -20,7 +22,6 @@ const MobileNav = () => { | ||||
|   return ( | ||||
|     <div className="sm:hidden"> | ||||
|       <button | ||||
|         type="button" | ||||
|         className="ml-1 mr-1 h-8 w-8 rounded py-1" | ||||
|         aria-label="Toggle Menu" | ||||
|         onClick={onToggleNav} | ||||
| @@ -39,13 +40,12 @@ const MobileNav = () => { | ||||
|         </svg> | ||||
|       </button> | ||||
|       <div | ||||
|         className={`fixed top-0 left-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${ | ||||
|         className={`fixed left-0 top-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${ | ||||
|           navShow ? 'translate-x-0' : 'translate-x-full' | ||||
|         }`}
 | ||||
|       > | ||||
|         <div className="flex justify-end"> | ||||
|           <button | ||||
|             type="button" | ||||
|             className="mr-5 mt-11 h-8 w-8 rounded" | ||||
|             aria-label="Toggle Menu" | ||||
|             onClick={onToggleNav} | ||||
| @@ -1,84 +0,0 @@ | ||||
| import { useRef, useState } from 'react' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => { | ||||
|   const inputEl = useRef(null) | ||||
|   const [error, setError] = useState(false) | ||||
|   const [message, setMessage] = useState('') | ||||
|   const [subscribed, setSubscribed] = useState(false) | ||||
|  | ||||
|   const subscribe = async (e) => { | ||||
|     e.preventDefault() | ||||
|  | ||||
|     const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, { | ||||
|       body: JSON.stringify({ | ||||
|         email: inputEl.current.value, | ||||
|       }), | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|       }, | ||||
|       method: 'POST', | ||||
|     }) | ||||
|  | ||||
|     const { error } = await res.json() | ||||
|     if (error) { | ||||
|       setError(true) | ||||
|       setMessage('Your e-mail address is invalid or you are already subscribed!') | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     inputEl.current.value = '' | ||||
|     setError(false) | ||||
|     setSubscribed(true) | ||||
|     setMessage('Successfully! 🎉 You are now subscribed.') | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <div className="pb-1 text-lg font-semibold text-gray-800 dark:text-gray-100">{title}</div> | ||||
|       <form className="flex flex-col sm:flex-row" onSubmit={subscribe}> | ||||
|         <div> | ||||
|           <label className="sr-only" htmlFor="email-input"> | ||||
|             Email address | ||||
|           </label> | ||||
|           <input | ||||
|             autoComplete="email" | ||||
|             className="w-72 rounded-md px-4 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600 dark:bg-black" | ||||
|             id="email-input" | ||||
|             name="email" | ||||
|             placeholder={subscribed ? "You're subscribed !  🎉" : 'Enter your email'} | ||||
|             ref={inputEl} | ||||
|             required | ||||
|             type="email" | ||||
|             disabled={subscribed} | ||||
|           /> | ||||
|         </div> | ||||
|         <div className="mt-2 flex w-full rounded-md shadow-sm sm:mt-0 sm:ml-3"> | ||||
|           <button | ||||
|             className={`w-full rounded-md bg-primary-500 py-2 px-4 font-medium text-white sm:py-0 ${ | ||||
|               subscribed ? 'cursor-default' : 'hover:bg-primary-700 dark:hover:bg-primary-400' | ||||
|             } focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2 dark:ring-offset-black`} | ||||
|             type="submit" | ||||
|             disabled={subscribed} | ||||
|           > | ||||
|             {subscribed ? 'Thank you!' : 'Sign up'} | ||||
|           </button> | ||||
|         </div> | ||||
|       </form> | ||||
|       {error && ( | ||||
|         <div className="w-72 pt-2 text-sm text-red-500 dark:text-red-400 sm:w-96">{message}</div> | ||||
|       )} | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default NewsletterForm | ||||
|  | ||||
| export const BlogNewsletterForm = ({ title }) => ( | ||||
|   <div className="flex items-center justify-center"> | ||||
|     <div className="bg-gray-100 p-6 dark:bg-gray-800 sm:px-14 sm:py-8"> | ||||
|       <NewsletterForm title={title} /> | ||||
|     </div> | ||||
|   </div> | ||||
| ) | ||||
| @@ -1,4 +1,10 @@ | ||||
| export default function PageTitle({ children }) { | ||||
| import { ReactNode } from 'react' | ||||
| 
 | ||||
| interface Props { | ||||
|   children: ReactNode | ||||
| } | ||||
| 
 | ||||
| export default function PageTitle({ children }: Props) { | ||||
|   return ( | ||||
|     <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-5xl md:leading-14"> | ||||
|       {children} | ||||
| @@ -1,36 +0,0 @@ | ||||
| import Link from '@/components/Link' | ||||
|  | ||||
| export default function Pagination({ totalPages, currentPage }) { | ||||
|   const prevPage = parseInt(currentPage) - 1 > 0 | ||||
|   const nextPage = parseInt(currentPage) + 1 <= parseInt(totalPages) | ||||
|  | ||||
|   return ( | ||||
|     <div className="space-y-2 pt-6 pb-8 md:space-y-5"> | ||||
|       <nav className="flex justify-between"> | ||||
|         {!prevPage && ( | ||||
|           <button rel="previous" className="cursor-auto disabled:opacity-50" disabled={!prevPage}> | ||||
|             Previous | ||||
|           </button> | ||||
|         )} | ||||
|         {prevPage && ( | ||||
|           <Link href={currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`}> | ||||
|             <button rel="previous">Previous</button> | ||||
|           </Link> | ||||
|         )} | ||||
|         <span> | ||||
|           {currentPage} of {totalPages} | ||||
|         </span> | ||||
|         {!nextPage && ( | ||||
|           <button rel="next" className="cursor-auto disabled:opacity-50" disabled={!nextPage}> | ||||
|             Next | ||||
|           </button> | ||||
|         )} | ||||
|         {nextPage && ( | ||||
|           <Link href={`/blog/page/${currentPage + 1}`}> | ||||
|             <button rel="next">Next</button> | ||||
|           </Link> | ||||
|         )} | ||||
|       </nav> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| @@ -1,71 +0,0 @@ | ||||
| import { useState, useRef } from 'react' | ||||
|  | ||||
| const Pre = (props) => { | ||||
|   const textInput = useRef(null) | ||||
|   const [hovered, setHovered] = useState(false) | ||||
|   const [copied, setCopied] = useState(false) | ||||
|  | ||||
|   const onEnter = () => { | ||||
|     setHovered(true) | ||||
|   } | ||||
|   const onExit = () => { | ||||
|     setHovered(false) | ||||
|     setCopied(false) | ||||
|   } | ||||
|   const onCopy = () => { | ||||
|     setCopied(true) | ||||
|     navigator.clipboard.writeText(textInput.current.textContent) | ||||
|     setTimeout(() => { | ||||
|       setCopied(false) | ||||
|     }, 2000) | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div ref={textInput} onMouseEnter={onEnter} onMouseLeave={onExit} className="relative"> | ||||
|       {hovered && ( | ||||
|         <button | ||||
|           aria-label="Copy code" | ||||
|           type="button" | ||||
|           className={`absolute right-2 top-2 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${ | ||||
|             copied | ||||
|               ? 'border-green-400 focus:border-green-400 focus:outline-none' | ||||
|               : 'border-gray-300' | ||||
|           }`} | ||||
|           onClick={onCopy} | ||||
|         > | ||||
|           <svg | ||||
|             xmlns="http://www.w3.org/2000/svg" | ||||
|             viewBox="0 0 24 24" | ||||
|             stroke="currentColor" | ||||
|             fill="none" | ||||
|             className={copied ? 'text-green-400' : 'text-gray-300'} | ||||
|           > | ||||
|             {copied ? ( | ||||
|               <> | ||||
|                 <path | ||||
|                   strokeLinecap="round" | ||||
|                   strokeLinejoin="round" | ||||
|                   strokeWidth={2} | ||||
|                   d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" | ||||
|                 /> | ||||
|               </> | ||||
|             ) : ( | ||||
|               <> | ||||
|                 <path | ||||
|                   strokeLinecap="round" | ||||
|                   strokeLinejoin="round" | ||||
|                   strokeWidth={2} | ||||
|                   d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" | ||||
|                 /> | ||||
|               </> | ||||
|             )} | ||||
|           </svg> | ||||
|         </button> | ||||
|       )} | ||||
|  | ||||
|       <pre>{props.children}</pre> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Pre | ||||
| @@ -1,8 +1,30 @@ | ||||
| import Head from 'next/head' | ||||
| import { useRouter } from 'next/router' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import { CoreContent } from 'pliny/utils/contentlayer' | ||||
| import type { Blog, Authors } from 'contentlayer/generated' | ||||
| interface CommonSEOProps { | ||||
|   title: string | ||||
|   description: string | ||||
|   ogType: string | ||||
|   ogImage: | ||||
|     | string | ||||
|     | { | ||||
|         '@type': string | ||||
|         url: string | ||||
|       }[] | ||||
|   twImage: string | ||||
|   canonicalUrl?: string | ||||
| } | ||||
| 
 | ||||
| const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl }) => { | ||||
| const CommonSEO = ({ | ||||
|   title, | ||||
|   description, | ||||
|   ogType, | ||||
|   ogImage, | ||||
|   twImage, | ||||
|   canonicalUrl, | ||||
| }: CommonSEOProps) => { | ||||
|   const router = useRouter() | ||||
|   return ( | ||||
|     <Head> | ||||
| @@ -14,7 +36,7 @@ const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl | ||||
|       <meta property="og:site_name" content={siteMetadata.title} /> | ||||
|       <meta property="og:description" content={description} /> | ||||
|       <meta property="og:title" content={title} /> | ||||
|       {ogImage.constructor.name === 'Array' ? ( | ||||
|       {Array.isArray(ogImage) ? ( | ||||
|         ogImage.map(({ url }) => <meta property="og:image" content={url} key={url} />) | ||||
|       ) : ( | ||||
|         <meta property="og:image" content={ogImage} key={ogImage} /> | ||||
| @@ -32,7 +54,12 @@ const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export const PageSEO = ({ title, description }) => { | ||||
| interface PageSEOProps { | ||||
|   title: string | ||||
|   description: string | ||||
| } | ||||
| 
 | ||||
| export const PageSEO = ({ title, description }: PageSEOProps) => { | ||||
|   const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner | ||||
|   const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner | ||||
|   return ( | ||||
| @@ -46,7 +73,7 @@ export const PageSEO = ({ title, description }) => { | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export const TagSEO = ({ title, description }) => { | ||||
| export const TagSEO = ({ title, description }: PageSEOProps) => { | ||||
|   const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner | ||||
|   const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner | ||||
|   const router = useRouter() | ||||
| @@ -71,6 +98,11 @@ export const TagSEO = ({ title, description }) => { | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| interface BlogSeoProps extends CoreContent<Blog> { | ||||
|   authorDetails?: CoreContent<Authors>[] | ||||
|   url: string | ||||
| } | ||||
| 
 | ||||
| export const BlogSEO = ({ | ||||
|   authorDetails, | ||||
|   title, | ||||
| @@ -80,11 +112,10 @@ export const BlogSEO = ({ | ||||
|   url, | ||||
|   images = [], | ||||
|   canonicalUrl, | ||||
| }) => { | ||||
|   const router = useRouter() | ||||
| }: BlogSeoProps) => { | ||||
|   const publishedAt = new Date(date).toISOString() | ||||
|   const modifiedAt = new Date(lastmod || date).toISOString() | ||||
|   let imagesArr = | ||||
|   const imagesArr = | ||||
|     images.length === 0 | ||||
|       ? [siteMetadata.socialBanner] | ||||
|       : typeof images === 'string' | ||||
| @@ -142,7 +173,7 @@ export const BlogSEO = ({ | ||||
|     <> | ||||
|       <CommonSEO | ||||
|         title={title} | ||||
|         description={summary} | ||||
|         description={summary || ''} | ||||
|         ogType="article" | ||||
|         ogImage={featuredImages} | ||||
|         twImage={twImageUrl} | ||||
| @@ -18,16 +18,15 @@ const ScrollTopAndComment = () => { | ||||
|     window.scrollTo({ top: 0 }) | ||||
|   } | ||||
|   const handleScrollToComment = () => { | ||||
|     document.getElementById('comment').scrollIntoView() | ||||
|     document.getElementById('comment')?.scrollIntoView() | ||||
|   } | ||||
|   return ( | ||||
|     <div | ||||
|       className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`} | ||||
|       className={`fixed bottom-8 right-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`} | ||||
|     > | ||||
|       {siteMetadata.comment.provider && ( | ||||
|       {siteMetadata.comments?.provider && ( | ||||
|         <button | ||||
|           aria-label="Scroll To Comment" | ||||
|           type="button" | ||||
|           onClick={handleScrollToComment} | ||||
|           className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600" | ||||
|         > | ||||
| @@ -42,7 +41,6 @@ const ScrollTopAndComment = () => { | ||||
|       )} | ||||
|       <button | ||||
|         aria-label="Scroll To Top" | ||||
|         type="button" | ||||
|         onClick={handleScrollTop} | ||||
|         className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600" | ||||
|       > | ||||
| @@ -1,3 +0,0 @@ | ||||
| export default function SectionContainer({ children }) { | ||||
|   return <div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</div> | ||||
| } | ||||
							
								
								
									
										11
									
								
								components/SectionContainer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								components/SectionContainer.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { ReactNode } from 'react' | ||||
|  | ||||
| interface Props { | ||||
|   children: ReactNode | ||||
| } | ||||
|  | ||||
| export default function SectionContainer({ children }: Props) { | ||||
|   return ( | ||||
|     <section className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</section> | ||||
|   ) | ||||
| } | ||||
| @@ -1,64 +0,0 @@ | ||||
| /** | ||||
|  * @typedef TocHeading | ||||
|  * @prop {string} value | ||||
|  * @prop {number} depth | ||||
|  * @prop {string} url | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * Generates an inline table of contents | ||||
|  * Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')). | ||||
|  * If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')). | ||||
|  * | ||||
|  * @param {{ | ||||
|  *  toc: TocHeading[], | ||||
|  *  indentDepth?: number, | ||||
|  *  fromHeading?: number, | ||||
|  *  toHeading?: number, | ||||
|  *  asDisclosure?: boolean, | ||||
|  *  exclude?: string|string[] | ||||
|  * }} props | ||||
|  * | ||||
|  */ | ||||
| const TOCInline = ({ | ||||
|   toc, | ||||
|   indentDepth = 3, | ||||
|   fromHeading = 1, | ||||
|   toHeading = 6, | ||||
|   asDisclosure = false, | ||||
|   exclude = '', | ||||
| }) => { | ||||
|   const re = Array.isArray(exclude) | ||||
|     ? new RegExp('^(' + exclude.join('|') + ')$', 'i') | ||||
|     : new RegExp('^(' + exclude + ')$', 'i') | ||||
|  | ||||
|   const filteredToc = toc.filter( | ||||
|     (heading) => | ||||
|       heading.depth >= fromHeading && heading.depth <= toHeading && !re.test(heading.value) | ||||
|   ) | ||||
|  | ||||
|   const tocList = ( | ||||
|     <ul> | ||||
|       {filteredToc.map((heading) => ( | ||||
|         <li key={heading.value} className={`${heading.depth >= indentDepth && 'ml-6'}`}> | ||||
|           <a href={heading.url}>{heading.value}</a> | ||||
|         </li> | ||||
|       ))} | ||||
|     </ul> | ||||
|   ) | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {asDisclosure ? ( | ||||
|         <details open> | ||||
|           <summary className="ml-6 pt-2 pb-2 text-xl font-bold">Table of Contents</summary> | ||||
|           <div className="ml-6">{tocList}</div> | ||||
|         </details> | ||||
|       ) : ( | ||||
|         tocList | ||||
|       )} | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default TOCInline | ||||
| @@ -1,14 +0,0 @@ | ||||
| import Link from 'next/link' | ||||
| import kebabCase from '@/lib/utils/kebabCase' | ||||
|  | ||||
| const Tag = ({ text }) => { | ||||
|   return ( | ||||
|     <Link href={`/tags/${kebabCase(text)}`}> | ||||
|       <a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"> | ||||
|         {text.split(' ').join('-')} | ||||
|       </a> | ||||
|     </Link> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Tag | ||||
							
								
								
									
										19
									
								
								components/Tag.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								components/Tag.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import Link from 'next/link' | ||||
| import { kebabCase } from 'pliny/utils/kebabCase' | ||||
|  | ||||
| interface Props { | ||||
|   text: string | ||||
| } | ||||
|  | ||||
| const Tag = ({ text }: Props) => { | ||||
|   return ( | ||||
|     <Link | ||||
|       href={`/tags/${kebabCase(text)}`} | ||||
|       className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" | ||||
|     > | ||||
|       {text.split(' ').join('-')} | ||||
|     </Link> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Tag | ||||
| @@ -1,19 +1,24 @@ | ||||
| 'use client' | ||||
| 
 | ||||
| import { useEffect, useState } from 'react' | ||||
| import { useTheme } from 'next-themes' | ||||
| 
 | ||||
| const ThemeSwitch = () => { | ||||
|   const [mounted, setMounted] = useState(false) | ||||
|   const { theme, setTheme, resolvedTheme } = useTheme() | ||||
|   const { theme, setTheme } = useTheme() | ||||
| 
 | ||||
|   // When mounted on client, now we can show the UI
 | ||||
|   useEffect(() => setMounted(true), []) | ||||
| 
 | ||||
|   if (!mounted) { | ||||
|     return null | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <button | ||||
|       aria-label="Toggle Dark Mode" | ||||
|       type="button" | ||||
|       className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4" | ||||
|       onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')} | ||||
|       onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} | ||||
|     > | ||||
|       <svg | ||||
|         xmlns="http://www.w3.org/2000/svg" | ||||
| @@ -21,7 +26,7 @@ const ThemeSwitch = () => { | ||||
|         fill="currentColor" | ||||
|         className="text-gray-900 dark:text-gray-100" | ||||
|       > | ||||
|         {mounted && (theme === 'dark' || resolvedTheme === 'dark') ? ( | ||||
|         {mounted && theme === 'dark' ? ( | ||||
|           <path | ||||
|             fillRule="evenodd" | ||||
|             d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" | ||||
| @@ -1,36 +0,0 @@ | ||||
| import Script from 'next/script' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const GAScript = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <Script | ||||
|         strategy="lazyOnload" | ||||
|         src={`https://www.googletagmanager.com/gtag/js?id=${siteMetadata.analytics.googleAnalyticsId}`} | ||||
|       /> | ||||
|  | ||||
|       <Script strategy="lazyOnload" id="ga-script"> | ||||
|         {` | ||||
|             window.dataLayer = window.dataLayer || []; | ||||
|             function gtag(){dataLayer.push(arguments);} | ||||
|             gtag('js', new Date()); | ||||
|             gtag('config', '${siteMetadata.analytics.googleAnalyticsId}', { | ||||
|               page_path: window.location.pathname, | ||||
|             }); | ||||
|         `} | ||||
|       </Script> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default GAScript | ||||
|  | ||||
| // https://developers.google.com/analytics/devguides/collection/gtagjs/events | ||||
| export const logEvent = (action, category, label, value) => { | ||||
|   window.gtag?.('event', action, { | ||||
|     event_category: category, | ||||
|     event_label: label, | ||||
|     value: value, | ||||
|   }) | ||||
| } | ||||
| @@ -1,27 +0,0 @@ | ||||
| import Script from 'next/script' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const PlausibleScript = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <Script | ||||
|         strategy="lazyOnload" | ||||
|         data-domain={siteMetadata.analytics.plausibleDataDomain} | ||||
|         src="https://plausible.io/js/plausible.js" | ||||
|       /> | ||||
|       <Script strategy="lazyOnload" id="plausible-script"> | ||||
|         {` | ||||
|             window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) } | ||||
|         `} | ||||
|       </Script> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default PlausibleScript | ||||
|  | ||||
| // https://plausible.io/docs/custom-event-goals | ||||
| export const logEvent = (eventName, ...rest) => { | ||||
|   return window.plausible?.(eventName, ...rest) | ||||
| } | ||||
| @@ -1,18 +0,0 @@ | ||||
| import Script from 'next/script' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const PosthogScript = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <Script strategy="lazyOnload" id="posthog-script"> | ||||
|         {` | ||||
|             !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]); | ||||
|             posthog.init('${siteMetadata.analytics.posthogAnalyticsId}',{api_host:'https://app.posthog.com'}) | ||||
|         `} | ||||
|       </Script> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default PosthogScript | ||||
| @@ -1,25 +0,0 @@ | ||||
| import Script from 'next/script' | ||||
|  | ||||
| const SimpleAnalyticsScript = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <Script strategy="lazyOnload" id="sa-script"> | ||||
|         {` | ||||
|             window.sa_event=window.sa_event||function(){var a=[].slice.call(arguments);window.sa_event.q?window.sa_event.q.push(a):window.sa_event.q=[a]}; | ||||
|         `} | ||||
|       </Script> | ||||
|       <Script strategy="lazyOnload" src="https://scripts.simpleanalyticscdn.com/latest.js" /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| // https://docs.simpleanalytics.com/events | ||||
| export const logEvent = (eventName, callback) => { | ||||
|   if (callback) { | ||||
|     return window.sa_event?.(eventName, callback) | ||||
|   } else { | ||||
|     return window.sa_event?.(eventName) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default SimpleAnalyticsScript | ||||
| @@ -1,18 +0,0 @@ | ||||
| import Script from 'next/script' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const UmamiScript = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <Script | ||||
|         async | ||||
|         defer | ||||
|         data-website-id={siteMetadata.analytics.umamiWebsiteId} | ||||
|         src="https://umami.example.com/umami.js" // Replace with your umami instance | ||||
|       /> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default UmamiScript | ||||
| @@ -1,22 +0,0 @@ | ||||
| import GA from './GoogleAnalytics' | ||||
| import Plausible from './Plausible' | ||||
| import SimpleAnalytics from './SimpleAnalytics' | ||||
| import Umami from './Umami' | ||||
| import Posthog from './Posthog' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const isProduction = process.env.NODE_ENV === 'production' | ||||
|  | ||||
| const Analytics = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       {isProduction && siteMetadata.analytics.plausibleDataDomain && <Plausible />} | ||||
|       {isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />} | ||||
|       {isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />} | ||||
|       {isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />} | ||||
|       {isProduction && siteMetadata.analytics.posthogAnalyticsId && <Posthog />} | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Analytics | ||||
| @@ -1,37 +0,0 @@ | ||||
| import React, { useState } from 'react' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const Disqus = ({ frontMatter }) => { | ||||
|   const [enableLoadComments, setEnabledLoadComments] = useState(true) | ||||
|  | ||||
|   const COMMENTS_ID = 'disqus_thread' | ||||
|  | ||||
|   function LoadComments() { | ||||
|     setEnabledLoadComments(false) | ||||
|  | ||||
|     window.disqus_config = function () { | ||||
|       this.page.url = window.location.href | ||||
|       this.page.identifier = frontMatter.slug | ||||
|     } | ||||
|     if (window.DISQUS === undefined) { | ||||
|       const script = document.createElement('script') | ||||
|       script.src = 'https://' + siteMetadata.comment.disqusConfig.shortname + '.disqus.com/embed.js' | ||||
|       script.setAttribute('data-timestamp', +new Date()) | ||||
|       script.setAttribute('crossorigin', 'anonymous') | ||||
|       script.async = true | ||||
|       document.body.appendChild(script) | ||||
|     } else { | ||||
|       window.DISQUS.reset({ reload: true }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300"> | ||||
|       {enableLoadComments && <button onClick={LoadComments}>Load Comments</button>} | ||||
|       <div className="disqus-frame" id={COMMENTS_ID} /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Disqus | ||||
| @@ -1,72 +0,0 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react' | ||||
| import { useTheme } from 'next-themes' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const Giscus = () => { | ||||
|   const [enableLoadComments, setEnabledLoadComments] = useState(true) | ||||
|   const { theme, resolvedTheme } = useTheme() | ||||
|   const commentsTheme = | ||||
|     siteMetadata.comment.giscusConfig.themeURL === '' | ||||
|       ? theme === 'dark' || resolvedTheme === 'dark' | ||||
|         ? siteMetadata.comment.giscusConfig.darkTheme | ||||
|         : siteMetadata.comment.giscusConfig.theme | ||||
|       : siteMetadata.comment.giscusConfig.themeURL | ||||
|  | ||||
|   const COMMENTS_ID = 'comments-container' | ||||
|  | ||||
|   const LoadComments = useCallback(() => { | ||||
|     setEnabledLoadComments(false) | ||||
|  | ||||
|     const { | ||||
|       repo, | ||||
|       repositoryId, | ||||
|       category, | ||||
|       categoryId, | ||||
|       mapping, | ||||
|       reactions, | ||||
|       metadata, | ||||
|       inputPosition, | ||||
|       lang, | ||||
|     } = siteMetadata?.comment?.giscusConfig | ||||
|  | ||||
|     const script = document.createElement('script') | ||||
|     script.src = 'https://giscus.app/client.js' | ||||
|     script.setAttribute('data-repo', repo) | ||||
|     script.setAttribute('data-repo-id', repositoryId) | ||||
|     script.setAttribute('data-category', category) | ||||
|     script.setAttribute('data-category-id', categoryId) | ||||
|     script.setAttribute('data-mapping', mapping) | ||||
|     script.setAttribute('data-reactions-enabled', reactions) | ||||
|     script.setAttribute('data-emit-metadata', metadata) | ||||
|     script.setAttribute('data-input-position', inputPosition) | ||||
|     script.setAttribute('data-lang', lang) | ||||
|     script.setAttribute('data-theme', commentsTheme) | ||||
|     script.setAttribute('crossorigin', 'anonymous') | ||||
|     script.async = true | ||||
|  | ||||
|     const comments = document.getElementById(COMMENTS_ID) | ||||
|     if (comments) comments.appendChild(script) | ||||
|  | ||||
|     return () => { | ||||
|       const comments = document.getElementById(COMMENTS_ID) | ||||
|       if (comments) comments.innerHTML = '' | ||||
|     } | ||||
|   }, [commentsTheme]) | ||||
|  | ||||
|   // Reload on theme change | ||||
|   useEffect(() => { | ||||
|     const iframe = document.querySelector('iframe.giscus-frame') | ||||
|     if (!iframe) return | ||||
|     LoadComments() | ||||
|   }, [LoadComments]) | ||||
|  | ||||
|   return ( | ||||
|     <div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300"> | ||||
|       {enableLoadComments && <button onClick={LoadComments}>Load Comments</button>} | ||||
|       <div className="giscus" id={COMMENTS_ID} /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Giscus | ||||
| @@ -1,52 +0,0 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react' | ||||
| import { useTheme } from 'next-themes' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const Utterances = () => { | ||||
|   const [enableLoadComments, setEnabledLoadComments] = useState(true) | ||||
|   const { theme, resolvedTheme } = useTheme() | ||||
|   const commentsTheme = | ||||
|     theme === 'dark' || resolvedTheme === 'dark' | ||||
|       ? siteMetadata.comment.utterancesConfig.darkTheme | ||||
|       : siteMetadata.comment.utterancesConfig.theme | ||||
|  | ||||
|   const COMMENTS_ID = 'comments-container' | ||||
|  | ||||
|   const LoadComments = useCallback(() => { | ||||
|     setEnabledLoadComments(false) | ||||
|     const script = document.createElement('script') | ||||
|     script.src = 'https://utteranc.es/client.js' | ||||
|     script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo) | ||||
|     script.setAttribute('issue-term', siteMetadata.comment.utterancesConfig.issueTerm) | ||||
|     script.setAttribute('label', siteMetadata.comment.utterancesConfig.label) | ||||
|     script.setAttribute('theme', commentsTheme) | ||||
|     script.setAttribute('crossorigin', 'anonymous') | ||||
|     script.async = true | ||||
|  | ||||
|     const comments = document.getElementById(COMMENTS_ID) | ||||
|     if (comments) comments.appendChild(script) | ||||
|  | ||||
|     return () => { | ||||
|       const comments = document.getElementById(COMMENTS_ID) | ||||
|       if (comments) comments.innerHTML = '' | ||||
|     } | ||||
|   }, [commentsTheme]) | ||||
|  | ||||
|   // Reload on theme change | ||||
|   useEffect(() => { | ||||
|     const iframe = document.querySelector('iframe.utterances-frame') | ||||
|     if (!iframe) return | ||||
|     LoadComments() | ||||
|   }, [LoadComments]) | ||||
|  | ||||
|   // Added `relative` to fix a weird bug with `utterances-frame` position | ||||
|   return ( | ||||
|     <div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300"> | ||||
|       {enableLoadComments && <button onClick={LoadComments}>Load Comments</button>} | ||||
|       <div className="utterances-frame relative" id={COMMENTS_ID} /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Utterances | ||||
| @@ -1,39 +0,0 @@ | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import dynamic from 'next/dynamic' | ||||
|  | ||||
| const UtterancesComponent = dynamic( | ||||
|   () => { | ||||
|     return import('@/components/comments/Utterances') | ||||
|   }, | ||||
|   { ssr: false } | ||||
| ) | ||||
| const GiscusComponent = dynamic( | ||||
|   () => { | ||||
|     return import('@/components/comments/Giscus') | ||||
|   }, | ||||
|   { ssr: false } | ||||
| ) | ||||
| const DisqusComponent = dynamic( | ||||
|   () => { | ||||
|     return import('@/components/comments/Disqus') | ||||
|   }, | ||||
|   { ssr: false } | ||||
| ) | ||||
|  | ||||
| const Comments = ({ frontMatter }) => { | ||||
|   const comment = siteMetadata?.comment | ||||
|   if (!comment || Object.keys(comment).length === 0) return <></> | ||||
|   return ( | ||||
|     <div id="comment"> | ||||
|       {siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && <GiscusComponent />} | ||||
|       {siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && ( | ||||
|         <UtterancesComponent /> | ||||
|       )} | ||||
|       {siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && ( | ||||
|         <DisqusComponent frontMatter={frontMatter} /> | ||||
|       )} | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default Comments | ||||
							
								
								
									
										99
									
								
								contentlayer.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								contentlayer.config.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer/source-files' | ||||
| import readingTime from 'reading-time' | ||||
| import path from 'path' | ||||
| // Remark packages | ||||
| import remarkGfm from 'remark-gfm' | ||||
| import remarkMath from 'remark-math' | ||||
| import { | ||||
|   remarkExtractFrontmatter, | ||||
|   remarkCodeTitles, | ||||
|   remarkImgToJsx, | ||||
|   extractTocHeadings, | ||||
| } from 'pliny/mdx-plugins.js' | ||||
| // Rehype packages | ||||
| import rehypeSlug from 'rehype-slug' | ||||
| import rehypeAutolinkHeadings from 'rehype-autolink-headings' | ||||
| import rehypeKatex from 'rehype-katex' | ||||
| import rehypeCitation from 'rehype-citation' | ||||
| import rehypePrismPlus from 'rehype-prism-plus' | ||||
| import rehypePresetMinify from 'rehype-preset-minify' | ||||
|  | ||||
| const root = process.cwd() | ||||
|  | ||||
| const computedFields: ComputedFields = { | ||||
|   readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) }, | ||||
|   slug: { | ||||
|     type: 'string', | ||||
|     resolve: (doc) => doc._raw.flattenedPath.replace(/^.+?(\/)/, ''), | ||||
|   }, | ||||
|   path: { | ||||
|     type: 'string', | ||||
|     resolve: (doc) => doc._raw.flattenedPath, | ||||
|   }, | ||||
|   filePath: { | ||||
|     type: 'string', | ||||
|     resolve: (doc) => doc._raw.sourceFilePath, | ||||
|   }, | ||||
|   toc: { type: 'string', resolve: (doc) => extractTocHeadings(doc.body.raw) }, | ||||
| } | ||||
|  | ||||
| export const Blog = defineDocumentType(() => ({ | ||||
|   name: 'Blog', | ||||
|   filePathPattern: 'blog/**/*.mdx', | ||||
|   contentType: 'mdx', | ||||
|   fields: { | ||||
|     title: { type: 'string', required: true }, | ||||
|     date: { type: 'date', required: true }, | ||||
|     tags: { type: 'list', of: { type: 'string' } }, | ||||
|     lastmod: { type: 'date' }, | ||||
|     draft: { type: 'boolean' }, | ||||
|     summary: { type: 'string' }, | ||||
|     images: { type: 'list', of: { type: 'string' } }, | ||||
|     authors: { type: 'list', of: { type: 'string' } }, | ||||
|     layout: { type: 'string' }, | ||||
|     bibliography: { type: 'string' }, | ||||
|     canonicalUrl: { type: 'string' }, | ||||
|   }, | ||||
|   computedFields, | ||||
| })) | ||||
|  | ||||
| export const Authors = defineDocumentType(() => ({ | ||||
|   name: 'Authors', | ||||
|   filePathPattern: 'authors/**/*.mdx', | ||||
|   contentType: 'mdx', | ||||
|   fields: { | ||||
|     name: { type: 'string', required: true }, | ||||
|     avatar: { type: 'string' }, | ||||
|     occupation: { type: 'string' }, | ||||
|     company: { type: 'string' }, | ||||
|     email: { type: 'string' }, | ||||
|     twitter: { type: 'string' }, | ||||
|     linkedin: { type: 'string' }, | ||||
|     github: { type: 'string' }, | ||||
|     layout: { type: 'string' }, | ||||
|   }, | ||||
|   computedFields, | ||||
| })) | ||||
|  | ||||
| export default makeSource({ | ||||
|   contentDirPath: 'data', | ||||
|   documentTypes: [Blog, Authors], | ||||
|   mdx: { | ||||
|     cwd: process.cwd(), | ||||
|     remarkPlugins: [ | ||||
|       remarkExtractFrontmatter, | ||||
|       remarkGfm, | ||||
|       remarkCodeTitles, | ||||
|       remarkMath, | ||||
|       remarkImgToJsx, | ||||
|     ], | ||||
|     rehypePlugins: [ | ||||
|       rehypeSlug, | ||||
|       rehypeAutolinkHeadings, | ||||
|       rehypeKatex, | ||||
|       [rehypeCitation, { path: path.join(root, 'data') }], | ||||
|       [rehypePrismPlus, { defaultLanguage: 'js' }], | ||||
|       rehypePresetMinify, | ||||
|     ], | ||||
|   }, | ||||
| }) | ||||
							
								
								
									
										63
									
								
								css/docsearch.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								css/docsearch.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
|  | ||||
| .light .DocSearch { | ||||
|     --docsearch-primary-color: theme(colors.primary.600); | ||||
|     --docsearch-highlight-color: theme(colors.primary.600); | ||||
|     --docsearch-searchbox-shadow: inset 0 0 0 2px theme(colors.primary.600); | ||||
|     --docsearch-muted-color: theme(colors.gray.500); | ||||
|     --docsearch-container-background: theme(colors.gray.400 / 80%); | ||||
|     /* Modal */ | ||||
|     --docsearch-modal-background: theme(colors.gray.200); | ||||
|     /* Search box */ | ||||
|     --docsearch-searchbox-background: theme(colors.gray.100); | ||||
|     --docsearch-searchbox-focus-background: theme(colors.gray.100); | ||||
|     /* Hit */ | ||||
|     --docsearch-hit-color: theme(colors.gray.700); | ||||
|     --docsearch-hit-shadow: none; | ||||
|     --docsearch-hit-active-color: theme(colors.gray.800); | ||||
|     --docsearch-hit-background: theme(colors.gray.100); | ||||
|     /* Footer */ | ||||
|     --docsearch-footer-background: theme(colors.gray.100); | ||||
| } | ||||
|  | ||||
| .dark .DocSearch { | ||||
|     --docsearch-primary-color: theme(colors.primary.600); | ||||
|     --docsearch-highlight-color: theme(colors.primary.600); | ||||
|     --docsearch-searchbox-shadow: inset 0 0 0 2px theme(colors.primary.600); | ||||
|     --docsearch-text-color: theme(colors.gray.200); | ||||
|     --docsearch-muted-color: theme(colors.gray.400); | ||||
|     --docsearch-container-background: theme(colors.gray.900 / 80%); | ||||
|     /* Modal */ | ||||
|     --docsearch-modal-background: theme(colors.gray.900); | ||||
|     --docsearch-modal-shadow: inset 1px 1px 0 0 rgb(44, 46, 64), | ||||
|             0 3px 8px 0 rgb(0, 3, 9); | ||||
|     /* Search box */ | ||||
|     --docsearch-searchbox-background: theme(colors.gray.800); | ||||
|     --docsearch-searchbox-focus-background: theme(colors.gray.800); | ||||
|     /* Hit */ | ||||
|     --docsearch-hit-color: theme(colors.gray.200); | ||||
|     --docsearch-hit-shadow: none; | ||||
|     --docsearch-hit-active-color: theme(colors.gray.100); | ||||
|     --docsearch-hit-background: theme(colors.gray.800); | ||||
|     /* Footer */ | ||||
|     --docsearch-footer-background: theme(colors.gray.900); | ||||
|     --docsearch-footer-shadow: inset 0 1px 0 0 rgba(73, 76, 106, 0.5), | ||||
|             0 -4px 8px 0 rgba(0, 0, 0, 0.2); | ||||
|     --docsearch-key-gradient: linear-gradient(-26.5deg, | ||||
|             theme(colors.gray.800) 0%, | ||||
|             theme(colors.gray.900) 100%); | ||||
|     --docsearch-key-shadow: inset 0 -2px 0 0 rgb(40, 45, 85), | ||||
|     inset 0 0 1px 1px rgb(81, 87, 125), 0 2px 2px 0 rgba(3, 4, 9, 0.3); | ||||
|     --docsearch-logo-color: theme(colors.gray.300); | ||||
| } | ||||
|  | ||||
| .light .DocSearch-Input { | ||||
|     @apply hover:ring-0 ring-0; | ||||
| } | ||||
|  | ||||
| .dark .DocSearch-Input { | ||||
|     @apply hover:ring-0 ring-0; | ||||
| } | ||||
|  | ||||
| .DocSearch-Container { | ||||
|     @apply backdrop-blur; | ||||
| } | ||||
| @@ -32,7 +32,7 @@ | ||||
| } | ||||
|  | ||||
| .highlight-line { | ||||
|   @apply -mx-4 border-l-4 border-primary-500  bg-gray-700 bg-opacity-50; | ||||
|   @apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50; | ||||
| } | ||||
|  | ||||
| .line-number::before { | ||||
| @@ -138,3 +138,7 @@ | ||||
| .token.table { | ||||
|   display: inline; | ||||
| } | ||||
|  | ||||
| .token.table { | ||||
|   display: inline; | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,11 @@ | ||||
| } | ||||
|  | ||||
| .footnotes { | ||||
|   @apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700; | ||||
|   @apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700; | ||||
| } | ||||
|  | ||||
| .data-footnote-backref { | ||||
|   @apply no-underline; | ||||
| } | ||||
|  | ||||
| .csl-entry { | ||||
|   | ||||
| @@ -10,7 +10,9 @@ summary: 'How to derive the OLS Estimator with matrix notation and a tour of mat | ||||
|  | ||||
| Parsing and display of math equations is included in this blog template. Parsing of math is enabled by `remark-math` and `rehype-katex`. | ||||
| KaTeX and its associated font is included in `_document.js` so feel free to use it on any page. | ||||
| ^[For the full list of supported TeX functions, check out the [KaTeX documentation](https://katex.org/docs/supported.html)] | ||||
| [^footnote] | ||||
|  | ||||
| [^footnote]: For the full list of supported TeX functions, check out the [KaTeX documentation](https://katex.org/docs/supported.html) | ||||
|  | ||||
| Inline math symbols can be included by enclosing the term between the `$` symbol. | ||||
|  | ||||
|   | ||||
| @@ -49,12 +49,8 @@ _Note_: If you try to save the image, it is in webp format, if your browser supp | ||||
|  | ||||
|  | ||||
|  | ||||
| <p> | ||||
|   Photo by [YUCAR | ||||
|   FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) | ||||
|   on | ||||
|   [Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) | ||||
| </p> | ||||
| Photo by [YUCAR FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) | ||||
| on [Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) | ||||
|  | ||||
| # Benefits | ||||
|  | ||||
|   | ||||
| @@ -60,7 +60,7 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea | ||||
| - Blog templates | ||||
| - TOC component | ||||
| - Support for nested routing of blog posts | ||||
| - Newsletter component with support for mailchimp, buttondown, convertkit, klaviyo, revue, and emailoctopus | ||||
| - Newsletter component with support for mailchimp, buttondown and convertkit | ||||
| - Supports [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus | ||||
| - Projects page | ||||
| - Preconfigured security headers | ||||
| @@ -77,27 +77,7 @@ I wanted it to be nearly as feature-rich as popular blogging templates like [bea | ||||
|  | ||||
| ## Quick Start Guide | ||||
|  | ||||
| 1. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny): | ||||
|  | ||||
| ```bash | ||||
| npm i -g @pliny/cli | ||||
| pliny new --template=starter-blog my-blog | ||||
| ``` | ||||
|  | ||||
| It supports the updated version of the blog with Contentlayer, optional choice of TS/JS and different package managers as well as more modularized components which will be the basis of the template going forward. | ||||
|  | ||||
| Alternatively to stick with the current version, TypeScript and Contentlayer: | ||||
|  | ||||
| ```bash | ||||
| npx degit 'timlrx/tailwind-nextjs-starter-blog#contentlayer' | ||||
| ``` | ||||
|  | ||||
| or JS (official support) | ||||
|  | ||||
| ```bash | ||||
| npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git | ||||
| ``` | ||||
|  | ||||
| 1. JS (official support) - `npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git` or TS (community support) - `npx degit timlrx/tailwind-nextjs-starter-blog#typescript` | ||||
| 2. Personalize `siteMetadata.js` (site related information) | ||||
| 3. Modify the content security policy in `next.config.js` if you want to use | ||||
|    any analytics provider or a commenting solution other than giscus. | ||||
| @@ -157,7 +137,7 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda | ||||
|  | ||||
| Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/). | ||||
|  | ||||
| Currently 7 fields are supported. | ||||
| Currently 10 fields are supported. | ||||
|  | ||||
| ``` | ||||
| title (required) | ||||
|   | ||||
| @@ -3,7 +3,7 @@ title: My fancy title | ||||
| date: '2021-01-31' | ||||
| tags: ['hello'] | ||||
| draft: true | ||||
| summary: | ||||
| summary: draft post | ||||
| images: [] | ||||
| --- | ||||
| 
 | ||||
| @@ -7,7 +7,6 @@ draft: false | ||||
| summary: 'An overview of the new features released in v1 - code block copy, multiple authors, frontmatter layout and more' | ||||
| layout: PostSimple | ||||
| bibliography: references-data.bib | ||||
| canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/new-features-in-v1/ | ||||
| --- | ||||
|  | ||||
| ## Overview | ||||
| @@ -16,7 +15,9 @@ A post on the new features introduced in v1.0. New features: | ||||
|  | ||||
| <TOCInline toc={props.toc} exclude="Overview" toHeading={2} /> | ||||
|  | ||||
| First load JS decreased from 43kB to 39kB despite all the new features added!^[With the new changes in Nextjs 12, first load JS increase to 45kB.] | ||||
| First load JS decreased from 43kB to 39kB despite all the new features added! [^1] | ||||
|  | ||||
| [^1]: With the new changes in Nextjs 12, first load JS increase to 45kB. | ||||
|  | ||||
| See [upgrade guide](#upgrade-guide) below if you are migrating from v0 version of the template. | ||||
|  | ||||
| @@ -59,14 +60,11 @@ Some new possibilities include loading components directly in the mdx file using | ||||
| For example, the following jsx snippet can be used directly in an MDX file to render the page title component: | ||||
|  | ||||
| ```jsx | ||||
| import PageTitle from './PageTitle.js' | ||||
| // Or import PageTitle from './components/PageTitle.js' if you are using js | ||||
| import PageTitle from './components/PageTitle.tsx' | ||||
| ;<PageTitle> Using JSX components in MDX </PageTitle> | ||||
| ``` | ||||
|  | ||||
| import PageTitle from './PageTitle.js' | ||||
|  | ||||
| <PageTitle> Using JSX components in MDX </PageTitle> | ||||
|  | ||||
| The default configuration resolves all components relative to the `components` directory. | ||||
|  | ||||
| **Note**: | ||||
| @@ -338,7 +336,7 @@ To modify the styles, change the following class selectors in the `prism.css` fi | ||||
| } | ||||
|  | ||||
| .line-number::before { | ||||
|   @apply mr-4 -ml-2 inline-block w-4 text-right text-gray-400; | ||||
|   @apply -ml-2 mr-4 inline-block w-4 text-right text-gray-400; | ||||
|   content: attr(line); | ||||
| } | ||||
| ``` | ||||
|   | ||||
| @@ -18,17 +18,17 @@ Since we are using mdx, we can create a simple responsive flexbox grid to displa | ||||
|  | ||||
| # Gallery | ||||
|  | ||||
| <div className="flex flex-wrap -mx-2 overflow-hidden xl:-mx-2"> | ||||
|   <div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2"> | ||||
| <div className="-mx-2 flex flex-wrap overflow-hidden xl:-mx-2"> | ||||
|   <div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2"> | ||||
|      | ||||
|   </div> | ||||
|   <div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2"> | ||||
|   <div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2"> | ||||
|      | ||||
|   </div> | ||||
|   <div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2"> | ||||
|   <div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2"> | ||||
|      | ||||
|   </div> | ||||
|   <div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2"> | ||||
|   <div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2"> | ||||
|      | ||||
|   </div> | ||||
| </div> | ||||
| @@ -36,17 +36,17 @@ Since we are using mdx, we can create a simple responsive flexbox grid to displa | ||||
| # Implementation | ||||
|  | ||||
| ```js | ||||
| <div className="flex flex-wrap -mx-2 overflow-hidden xl:-mx-2"> | ||||
|   <div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2"> | ||||
| <div className="-mx-2 flex flex-wrap overflow-hidden xl:-mx-2"> | ||||
|   <div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2"> | ||||
|      | ||||
|   </div> | ||||
|   <div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2"> | ||||
|   <div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2"> | ||||
|      | ||||
|   </div> | ||||
|   <div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2"> | ||||
|   <div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2"> | ||||
|      | ||||
|   </div> | ||||
|   <div className="my-1 px-2 w-full overflow-hidden xl:my-1 xl:px-2 xl:w-1/2"> | ||||
|   <div className="my-1 w-full overflow-hidden px-2 xl:my-1 xl:w-1/2 xl:px-2"> | ||||
|      | ||||
|   </div> | ||||
| </div> | ||||
|   | ||||
| @@ -3,9 +3,9 @@ title: 'The Time Machine' | ||||
| date: '2018-08-15' | ||||
| tags: ['writings', 'book', 'reflection'] | ||||
| draft: false | ||||
| summary: 'The Time Traveller (for so it will be convenient to speak of him) was | ||||
| expounding a recondite matter to us. His pale grey eyes shone and | ||||
| twinkled, and his usually pale face was flushed and animated...' | ||||
| summary: The Time Traveller (for so it will be convenient to speak of him) was | ||||
|   expounding a recondite matter to us. His pale grey eyes shone and | ||||
|   twinkled, and his usually pale face was flushed and animated... | ||||
| --- | ||||
|  | ||||
| # The Time Machine by H. G. Wells | ||||
|   | ||||
| @@ -1,3 +1,6 @@ | ||||
| // @ts-check | ||||
|  | ||||
| /** @type {import("pliny/config").PlinyConfig } */ | ||||
| const siteMetadata = { | ||||
|   title: 'Next.js Starter Blog', | ||||
|   author: 'Tails Azimuth', | ||||
| @@ -24,16 +27,16 @@ const siteMetadata = { | ||||
|     plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app | ||||
|     simpleAnalytics: false, // true or false | ||||
|     umamiWebsiteId: '', // e.g. 123e4567-e89b-12d3-a456-426614174000 | ||||
|     posthogProjectApiKey: '', // e.g. AhnJK8392ndPOav87as450xd | ||||
|     googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX | ||||
|     posthogAnalyticsId: '', // posthog.init e.g. phc_5yXvArzvRdqtZIsHkEm3Fkkhm3d0bEYUXCaFISzqPSQ | ||||
|   }, | ||||
|   newsletter: { | ||||
|     // supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus | ||||
|     // Please add your .env file and modify it according to your selection | ||||
|     provider: 'buttondown', | ||||
|   }, | ||||
|   comment: { | ||||
|     // If you want to use a commenting system other than giscus you have to add it to the | ||||
|   comments: { | ||||
|     // If you want to use an analytics provider you have to add it to the | ||||
|     // content security policy in the `next.config.js` file. | ||||
|     // Select a provider and use the environment variables associated to it | ||||
|     // https://vercel.com/docs/environment-variables | ||||
| @@ -52,34 +55,30 @@ const siteMetadata = { | ||||
|       // theme example: light, dark, dark_dimmed, dark_high_contrast | ||||
|       // transparent_dark, preferred_color_scheme, custom | ||||
|       theme: 'light', | ||||
|       // Place the comment box above the comments. options: bottom, top | ||||
|       inputPosition: 'bottom', | ||||
|       // Choose the language giscus will be displayed in. options: en, es, zh-CN, zh-TW, ko, ja etc | ||||
|       lang: 'en', | ||||
|       // theme when dark mode | ||||
|       darkTheme: 'transparent_dark', | ||||
|       // If the theme option above is set to 'custom` | ||||
|       // please provide a link below to your custom theme css file. | ||||
|       // example: https://giscus.app/themes/custom_example.css | ||||
|       themeURL: '', | ||||
|     }, | ||||
|     utterancesConfig: { | ||||
|       // Visit the link below, and follow the steps in the 'configuration' section | ||||
|       // https://utteranc.es/ | ||||
|       repo: process.env.NEXT_PUBLIC_UTTERANCES_REPO, | ||||
|       issueTerm: '', // supported options: pathname, url, title | ||||
|       label: '', // label (optional): Comment 💬 | ||||
|       // theme example: github-light, github-dark, preferred-color-scheme | ||||
|       // github-dark-orange, icy-dark, dark-blue, photon-dark, boxy-light | ||||
|       theme: '', | ||||
|       // theme when dark mode | ||||
|       darkTheme: '', | ||||
|     }, | ||||
|     disqusConfig: { | ||||
|       // https://help.disqus.com/en/articles/1717111-what-s-a-shortname | ||||
|       shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME, | ||||
|       // This corresponds to the `data-lang="en"` in giscus's configurations | ||||
|       lang: 'en', | ||||
|     }, | ||||
|   }, | ||||
|   // search: { | ||||
|   //   provider: 'kbar', // kbar or algolia | ||||
|   //   kbarConfig: { | ||||
|   //     searchDocumentsPath: 'search.json', // path to load documents to search | ||||
|   //   }, | ||||
|   //   provider: 'algolia', | ||||
|   //   algoliaConfig: { | ||||
|   //     // The application ID provided by Algolia | ||||
|   //     appId: 'R2IYF7ETH7', | ||||
|   //     // Public API key: it is safe to commit it | ||||
|   //     apiKey: '599cec31baffa4868cae4e79f180729b', | ||||
|   //     indexName: 'docsearch', | ||||
|   //   }, | ||||
|   // }, | ||||
| } | ||||
|  | ||||
| module.exports = siteMetadata | ||||
|   | ||||
| @@ -6,7 +6,8 @@ | ||||
|       "@/data/*": ["data/*"], | ||||
|       "@/layouts/*": ["layouts/*"], | ||||
|       "@/lib/*": ["lib/*"], | ||||
|       "@/css/*": ["css/*"] | ||||
|       "@/css/*": ["css/*"], | ||||
|       "contentlayer/generated": ["./.contentlayer/generated"] | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,29 +1,38 @@ | ||||
| import { ReactNode } from 'react' | ||||
| import type { Authors } from 'contentlayer/generated' | ||||
| import SocialIcon from '@/components/social-icons' | ||||
| import Image from '@/components/Image' | ||||
| import { PageSEO } from '@/components/SEO' | ||||
| // import { PageSEO } from '@/components/SEO'
 | ||||
| 
 | ||||
| export default function AuthorLayout({ children, frontMatter }) { | ||||
|   const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter | ||||
| interface Props { | ||||
|   children: ReactNode | ||||
|   content: Omit<Authors, '_id' | '_raw' | 'body'> | ||||
| } | ||||
| 
 | ||||
| export default function AuthorLayout({ children, content }: Props) { | ||||
|   const { name, avatar, occupation, company, email, twitter, linkedin, github } = content | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <PageSEO title={`About - ${name}`} description={`About me - ${name}`} /> | ||||
|       {/* <PageSEO title={`About - ${name}`} description={`About me - ${name}`} /> */} | ||||
|       <div className="divide-y divide-gray-200 dark:divide-gray-700"> | ||||
|         <div className="space-y-2 pt-6 pb-8 md:space-y-5"> | ||||
|         <div className="space-y-2 pb-8 pt-6 md:space-y-5"> | ||||
|           <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14"> | ||||
|             About | ||||
|           </h1> | ||||
|         </div> | ||||
|         <div className="items-start space-y-2 xl:grid xl:grid-cols-3 xl:gap-x-8 xl:space-y-0"> | ||||
|           <div className="flex flex-col items-center pt-8"> | ||||
|             <Image | ||||
|               src={avatar} | ||||
|               alt="avatar" | ||||
|               width="192px" | ||||
|               height="192px" | ||||
|               className="h-48 w-48 rounded-full" | ||||
|             /> | ||||
|             <h3 className="pt-4 pb-2 text-2xl font-bold leading-8 tracking-tight">{name}</h3> | ||||
|           <div className="flex flex-col items-center space-x-2 pt-8"> | ||||
|             {avatar && ( | ||||
|               <Image | ||||
|                 src={avatar} | ||||
|                 alt="avatar" | ||||
|                 width={192} | ||||
|                 height={192} | ||||
|                 className="h-48 w-48 rounded-full" | ||||
|               /> | ||||
|             )} | ||||
|             <h3 className="pb-2 pt-4 text-2xl font-bold leading-8 tracking-tight">{name}</h3> | ||||
|             <div className="text-gray-500 dark:text-gray-400">{occupation}</div> | ||||
|             <div className="text-gray-500 dark:text-gray-400">{company}</div> | ||||
|             <div className="flex space-x-3 pt-6"> | ||||
| @@ -33,7 +42,7 @@ export default function AuthorLayout({ children, frontMatter }) { | ||||
|               <SocialIcon kind="twitter" href={twitter} /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div className="prose max-w-none pt-8 pb-8 dark:prose-dark xl:col-span-2">{children}</div> | ||||
|           <div className="prose max-w-none pb-8 pt-8 dark:prose-dark xl:col-span-2">{children}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </> | ||||
| @@ -1,91 +0,0 @@ | ||||
| import Link from '@/components/Link' | ||||
| import Tag from '@/components/Tag' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import { useState } from 'react' | ||||
| import Pagination from '@/components/Pagination' | ||||
| import formatDate from '@/lib/utils/formatDate' | ||||
|  | ||||
| export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }) { | ||||
|   const [searchValue, setSearchValue] = useState('') | ||||
|   const filteredBlogPosts = posts.filter((frontMatter) => { | ||||
|     const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ') | ||||
|     return searchContent.toLowerCase().includes(searchValue.toLowerCase()) | ||||
|   }) | ||||
|  | ||||
|   // If initialDisplayPosts exist, display it if no searchValue is specified | ||||
|   const displayPosts = | ||||
|     initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="divide-y divide-gray-200 dark:divide-gray-700"> | ||||
|         <div className="space-y-2 pt-6 pb-8 md:space-y-5"> | ||||
|           <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14"> | ||||
|             {title} | ||||
|           </h1> | ||||
|           <div className="relative max-w-lg"> | ||||
|             <input | ||||
|               aria-label="Search articles" | ||||
|               type="text" | ||||
|               onChange={(e) => setSearchValue(e.target.value)} | ||||
|               placeholder="Search articles" | ||||
|               className="block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100" | ||||
|             /> | ||||
|             <svg | ||||
|               className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300" | ||||
|               xmlns="http://www.w3.org/2000/svg" | ||||
|               fill="none" | ||||
|               viewBox="0 0 24 24" | ||||
|               stroke="currentColor" | ||||
|             > | ||||
|               <path | ||||
|                 strokeLinecap="round" | ||||
|                 strokeLinejoin="round" | ||||
|                 strokeWidth={2} | ||||
|                 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" | ||||
|               /> | ||||
|             </svg> | ||||
|           </div> | ||||
|         </div> | ||||
|         <ul> | ||||
|           {!filteredBlogPosts.length && 'No posts found.'} | ||||
|           {displayPosts.map((frontMatter) => { | ||||
|             const { slug, date, title, summary, tags } = frontMatter | ||||
|             return ( | ||||
|               <li key={slug} className="py-4"> | ||||
|                 <article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0"> | ||||
|                   <dl> | ||||
|                     <dt className="sr-only">Published on</dt> | ||||
|                     <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400"> | ||||
|                       <time dateTime={date}>{formatDate(date)}</time> | ||||
|                     </dd> | ||||
|                   </dl> | ||||
|                   <div className="space-y-3 xl:col-span-3"> | ||||
|                     <div> | ||||
|                       <h3 className="text-2xl font-bold leading-8 tracking-tight"> | ||||
|                         <Link href={`/blog/${slug}`} className="text-gray-900 dark:text-gray-100"> | ||||
|                           {title} | ||||
|                         </Link> | ||||
|                       </h3> | ||||
|                       <div className="flex flex-wrap"> | ||||
|                         {tags.map((tag) => ( | ||||
|                           <Tag key={tag} text={tag} /> | ||||
|                         ))} | ||||
|                       </div> | ||||
|                     </div> | ||||
|                     <div className="prose max-w-none text-gray-500 dark:text-gray-400"> | ||||
|                       {summary} | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </article> | ||||
|               </li> | ||||
|             ) | ||||
|           })} | ||||
|         </ul> | ||||
|       </div> | ||||
|       {pagination && pagination.totalPages > 1 && !searchValue && ( | ||||
|         <Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} /> | ||||
|       )} | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										154
									
								
								layouts/ListLayout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								layouts/ListLayout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { useState } from 'react' | ||||
| import { usePathname } from 'next/navigation' | ||||
| import { formatDate } from 'pliny/utils/formatDate' | ||||
| import { CoreContent } from 'pliny/utils/contentlayer' | ||||
| import type { Blog } from 'contentlayer/generated' | ||||
| import Link from '@/components/Link' | ||||
| import Tag from '@/components/Tag' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| interface PaginationProps { | ||||
|   totalPages: number | ||||
|   currentPage: number | ||||
| } | ||||
| interface ListLayoutProps { | ||||
|   posts: CoreContent<Blog>[] | ||||
|   title: string | ||||
|   initialDisplayPosts?: CoreContent<Blog>[] | ||||
|   pagination?: PaginationProps | ||||
| } | ||||
|  | ||||
| function Pagination({ totalPages, currentPage }: PaginationProps) { | ||||
|   const pathname = usePathname() as string | ||||
|   const basePath = pathname.split('/')[1] | ||||
|   const prevPage = currentPage - 1 > 0 | ||||
|   const nextPage = currentPage + 1 <= totalPages | ||||
|  | ||||
|   return ( | ||||
|     <div className="space-y-2 pb-8 pt-6 md:space-y-5"> | ||||
|       <nav className="flex justify-between"> | ||||
|         {!prevPage && ( | ||||
|           <button className="cursor-auto disabled:opacity-50" disabled={!prevPage}> | ||||
|             Previous | ||||
|           </button> | ||||
|         )} | ||||
|         {prevPage && ( | ||||
|           <Link | ||||
|             href={currentPage - 1 === 1 ? `/${basePath}/` : `/${basePath}/page/${currentPage - 1}`} | ||||
|             rel="prev" | ||||
|           > | ||||
|             Previous | ||||
|           </Link> | ||||
|         )} | ||||
|         <span> | ||||
|           {currentPage} of {totalPages} | ||||
|         </span> | ||||
|         {!nextPage && ( | ||||
|           <button className="cursor-auto disabled:opacity-50" disabled={!nextPage}> | ||||
|             Next | ||||
|           </button> | ||||
|         )} | ||||
|         {nextPage && ( | ||||
|           <Link href={`/${basePath}/page/${currentPage + 1}`} rel="next"> | ||||
|             Next | ||||
|           </Link> | ||||
|         )} | ||||
|       </nav> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default function ListLayout({ | ||||
|   posts, | ||||
|   title, | ||||
|   initialDisplayPosts = [], | ||||
|   pagination, | ||||
| }: ListLayoutProps) { | ||||
|   const [searchValue, setSearchValue] = useState('') | ||||
|   const filteredBlogPosts = posts.filter((post) => { | ||||
|     const searchContent = post.title + post.summary + post.tags?.join(' ') | ||||
|     return searchContent.toLowerCase().includes(searchValue.toLowerCase()) | ||||
|   }) | ||||
|  | ||||
|   // If initialDisplayPosts exist, display it if no searchValue is specified | ||||
|   const displayPosts = | ||||
|     initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="divide-y divide-gray-200 dark:divide-gray-700"> | ||||
|         <div className="space-y-2 pb-8 pt-6 md:space-y-5"> | ||||
|           <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14"> | ||||
|             {title} | ||||
|           </h1> | ||||
|           <div className="relative max-w-lg"> | ||||
|             <label> | ||||
|               <span className="sr-only">Search articles</span> | ||||
|               <input | ||||
|                 aria-label="Search articles" | ||||
|                 type="text" | ||||
|                 onChange={(e) => setSearchValue(e.target.value)} | ||||
|                 placeholder="Search articles" | ||||
|                 className="block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100" | ||||
|               /> | ||||
|             </label> | ||||
|             <svg | ||||
|               className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300" | ||||
|               xmlns="http://www.w3.org/2000/svg" | ||||
|               fill="none" | ||||
|               viewBox="0 0 24 24" | ||||
|               stroke="currentColor" | ||||
|             > | ||||
|               <path | ||||
|                 strokeLinecap="round" | ||||
|                 strokeLinejoin="round" | ||||
|                 strokeWidth={2} | ||||
|                 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" | ||||
|               /> | ||||
|             </svg> | ||||
|           </div> | ||||
|         </div> | ||||
|         <ul> | ||||
|           {!filteredBlogPosts.length && 'No posts found.'} | ||||
|           {displayPosts.map((post) => { | ||||
|             const { path, date, title, summary, tags } = post | ||||
|             return ( | ||||
|               <li key={path} className="py-4"> | ||||
|                 <article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0"> | ||||
|                   <dl> | ||||
|                     <dt className="sr-only">Published on</dt> | ||||
|                     <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400"> | ||||
|                       <time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time> | ||||
|                     </dd> | ||||
|                   </dl> | ||||
|                   <div className="space-y-3 xl:col-span-3"> | ||||
|                     <div> | ||||
|                       <h3 className="text-2xl font-bold leading-8 tracking-tight"> | ||||
|                         <Link href={`/${path}`} className="text-gray-900 dark:text-gray-100"> | ||||
|                           {title} | ||||
|                         </Link> | ||||
|                       </h3> | ||||
|                       <div className="flex flex-wrap"> | ||||
|                         {tags?.map((tag) => ( | ||||
|                           <Tag key={tag} text={tag} /> | ||||
|                         ))} | ||||
|                       </div> | ||||
|                     </div> | ||||
|                     <div className="prose max-w-none text-gray-500 dark:text-gray-400"> | ||||
|                       {summary} | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </article> | ||||
|               </li> | ||||
|             ) | ||||
|           })} | ||||
|         </ul> | ||||
|       </div> | ||||
|       {pagination && pagination.totalPages > 1 && !searchValue && ( | ||||
|         <Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} /> | ||||
|       )} | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| @@ -1,31 +1,43 @@ | ||||
| import { useState, ReactNode } from 'react' | ||||
| import { Comments } from 'pliny/comments' | ||||
| import { CoreContent } from 'pliny/utils/contentlayer' | ||||
| import type { Blog, Authors } from 'contentlayer/generated' | ||||
| import Link from '@/components/Link' | ||||
| import PageTitle from '@/components/PageTitle' | ||||
| import SectionContainer from '@/components/SectionContainer' | ||||
| import { BlogSEO } from '@/components/SEO' | ||||
| // import { BlogSEO } from '@/components/SEO'
 | ||||
| import Image from '@/components/Image' | ||||
| import Tag from '@/components/Tag' | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import Comments from '@/components/comments' | ||||
| import ScrollTopAndComment from '@/components/ScrollTopAndComment' | ||||
| 
 | ||||
| const editUrl = (fileName) => `${siteMetadata.siteRepo}/blob/master/data/blog/${fileName}` | ||||
| const discussUrl = (slug) => | ||||
|   `https://mobile.twitter.com/search?q=${encodeURIComponent( | ||||
|     `${siteMetadata.siteUrl}/blog/${slug}` | ||||
|   )}` | ||||
| const editUrl = (path) => `${siteMetadata.siteRepo}/blob/master/data/${path}` | ||||
| const discussUrl = (path) => | ||||
|   `https://mobile.twitter.com/search?q=${encodeURIComponent(`${siteMetadata.siteUrl}/${path}`)}` | ||||
| 
 | ||||
| const postDateTemplate = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' } | ||||
| const postDateTemplate: Intl.DateTimeFormatOptions = { | ||||
|   weekday: 'long', | ||||
|   year: 'numeric', | ||||
|   month: 'long', | ||||
|   day: 'numeric', | ||||
| } | ||||
| 
 | ||||
| export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) { | ||||
|   const { slug, fileName, date, title, images, tags } = frontMatter | ||||
| interface LayoutProps { | ||||
|   content: CoreContent<Blog> | ||||
|   authorDetails: CoreContent<Authors>[] | ||||
|   next?: { path: string; title: string } | ||||
|   prev?: { path: string; title: string } | ||||
|   children: ReactNode | ||||
| } | ||||
| 
 | ||||
| export default function PostLayout({ content, authorDetails, next, prev, children }: LayoutProps) { | ||||
|   const { filePath, path, slug, date, title, tags } = content | ||||
|   const basePath = path.split('/')[0] | ||||
|   const [loadComments, setLoadComments] = useState(false) | ||||
| 
 | ||||
|   return ( | ||||
|     <SectionContainer> | ||||
|       <BlogSEO | ||||
|         url={`${siteMetadata.siteUrl}/blog/${slug}`} | ||||
|         authorDetails={authorDetails} | ||||
|         {...frontMatter} | ||||
|       /> | ||||
|       {/* <BlogSEO url={`${siteMetadata.siteUrl}/${path}`} authorDetails={authorDetails} {...content} /> */} | ||||
|       <ScrollTopAndComment /> | ||||
|       <article> | ||||
|         <div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700"> | ||||
| @@ -46,21 +58,18 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi | ||||
|               </div> | ||||
|             </div> | ||||
|           </header> | ||||
|           <div | ||||
|             className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0" | ||||
|             style={{ gridTemplateRows: 'auto 1fr' }} | ||||
|           > | ||||
|             <dl className="pt-6 pb-10 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700"> | ||||
|           <div className="grid-rows-[auto_1fr] divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0"> | ||||
|             <dl className="pb-10 pt-6 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700"> | ||||
|               <dt className="sr-only">Authors</dt> | ||||
|               <dd> | ||||
|                 <ul className="flex justify-center space-x-8 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8"> | ||||
|                 <ul className="flex flex-wrap justify-center gap-4 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8"> | ||||
|                   {authorDetails.map((author) => ( | ||||
|                     <li className="flex items-center space-x-2" key={author.name}> | ||||
|                       {author.avatar && ( | ||||
|                         <Image | ||||
|                           src={author.avatar} | ||||
|                           width="38px" | ||||
|                           height="38px" | ||||
|                           width={38} | ||||
|                           height={38} | ||||
|                           alt="avatar" | ||||
|                           className="h-10 w-10 rounded-full" | ||||
|                         /> | ||||
| @@ -86,15 +95,25 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi | ||||
|               </dd> | ||||
|             </dl> | ||||
|             <div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0"> | ||||
|               <div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div> | ||||
|               <div className="pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300"> | ||||
|                 <Link href={discussUrl(slug)} rel="nofollow"> | ||||
|                   {'Discuss on Twitter'} | ||||
|               <div className="prose max-w-none pb-8 pt-10 dark:prose-dark">{children}</div> | ||||
|               <div className="pb-6 pt-6 text-sm text-gray-700 dark:text-gray-300"> | ||||
|                 <Link href={discussUrl(path)} rel="nofollow"> | ||||
|                   Discuss on Twitter | ||||
|                 </Link> | ||||
|                 {` • `} | ||||
|                 <Link href={editUrl(fileName)}>{'View on GitHub'}</Link> | ||||
|                 <Link href={editUrl(filePath)}>View on GitHub</Link> | ||||
|               </div> | ||||
|               <Comments frontMatter={frontMatter} /> | ||||
|               {siteMetadata.comments && ( | ||||
|                 <div | ||||
|                   className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300" | ||||
|                   id="comment" | ||||
|                 > | ||||
|                   {!loadComments && ( | ||||
|                     <button onClick={() => setLoadComments(true)}>Load Comments</button> | ||||
|                   )} | ||||
|                   {loadComments && <Comments commentsConfig={siteMetadata.comments} slug={slug} />} | ||||
|                 </div> | ||||
|               )} | ||||
|             </div> | ||||
|             <footer> | ||||
|               <div className="divide-gray-200 text-sm font-medium leading-5 dark:divide-gray-700 xl:col-start-1 xl:row-start-2 xl:divide-y"> | ||||
| @@ -118,7 +137,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi | ||||
|                           Previous Article | ||||
|                         </h2> | ||||
|                         <div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"> | ||||
|                           <Link href={`/blog/${prev.slug}`}>{prev.title}</Link> | ||||
|                           <Link href={`/${prev.path}`}>{prev.title}</Link> | ||||
|                         </div> | ||||
|                       </div> | ||||
|                     )} | ||||
| @@ -128,7 +147,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi | ||||
|                           Next Article | ||||
|                         </h2> | ||||
|                         <div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"> | ||||
|                           <Link href={`/blog/${next.slug}`}>{next.title}</Link> | ||||
|                           <Link href={`/${next.path}`}>{next.title}</Link> | ||||
|                         </div> | ||||
|                       </div> | ||||
|                     )} | ||||
| @@ -137,8 +156,9 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi | ||||
|               </div> | ||||
|               <div className="pt-4 xl:pt-8"> | ||||
|                 <Link | ||||
|                   href="/blog" | ||||
|                   href={`/${basePath}`} | ||||
|                   className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" | ||||
|                   aria-label="Back to the blog" | ||||
|                 > | ||||
|                   ← Back to the blog | ||||
|                 </Link> | ||||
| @@ -1,18 +1,30 @@ | ||||
| import { useState, ReactNode } from 'react' | ||||
| import { Comments } from 'pliny/comments' | ||||
| import { formatDate } from 'pliny/utils/formatDate' | ||||
| import { CoreContent } from 'pliny/utils/contentlayer' | ||||
| import type { Blog } from 'contentlayer/generated' | ||||
| import Link from '@/components/Link' | ||||
| import PageTitle from '@/components/PageTitle' | ||||
| import SectionContainer from '@/components/SectionContainer' | ||||
| import { BlogSEO } from '@/components/SEO' | ||||
| // import { BlogSEO } from '@/components/SEO'
 | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import formatDate from '@/lib/utils/formatDate' | ||||
| import Comments from '@/components/comments' | ||||
| import ScrollTopAndComment from '@/components/ScrollTopAndComment' | ||||
| 
 | ||||
| export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) { | ||||
|   const { date, title } = frontMatter | ||||
| interface LayoutProps { | ||||
|   content: CoreContent<Blog> | ||||
|   children: ReactNode | ||||
|   next?: { path: string; title: string } | ||||
|   prev?: { path: string; title: string } | ||||
| } | ||||
| 
 | ||||
| export default function PostLayout({ content, next, prev, children }: LayoutProps) { | ||||
|   const [loadComments, setLoadComments] = useState(false) | ||||
| 
 | ||||
|   const { path, slug, date, title } = content | ||||
| 
 | ||||
|   return ( | ||||
|     <SectionContainer> | ||||
|       <BlogSEO url={`${siteMetadata.siteUrl}/blog/${frontMatter.slug}`} {...frontMatter} /> | ||||
|       {/* <BlogSEO url={`${siteMetadata.siteUrl}/${path}`} {...content} /> */} | ||||
|       <ScrollTopAndComment /> | ||||
|       <article> | ||||
|         <div> | ||||
| @@ -22,7 +34,7 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi | ||||
|                 <div> | ||||
|                   <dt className="sr-only">Published on</dt> | ||||
|                   <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400"> | ||||
|                     <time dateTime={date}>{formatDate(date)}</time> | ||||
|                     <time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time> | ||||
|                   </dd> | ||||
|                 </div> | ||||
|               </dl> | ||||
| @@ -31,21 +43,26 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi | ||||
|               </div> | ||||
|             </div> | ||||
|           </header> | ||||
|           <div | ||||
|             className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0 " | ||||
|             style={{ gridTemplateRows: 'auto 1fr' }} | ||||
|           > | ||||
|           <div className="grid-rows-[auto_1fr] divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0"> | ||||
|             <div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0"> | ||||
|               <div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div> | ||||
|               <div className="prose max-w-none pb-8 pt-10 dark:prose-dark">{children}</div> | ||||
|             </div> | ||||
|             <Comments frontMatter={frontMatter} /> | ||||
|             {siteMetadata.comments && ( | ||||
|               <div className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300" id="comment"> | ||||
|                 {!loadComments && ( | ||||
|                   <button onClick={() => setLoadComments(true)}>Load Comments</button> | ||||
|                 )} | ||||
|                 {loadComments && <Comments commentsConfig={siteMetadata.comments} slug={slug} />} | ||||
|               </div> | ||||
|             )} | ||||
|             <footer> | ||||
|               <div className="flex flex-col text-sm font-medium sm:flex-row sm:justify-between sm:text-base"> | ||||
|                 {prev && ( | ||||
|                   <div className="pt-4 xl:pt-8"> | ||||
|                     <Link | ||||
|                       href={`/blog/${prev.slug}`} | ||||
|                       href={`/${prev.path}`} | ||||
|                       className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" | ||||
|                       aria-label={`Previous post: ${prev.title}`} | ||||
|                     > | ||||
|                       ← {prev.title} | ||||
|                     </Link> | ||||
| @@ -54,8 +71,9 @@ export default function PostLayout({ frontMatter, authorDetails, next, prev, chi | ||||
|                 {next && ( | ||||
|                   <div className="pt-4 xl:pt-8"> | ||||
|                     <Link | ||||
|                       href={`/blog/${next.slug}`} | ||||
|                       href={`/${next.path}`} | ||||
|                       className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400" | ||||
|                       aria-label={`Next post: ${next.title}`} | ||||
|                     > | ||||
|                       {next.title} → | ||||
|                     </Link> | ||||
| @@ -1,32 +0,0 @@ | ||||
| import { escape } from '@/lib/utils/htmlEscaper' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const generateRssItem = (post) => ` | ||||
|   <item> | ||||
|     <guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid> | ||||
|     <title>${escape(post.title)}</title> | ||||
|     <link>${siteMetadata.siteUrl}/blog/${post.slug}</link> | ||||
|     ${post.summary && `<description>${escape(post.summary)}</description>`} | ||||
|     <pubDate>${new Date(post.date).toUTCString()}</pubDate> | ||||
|     <author>${siteMetadata.email} (${siteMetadata.author})</author> | ||||
|     ${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')} | ||||
|   </item> | ||||
| ` | ||||
|  | ||||
| const generateRss = (posts, page = 'feed.xml') => ` | ||||
|   <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> | ||||
|     <channel> | ||||
|       <title>${escape(siteMetadata.title)}</title> | ||||
|       <link>${siteMetadata.siteUrl}/blog</link> | ||||
|       <description>${escape(siteMetadata.description)}</description> | ||||
|       <language>${siteMetadata.language}</language> | ||||
|       <managingEditor>${siteMetadata.email} (${siteMetadata.author})</managingEditor> | ||||
|       <webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster> | ||||
|       <lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate> | ||||
|       <atom:link href="${siteMetadata.siteUrl}/${page}" rel="self" type="application/rss+xml"/> | ||||
|       ${posts.map(generateRssItem).join('')} | ||||
|     </channel> | ||||
|   </rss> | ||||
| ` | ||||
| export default generateRss | ||||
							
								
								
									
										136
									
								
								lib/mdx.js
									
									
									
									
									
								
							
							
						
						
									
										136
									
								
								lib/mdx.js
									
									
									
									
									
								
							| @@ -1,136 +0,0 @@ | ||||
| import { bundleMDX } from 'mdx-bundler' | ||||
| import fs from 'fs' | ||||
| import matter from 'gray-matter' | ||||
| import path from 'path' | ||||
| import readingTime from 'reading-time' | ||||
| import { visit } from 'unist-util-visit' | ||||
| import getAllFilesRecursively from './utils/files' | ||||
| // Remark packages | ||||
| import remarkGfm from 'remark-gfm' | ||||
| import remarkFootnotes from 'remark-footnotes' | ||||
| import remarkMath from 'remark-math' | ||||
| import remarkExtractFrontmatter from './remark-extract-frontmatter' | ||||
| import remarkCodeTitles from './remark-code-title' | ||||
| import remarkTocHeadings from './remark-toc-headings' | ||||
| import remarkImgToJsx from './remark-img-to-jsx' | ||||
| // Rehype packages | ||||
| import rehypeSlug from 'rehype-slug' | ||||
| import rehypeAutolinkHeadings from 'rehype-autolink-headings' | ||||
| import rehypeKatex from 'rehype-katex' | ||||
| import rehypeCitation from 'rehype-citation' | ||||
| import rehypePrismPlus from 'rehype-prism-plus' | ||||
| import rehypePresetMinify from 'rehype-preset-minify' | ||||
|  | ||||
| const root = process.cwd() | ||||
|  | ||||
| export function getFiles(type) { | ||||
|   const prefixPaths = path.join(root, 'data', type) | ||||
|   const files = getAllFilesRecursively(prefixPaths) | ||||
|   // Only want to return blog/path and ignore root, replace is needed to work on Windows | ||||
|   return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/')) | ||||
| } | ||||
|  | ||||
| export function formatSlug(slug) { | ||||
|   return slug.replace(/\.(mdx|md)/, '') | ||||
| } | ||||
|  | ||||
| export function dateSortDesc(a, b) { | ||||
|   if (a > b) return -1 | ||||
|   if (a < b) return 1 | ||||
|   return 0 | ||||
| } | ||||
|  | ||||
| export async function getFileBySlug(type, slug) { | ||||
|   const mdxPath = path.join(root, 'data', type, `${slug}.mdx`) | ||||
|   const mdPath = path.join(root, 'data', type, `${slug}.md`) | ||||
|   const source = fs.existsSync(mdxPath) | ||||
|     ? fs.readFileSync(mdxPath, 'utf8') | ||||
|     : fs.readFileSync(mdPath, 'utf8') | ||||
|  | ||||
|   // https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent | ||||
|   if (process.platform === 'win32') { | ||||
|     process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'esbuild.exe') | ||||
|   } else { | ||||
|     process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild') | ||||
|   } | ||||
|  | ||||
|   let toc = [] | ||||
|  | ||||
|   const { code, frontmatter } = await bundleMDX({ | ||||
|     source, | ||||
|     // mdx imports can be automatically source from the components directory | ||||
|     cwd: path.join(root, 'components'), | ||||
|     xdmOptions(options, frontmatter) { | ||||
|       // this is the recommended way to add custom remark/rehype plugins: | ||||
|       // The syntax might look weird, but it protects you in case we add/remove | ||||
|       // plugins in the future. | ||||
|       options.remarkPlugins = [ | ||||
|         ...(options.remarkPlugins ?? []), | ||||
|         remarkExtractFrontmatter, | ||||
|         [remarkTocHeadings, { exportRef: toc }], | ||||
|         remarkGfm, | ||||
|         remarkCodeTitles, | ||||
|         [remarkFootnotes, { inlineNotes: true }], | ||||
|         remarkMath, | ||||
|         remarkImgToJsx, | ||||
|       ] | ||||
|       options.rehypePlugins = [ | ||||
|         ...(options.rehypePlugins ?? []), | ||||
|         rehypeSlug, | ||||
|         rehypeAutolinkHeadings, | ||||
|         rehypeKatex, | ||||
|         [rehypeCitation, { path: path.join(root, 'data') }], | ||||
|         [rehypePrismPlus, { ignoreMissing: true }], | ||||
|         rehypePresetMinify, | ||||
|       ] | ||||
|       return options | ||||
|     }, | ||||
|     esbuildOptions: (options) => { | ||||
|       options.loader = { | ||||
|         ...options.loader, | ||||
|         '.js': 'jsx', | ||||
|       } | ||||
|       return options | ||||
|     }, | ||||
|   }) | ||||
|  | ||||
|   return { | ||||
|     mdxSource: code, | ||||
|     toc, | ||||
|     frontMatter: { | ||||
|       readingTime: readingTime(code), | ||||
|       slug: slug || null, | ||||
|       fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`, | ||||
|       ...frontmatter, | ||||
|       date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null, | ||||
|     }, | ||||
|   } | ||||
| } | ||||
|  | ||||
| export async function getAllFilesFrontMatter(folder) { | ||||
|   const prefixPaths = path.join(root, 'data', folder) | ||||
|  | ||||
|   const files = getAllFilesRecursively(prefixPaths) | ||||
|  | ||||
|   const allFrontMatter = [] | ||||
|  | ||||
|   files.forEach((file) => { | ||||
|     // Replace is needed to work on Windows | ||||
|     const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/') | ||||
|     // Remove Unexpected File | ||||
|     if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') { | ||||
|       return | ||||
|     } | ||||
|     const source = fs.readFileSync(file, 'utf8') | ||||
|     const { data: frontmatter } = matter(source) | ||||
|     if (frontmatter.draft !== true) { | ||||
|       allFrontMatter.push({ | ||||
|         ...frontmatter, | ||||
|         slug: formatSlug(fileName), | ||||
|         date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null, | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date)) | ||||
| } | ||||
| @@ -1,32 +0,0 @@ | ||||
| import { visit } from 'unist-util-visit' | ||||
|  | ||||
| export default function remarkCodeTitles() { | ||||
|   return (tree) => | ||||
|     visit(tree, 'code', (node, index, parent) => { | ||||
|       const nodeLang = node.lang || '' | ||||
|       let language = '' | ||||
|       let title = '' | ||||
|  | ||||
|       if (nodeLang.includes(':')) { | ||||
|         language = nodeLang.slice(0, nodeLang.search(':')) | ||||
|         title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length) | ||||
|       } | ||||
|  | ||||
|       if (!title) { | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       const className = 'remark-code-title' | ||||
|  | ||||
|       const titleNode = { | ||||
|         type: 'mdxJsxFlowElement', | ||||
|         name: 'div', | ||||
|         attributes: [{ type: 'mdxJsxAttribute', name: 'className', value: className }], | ||||
|         children: [{ type: 'text', value: title }], | ||||
|         data: { _xdmExplicitJsx: true }, | ||||
|       } | ||||
|  | ||||
|       parent.children.splice(index, 0, titleNode) | ||||
|       node.lang = language | ||||
|     }) | ||||
| } | ||||
| @@ -1,10 +0,0 @@ | ||||
| import { visit } from 'unist-util-visit' | ||||
| import { load } from 'js-yaml' | ||||
|  | ||||
| export default function extractFrontmatter() { | ||||
|   return (tree, file) => { | ||||
|     visit(tree, 'yaml', (node, index, parent) => { | ||||
|       file.data.frontmatter = load(node.value) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @@ -1,35 +0,0 @@ | ||||
| import { visit } from 'unist-util-visit' | ||||
| import sizeOf from 'image-size' | ||||
| import fs from 'fs' | ||||
|  | ||||
| export default function remarkImgToJsx() { | ||||
|   return (tree) => { | ||||
|     visit( | ||||
|       tree, | ||||
|       // only visit p tags that contain an img element | ||||
|       (node) => node.type === 'paragraph' && node.children.some((n) => n.type === 'image'), | ||||
|       (node) => { | ||||
|         const imageNode = node.children.find((n) => n.type === 'image') | ||||
|  | ||||
|         // only local files | ||||
|         if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) { | ||||
|           const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`) | ||||
|  | ||||
|           // Convert original node to next/image | ||||
|           ;(imageNode.type = 'mdxJsxFlowElement'), | ||||
|             (imageNode.name = 'Image'), | ||||
|             (imageNode.attributes = [ | ||||
|               { type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt }, | ||||
|               { type: 'mdxJsxAttribute', name: 'src', value: imageNode.url }, | ||||
|               { type: 'mdxJsxAttribute', name: 'width', value: dimensions.width }, | ||||
|               { type: 'mdxJsxAttribute', name: 'height', value: dimensions.height }, | ||||
|             ]) | ||||
|  | ||||
|           // Change node type from p to div to avoid nesting error | ||||
|           node.type = 'div' | ||||
|           node.children = [imageNode] | ||||
|         } | ||||
|       } | ||||
|     ) | ||||
|   } | ||||
| } | ||||
| @@ -1,15 +0,0 @@ | ||||
| import { visit } from 'unist-util-visit' | ||||
| import { slug } from 'github-slugger' | ||||
| import { toString } from 'mdast-util-to-string' | ||||
|  | ||||
| export default function remarkTocHeadings(options) { | ||||
|   return (tree) => | ||||
|     visit(tree, 'heading', (node, index, parent) => { | ||||
|       const textContent = toString(node) | ||||
|       options.exportRef.push({ | ||||
|         value: textContent, | ||||
|         url: '#' + slug(textContent), | ||||
|         depth: node.depth, | ||||
|       }) | ||||
|     }) | ||||
| } | ||||
							
								
								
									
										30
									
								
								lib/tags.js
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								lib/tags.js
									
									
									
									
									
								
							| @@ -1,30 +0,0 @@ | ||||
| import fs from 'fs' | ||||
| import matter from 'gray-matter' | ||||
| import path from 'path' | ||||
| import { getFiles } from './mdx' | ||||
| import kebabCase from './utils/kebabCase' | ||||
|  | ||||
| const root = process.cwd() | ||||
|  | ||||
| export async function getAllTags(type) { | ||||
|   const files = await getFiles(type) | ||||
|  | ||||
|   let tagCount = {} | ||||
|   // Iterate through each post, putting all found tags into `tags` | ||||
|   files.forEach((file) => { | ||||
|     const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8') | ||||
|     const { data } = matter(source) | ||||
|     if (data.tags && data.draft !== true) { | ||||
|       data.tags.forEach((tag) => { | ||||
|         const formattedTag = kebabCase(tag) | ||||
|         if (formattedTag in tagCount) { | ||||
|           tagCount[formattedTag] += 1 | ||||
|         } else { | ||||
|           tagCount[formattedTag] = 1 | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   return tagCount | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| import fs from 'fs' | ||||
| import path from 'path' | ||||
|  | ||||
| const pipe = | ||||
|   (...fns) => | ||||
|   (x) => | ||||
|     fns.reduce((v, f) => f(v), x) | ||||
|  | ||||
| const flattenArray = (input) => | ||||
|   input.reduce((acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])], []) | ||||
|  | ||||
| const map = (fn) => (input) => input.map(fn) | ||||
|  | ||||
| const walkDir = (fullPath) => { | ||||
|   return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath) | ||||
| } | ||||
|  | ||||
| const pathJoinPrefix = (prefix) => (extraPath) => path.join(prefix, extraPath) | ||||
|  | ||||
| const getAllFilesRecursively = (folder) => | ||||
|   pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder) | ||||
|  | ||||
| export default getAllFilesRecursively | ||||
| @@ -1,14 +0,0 @@ | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
|  | ||||
| const formatDate = (date) => { | ||||
|   const options = { | ||||
|     year: 'numeric', | ||||
|     month: 'long', | ||||
|     day: 'numeric', | ||||
|   } | ||||
|   const now = new Date(date).toLocaleDateString(siteMetadata.locale, options) | ||||
|  | ||||
|   return now | ||||
| } | ||||
|  | ||||
| export default formatDate | ||||
| @@ -1,23 +0,0 @@ | ||||
| const { replace } = '' | ||||
|  | ||||
| // escape | ||||
| const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g | ||||
| const ca = /[&<>'"]/g | ||||
|  | ||||
| const esca = { | ||||
|   '&': '&', | ||||
|   '<': '<', | ||||
|   '>': '>', | ||||
|   "'": ''', | ||||
|   '"': '"', | ||||
| } | ||||
| const pe = (m) => esca[m] | ||||
|  | ||||
| /** | ||||
|  * Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`. | ||||
|  * @param {string} es the input to safely escape | ||||
|  * @returns {string} the escaped input, and it **throws** an error if | ||||
|  *  the input type is unexpected, except for boolean and numbers, | ||||
|  *  converted as string. | ||||
|  */ | ||||
| export const escape = (es) => replace.call(es, ca, pe) | ||||
| @@ -1,5 +0,0 @@ | ||||
| import { slug } from 'github-slugger' | ||||
|  | ||||
| const kebabCase = (str) => slug(str) | ||||
|  | ||||
| export default kebabCase | ||||
							
								
								
									
										6
									
								
								next-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								next-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| /// <reference types="next" /> | ||||
| /// <reference types="next/image-types/global" /> | ||||
| /// <reference types="next/navigation-types/compat/navigation" /> | ||||
|  | ||||
| // NOTE: This file should not be edited | ||||
| // see https://nextjs.org/docs/basic-features/typescript for more information. | ||||
| @@ -1,3 +1,5 @@ | ||||
| const { withContentlayer } = require('next-contentlayer') | ||||
|  | ||||
| const withBundleAnalyzer = require('@next/bundle-analyzer')({ | ||||
|   enabled: process.env.ANALYZE === 'true', | ||||
| }) | ||||
| @@ -52,36 +54,35 @@ const securityHeaders = [ | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| module.exports = withBundleAnalyzer({ | ||||
|   reactStrictMode: true, | ||||
|   pageExtensions: ['js', 'jsx', 'md', 'mdx'], | ||||
|   eslint: { | ||||
|     dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'], | ||||
|   }, | ||||
|   async headers() { | ||||
|     return [ | ||||
|       { | ||||
|         source: '/(.*)', | ||||
|         headers: securityHeaders, | ||||
|       }, | ||||
|     ] | ||||
|   }, | ||||
|   webpack: (config, { dev, isServer }) => { | ||||
|     config.module.rules.push({ | ||||
|       test: /\.svg$/, | ||||
|       use: ['@svgr/webpack'], | ||||
|     }) | ||||
|  | ||||
|     if (!dev && !isServer) { | ||||
|       // Replace React with Preact only in client production build | ||||
|       Object.assign(config.resolve.alias, { | ||||
|         'react/jsx-runtime.js': 'preact/compat/jsx-runtime', | ||||
|         react: 'preact/compat', | ||||
|         'react-dom/test-utils': 'preact/test-utils', | ||||
|         'react-dom': 'preact/compat', | ||||
| /** | ||||
|  * @type {import('next/dist/next-server/server/config').NextConfig} | ||||
|  **/ | ||||
| module.exports = () => { | ||||
|   const plugins = [withContentlayer, withBundleAnalyzer] | ||||
|   return plugins.reduce((acc, next) => next(acc), { | ||||
|     reactStrictMode: true, | ||||
|     pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'], | ||||
|     eslint: { | ||||
|       dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'], | ||||
|     }, | ||||
|     experimental: { | ||||
|       appDir: true, | ||||
|     }, | ||||
|     async headers() { | ||||
|       return [ | ||||
|         { | ||||
|           source: '/(.*)', | ||||
|           headers: securityHeaders, | ||||
|         }, | ||||
|       ] | ||||
|     }, | ||||
|     webpack: (config, options) => { | ||||
|       config.module.rules.push({ | ||||
|         test: /\.svg$/, | ||||
|         use: ['@svgr/webpack'], | ||||
|       }) | ||||
|     } | ||||
|  | ||||
|     return config | ||||
|   }, | ||||
| }) | ||||
|       return config | ||||
|     }, | ||||
|   }) | ||||
| } | ||||
|   | ||||
							
								
								
									
										10657
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10657
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										86
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										86
									
								
								package.json
									
									
									
									
									
								
							| @@ -3,64 +3,64 @@ | ||||
|   "version": "1.5.6", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "start": "cross-env SOCKET=true node ./scripts/next-remote-watch.js ./data", | ||||
|     "dev": "next dev", | ||||
|     "build": "next build && node ./scripts/generate-sitemap", | ||||
|     "start": "next dev", | ||||
|     "dev": "cross-env INIT_CWD=$PWD next dev", | ||||
|     "build": "cross-env INIT_CWD=$PWD next build && cross-env NODE_OPTIONS='--experimental-json-modules' node -r esbuild-register ./scripts/postbuild.mjs", | ||||
|     "serve": "next start", | ||||
|     "analyze": "cross-env ANALYZE=true next build", | ||||
|     "lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts", | ||||
|     "prepare": "husky install" | ||||
|     "lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fontsource/inter": "4.5.2", | ||||
|     "@mailchimp/mailchimp_marketing": "^3.0.58", | ||||
|     "@next/bundle-analyzer": "^12.1.4", | ||||
|     "@tailwindcss/forms": "^0.4.0", | ||||
|     "@tailwindcss/typography": "^0.5.0", | ||||
|     "autoprefixer": "^10.4.0", | ||||
|     "esbuild": "^0.13.13", | ||||
|     "github-slugger": "^1.3.0", | ||||
|     "@next/bundle-analyzer": "13.4.8", | ||||
|     "@tailwindcss/forms": "^0.5.3", | ||||
|     "@tailwindcss/typography": "^0.5.9", | ||||
|     "autoprefixer": "^10.4.13", | ||||
|     "contentlayer": "0.3.4", | ||||
|     "esbuild": "0.18.11", | ||||
|     "github-slugger": "^1.4.0", | ||||
|     "gray-matter": "^4.0.2", | ||||
|     "image-size": "1.0.0", | ||||
|     "mdx-bundler": "^8.0.0", | ||||
|     "next": "12.1.4", | ||||
|     "next-themes": "^0.0.14", | ||||
|     "postcss": "^8.4.5", | ||||
|     "preact": "^10.6.2", | ||||
|     "react": "17.0.2", | ||||
|     "react-dom": "17.0.2", | ||||
|     "reading-time": "1.3.0", | ||||
|     "mdx-bundler": "^9.2.1", | ||||
|     "next": "13.4.8", | ||||
|     "next-contentlayer": "0.3.4", | ||||
|     "next-themes": "^0.2.1", | ||||
|     "pliny": "0.0.10", | ||||
|     "postcss": "^8.4.24", | ||||
|     "react": "18.2.0", | ||||
|     "react-dom": "18.2.0", | ||||
|     "reading-time": "1.5.0", | ||||
|     "rehype-autolink-headings": "^6.1.0", | ||||
|     "rehype-citation": "^0.4.0", | ||||
|     "rehype-katex": "^6.0.2", | ||||
|     "rehype-citation": "^1.0.2", | ||||
|     "rehype-katex": "^6.0.3", | ||||
|     "rehype-preset-minify": "6.0.0", | ||||
|     "rehype-prism-plus": "^1.1.3", | ||||
|     "rehype-slug": "^5.0.0", | ||||
|     "remark-footnotes": "^4.0.1", | ||||
|     "rehype-prism-plus": "^1.6.0", | ||||
|     "rehype-slug": "^5.1.0", | ||||
|     "remark": "^14.0.2", | ||||
|     "remark-gfm": "^3.0.1", | ||||
|     "remark-math": "^5.1.1", | ||||
|     "sharp": "^0.28.3", | ||||
|     "tailwindcss": "^3.0.23", | ||||
|     "unist-util-visit": "^4.0.0" | ||||
|     "tailwindcss": "^3.3.2", | ||||
|     "unist-util-visit": "^4.1.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@svgr/webpack": "^6.1.2", | ||||
|     "@svgr/webpack": "^8.0.1", | ||||
|     "@types/react": "^18.2.14", | ||||
|     "@typescript-eslint/eslint-plugin": "^5.61.0", | ||||
|     "@typescript-eslint/parser": "^5.61.0", | ||||
|     "cross-env": "^7.0.3", | ||||
|     "dedent": "^0.7.0", | ||||
|     "eslint": "^7.29.0", | ||||
|     "eslint-config-next": "12.1.4", | ||||
|     "eslint-config-prettier": "^8.3.0", | ||||
|     "eslint-plugin-prettier": "^3.3.1", | ||||
|     "file-loader": "^6.0.0", | ||||
|     "esbuild-register": "3.4.2", | ||||
|     "eslint": "^8.43.0", | ||||
|     "eslint-config-next": "13.4.7", | ||||
|     "eslint-config-prettier": "^8.8.0", | ||||
|     "eslint-plugin-prettier": "^4.2.1", | ||||
|     "file-loader": "^6.2.0", | ||||
|     "globby": "11.0.3", | ||||
|     "husky": "^6.0.0", | ||||
|     "inquirer": "^8.1.1", | ||||
|     "lint-staged": "^11.0.0", | ||||
|     "next-remote-watch": "^1.0.0", | ||||
|     "prettier": "^2.5.1", | ||||
|     "prettier-plugin-tailwindcss": "^0.1.4", | ||||
|     "socket.io": "^4.4.0", | ||||
|     "socket.io-client": "^4.4.0" | ||||
|     "husky": "^8.0.0", | ||||
|     "js-yaml": "4.1.0", | ||||
|     "lint-staged": "^13.0.0", | ||||
|     "prettier": "^2.8.8", | ||||
|     "prettier-plugin-tailwindcss": "^0.3.0", | ||||
|     "typescript": "^5.1.3" | ||||
|   }, | ||||
|   "lint-staged": { | ||||
|     "*.+(js|jsx|ts|tsx)": [ | ||||
|   | ||||
| @@ -1,31 +0,0 @@ | ||||
| import '@/css/tailwind.css' | ||||
| import '@/css/prism.css' | ||||
| import 'katex/dist/katex.css' | ||||
|  | ||||
| import '@fontsource/inter/variable-full.css' | ||||
|  | ||||
| import { ThemeProvider } from 'next-themes' | ||||
| import Head from 'next/head' | ||||
|  | ||||
| import siteMetadata from '@/data/siteMetadata' | ||||
| import Analytics from '@/components/analytics' | ||||
| import LayoutWrapper from '@/components/LayoutWrapper' | ||||
| import { ClientReload } from '@/components/ClientReload' | ||||
|  | ||||
| const isDevelopment = process.env.NODE_ENV === 'development' | ||||
| const isSocket = process.env.SOCKET | ||||
|  | ||||
| export default function App({ Component, pageProps }) { | ||||
|   return ( | ||||
|     <ThemeProvider attribute="class" defaultTheme={siteMetadata.theme}> | ||||
|       <Head> | ||||
|         <meta content="width=device-width, initial-scale=1" name="viewport" /> | ||||
|       </Head> | ||||
|       {isDevelopment && isSocket && <ClientReload />} | ||||
|       <Analytics /> | ||||
|       <LayoutWrapper> | ||||
|         <Component {...pageProps} /> | ||||
|       </LayoutWrapper> | ||||
|     </ThemeProvider> | ||||
|   ) | ||||
| } | ||||
| @@ -1,36 +0,0 @@ | ||||
| import Document, { Html, Head, Main, NextScript } from 'next/document' | ||||
| class MyDocument extends Document { | ||||
|   render() { | ||||
|     return ( | ||||
|       <Html lang="en" className="scroll-smooth"> | ||||
|         <Head> | ||||
|           <link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" /> | ||||
|           <link | ||||
|             rel="icon" | ||||
|             type="image/png" | ||||
|             sizes="32x32" | ||||
|             href="/static/favicons/favicon-32x32.png" | ||||
|           /> | ||||
|           <link | ||||
|             rel="icon" | ||||
|             type="image/png" | ||||
|             sizes="16x16" | ||||
|             href="/static/favicons/favicon-16x16.png" | ||||
|           /> | ||||
|           <link rel="manifest" href="/static/favicons/site.webmanifest" /> | ||||
|           <link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" /> | ||||
|           <meta name="msapplication-TileColor" content="#000000" /> | ||||
|           <meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" /> | ||||
|           <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" /> | ||||
|           <link rel="alternate" type="application/rss+xml" href="/feed.xml" /> | ||||
|         </Head> | ||||
|         <body className="bg-white text-black antialiased dark:bg-gray-900 dark:text-white"> | ||||
|           <Main /> | ||||
|           <NextScript /> | ||||
|         </body> | ||||
|       </Html> | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default MyDocument | ||||
| @@ -1,21 +0,0 @@ | ||||
| import { MDXLayoutRenderer } from '@/components/MDXComponents' | ||||
| import { getFileBySlug } from '@/lib/mdx' | ||||
|  | ||||
| const DEFAULT_LAYOUT = 'AuthorLayout' | ||||
|  | ||||
| export async function getStaticProps() { | ||||
|   const authorDetails = await getFileBySlug('authors', ['default']) | ||||
|   return { props: { authorDetails } } | ||||
| } | ||||
|  | ||||
| export default function About({ authorDetails }) { | ||||
|   const { mdxSource, frontMatter } = authorDetails | ||||
|  | ||||
|   return ( | ||||
|     <MDXLayoutRenderer | ||||
|       layout={frontMatter.layout || DEFAULT_LAYOUT} | ||||
|       mdxSource={mdxSource} | ||||
|       frontMatter={frontMatter} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| @@ -1,30 +0,0 @@ | ||||
| // eslint-disable-next-line import/no-anonymous-default-export | ||||
| export default async (req, res) => { | ||||
|   const { email } = req.body | ||||
|   if (!email) { | ||||
|     return res.status(400).json({ error: 'Email is required' }) | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const API_KEY = process.env.BUTTONDOWN_API_KEY | ||||
|     const buttondownRoute = `${process.env.BUTTONDOWN_API_URL}subscribers` | ||||
|     const response = await fetch(buttondownRoute, { | ||||
|       body: JSON.stringify({ | ||||
|         email, | ||||
|       }), | ||||
|       headers: { | ||||
|         Authorization: `Token ${API_KEY}`, | ||||
|         'Content-Type': 'application/json', | ||||
|       }, | ||||
|       method: 'POST', | ||||
|     }) | ||||
|  | ||||
|     if (response.status >= 400) { | ||||
|       return res.status(500).json({ error: `There was an error subscribing to the list.` }) | ||||
|     } | ||||
|  | ||||
|     return res.status(201).json({ error: '' }) | ||||
|   } catch (error) { | ||||
|     return res.status(500).json({ error: error.message || error.toString() }) | ||||
|   } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user