├── .husky ├── .gitignore └── pre-commit ├── .eslintignore ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── public └── static │ ├── images │ ├── logo.png │ ├── avatar.png │ ├── google.png │ ├── ocean.jpeg │ ├── canada │ │ ├── lake.jpg │ │ ├── maple.jpg │ │ ├── toronto.jpg │ │ └── mountains.jpg │ ├── time-machine.jpg │ ├── twitter-card.png │ └── sparrowhawk-avatar.jpg │ └── favicons │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-96x96.png │ ├── browserconfig.xml │ ├── site.webmanifest │ └── safari-pinned-tab.svg ├── postcss.config.js ├── lib ├── utils │ ├── kebabCase.js │ ├── formatDate.js │ ├── htmlEscaper.js │ └── files.js ├── remark-extract-frontmatter.js ├── remark-toc-headings.js ├── tags.js ├── remark-code-title.js ├── remark-img-to-jsx.js ├── generate-rss.js └── mdx.js ├── .gitignore ├── components ├── SectionContainer.js ├── Image.js ├── social-icons │ ├── mail.svg │ ├── facebook.svg │ ├── youtube.svg │ ├── twitter.svg │ ├── linkedin.svg │ ├── github.svg │ └── index.js ├── PageTitle.js ├── Tag.js ├── analytics │ ├── Umami.js │ ├── SimpleAnalytics.js │ ├── Plausible.js │ ├── index.js │ ├── GoogleAnalytics.js │ └── Posthog.js ├── Link.js ├── ClientReload.js ├── MDXComponents.js ├── comments │ ├── index.js │ ├── Disqus.js │ ├── Utterances.js │ └── Giscus.js ├── Pagination.js ├── Footer.js ├── ThemeSwitch.js ├── TOCInline.js ├── Card.js ├── Pre.js ├── ScrollTopAndComment.js ├── LayoutWrapper.js ├── MobileNav.js ├── NewsletterForm.js └── SEO.js ├── data ├── blog │ ├── my-fancy-title.md │ ├── code-sample.md │ ├── nested-route │ │ └── introducing-multi-part-posts-with-nested-routing.md │ ├── guide-to-using-images-in-nextjs.mdx │ ├── pictures-of-canada.mdx │ ├── deriving-ols-estimator.mdx │ ├── github-markdown-guide.mdx │ ├── the-time-machine.mdx │ ├── introducing-tailwind-nextjs-starter-blog.mdx │ └── new-features-in-v1.mdx ├── headerNavLinks.js ├── projectsData.js ├── references-data.bib ├── authors │ ├── default.md │ └── sparrowhawk.md ├── logo.svg └── siteMetadata.js ├── prettier.config.js ├── jsconfig.json ├── .eslintrc.js ├── css ├── tailwind.css └── prism.css ├── pages ├── about.js ├── api │ ├── mailchimp.js │ ├── revue.js │ ├── buttondown.js │ ├── convertkit.js │ ├── emailoctopus.js │ └── klaviyo.js ├── _app.js ├── blog.js ├── blog │ ├── [...slug].js │ └── page │ │ └── [page].js ├── projects.js ├── 404.js ├── _document.js ├── tags │ └── [tag].js ├── tags.js └── index.js ├── .env.example ├── LICENSE ├── README.md ├── layouts ├── AuthorLayout.js ├── PostSimple.js ├── ListLayout.js └── PostLayout.js ├── scripts ├── generate-sitemap.js ├── compose.js └── next-remote-watch.js ├── package.json ├── next.config.js ├── tailwind.config.js └── .gitattributes /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: timlrx 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /public/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/logo.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/static/images/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/avatar.png -------------------------------------------------------------------------------- /public/static/images/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/google.png -------------------------------------------------------------------------------- /public/static/images/ocean.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/ocean.jpeg -------------------------------------------------------------------------------- /public/static/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/favicons/favicon.ico -------------------------------------------------------------------------------- /public/static/images/canada/lake.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/canada/lake.jpg -------------------------------------------------------------------------------- /public/static/images/canada/maple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/canada/maple.jpg -------------------------------------------------------------------------------- /public/static/images/time-machine.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/time-machine.jpg -------------------------------------------------------------------------------- /public/static/images/twitter-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/twitter-card.png -------------------------------------------------------------------------------- /public/static/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/static/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/static/images/canada/toronto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/canada/toronto.jpg -------------------------------------------------------------------------------- /public/static/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /public/static/images/canada/mountains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/canada/mountains.jpg -------------------------------------------------------------------------------- /lib/utils/kebabCase.js: -------------------------------------------------------------------------------- 1 | import { slug } from 'github-slugger' 2 | 3 | const kebabCase = (str) => slug(str) 4 | 5 | export default kebabCase 6 | -------------------------------------------------------------------------------- /public/static/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/static/images/sparrowhawk-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/images/sparrowhawk-avatar.jpg -------------------------------------------------------------------------------- /public/static/favicons/android-chrome-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BuilderIO/blog-example/main/public/static/favicons/android-chrome-96x96.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /nbproject/ 2 | /.idea/* 3 | *.tmlanguage.cache 4 | *.tmPreferences.cache 5 | *.stTheme.cache 6 | *.sublime-workspace 7 | *.sublime-project 8 | -------------------------------------------------------------------------------- /components/SectionContainer.js: -------------------------------------------------------------------------------- 1 | export default function SectionContainer({ children }) { 2 | return
{children}
3 | } 4 | -------------------------------------------------------------------------------- /components/Image.js: -------------------------------------------------------------------------------- 1 | import NextImage from 'next/image' 2 | 3 | // eslint-disable-next-line jsx-a11y/alt-text 4 | const Image = ({ ...rest }) => 5 | 6 | export default Image 7 | -------------------------------------------------------------------------------- /data/blog/my-fancy-title.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: My fancy title 3 | date: '2021-01-31' 4 | tags: ['hello'] 5 | draft: true 6 | summary: 7 | images: [] 8 | --- 9 | 10 | Draft post which should not display 11 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | printWidth: 100, 5 | tabWidth: 2, 6 | useTabs: false, 7 | trailingComma: 'es5', 8 | bracketSpacing: true, 9 | } 10 | -------------------------------------------------------------------------------- /components/social-icons/mail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/PageTitle.js: -------------------------------------------------------------------------------- 1 | export default function PageTitle({ children }) { 2 | return ( 3 |

4 | {children} 5 |

6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/components/*": ["components/*"], 6 | "@/data/*": ["data/*"], 7 | "@/layouts/*": ["layouts/*"], 8 | "@/lib/*": ["lib/*"], 9 | "@/css/*": ["css/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /public/static/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #000000 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/remark-extract-frontmatter.js: -------------------------------------------------------------------------------- 1 | import { visit } from 'unist-util-visit' 2 | import { load } from 'js-yaml' 3 | 4 | export default function extractFrontmatter() { 5 | return (tree, file) => { 6 | visit(tree, 'yaml', (node, index, parent) => { 7 | file.data.frontmatter = load(node.value) 8 | }) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /public/static/favicons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-96x96.png", 7 | "sizes": "96x96", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#000000", 12 | "background_color": "#000000", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /data/headerNavLinks.js: -------------------------------------------------------------------------------- 1 | const headerNavLinks = [ 2 | { href: '/blog', title: 'Blog' }, 3 | { href: '/tags', title: 'Tags' }, 4 | { href: '/projects', title: 'Projects' }, 5 | { href: '/about', title: 'About' }, 6 | { href: 'https://github.com/BuilderIO/blog-example', title: 'Source Code', external: true }, 7 | ] 8 | 9 | export default headerNavLinks 10 | -------------------------------------------------------------------------------- /lib/utils/formatDate.js: -------------------------------------------------------------------------------- 1 | import siteMetadata from '@/data/siteMetadata' 2 | 3 | const formatDate = (date) => { 4 | const options = { 5 | year: 'numeric', 6 | month: 'long', 7 | day: 'numeric', 8 | } 9 | const now = new Date(date).toLocaleDateString(siteMetadata.locale, options) 10 | 11 | return now 12 | } 13 | 14 | export default formatDate 15 | -------------------------------------------------------------------------------- /components/social-icons/facebook.svg: -------------------------------------------------------------------------------- 1 | Facebook icon -------------------------------------------------------------------------------- /components/Tag.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import kebabCase from '@/lib/utils/kebabCase' 3 | 4 | const Tag = ({ text }) => { 5 | return ( 6 | 7 | 8 | {text.split(' ').join('-')} 9 | 10 | 11 | ) 12 | } 13 | 14 | export default Tag 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | amd: true, 6 | node: true, 7 | es6: true, 8 | }, 9 | extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'], 10 | rules: { 11 | 'prettier/prettier': 'error', 12 | 'react/react-in-jsx-scope': 'off', 13 | 'react/prop-types': 0, 14 | 'no-unused-vars': 0, 15 | 'react/no-unescaped-entities': 0, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /components/analytics/Umami.js: -------------------------------------------------------------------------------- 1 | import Script from 'next/script' 2 | 3 | import siteMetadata from '@/data/siteMetadata' 4 | 5 | const UmamiScript = () => { 6 | return ( 7 | <> 8 | 11 | 18 | 19 | ) 20 | } 21 | 22 | export default PlausibleScript 23 | 24 | // https://plausible.io/docs/custom-event-goals 25 | export const logEvent = (eventName, ...rest) => { 26 | return window.plausible?.(eventName, ...rest) 27 | } 28 | -------------------------------------------------------------------------------- /data/references-data.bib: -------------------------------------------------------------------------------- 1 | @article{Nash1950, 2 | title={Equilibrium points in n-person games}, 3 | author={Nash, John}, 4 | journal={Proceedings of the national academy of sciences}, 5 | volume={36}, 6 | number={1}, 7 | pages={48--49}, 8 | year={1950}, 9 | publisher={USA} 10 | } 11 | 12 | @article{Nash1951, 13 | title={Non-cooperative games}, 14 | author={Nash, John}, 15 | journal={Annals of mathematics}, 16 | pages={286--295}, 17 | year={1951}, 18 | publisher={JSTOR} 19 | } 20 | 21 | @Manual{Macfarlane2006, 22 | url={https://pandoc.org/}, 23 | title={Pandoc: a universal document converter}, 24 | author={MacFarlane, John}, 25 | year={2006} 26 | } 27 | 28 | @book{Xie2016, 29 | title={Bookdown: authoring books and technical documents with R markdown}, 30 | author={Xie, Yihui}, 31 | year={2016}, 32 | publisher={CRC Press} 33 | } 34 | -------------------------------------------------------------------------------- /components/analytics/index.js: -------------------------------------------------------------------------------- 1 | import GA from './GoogleAnalytics' 2 | import Plausible from './Plausible' 3 | import SimpleAnalytics from './SimpleAnalytics' 4 | import Umami from './Umami' 5 | import Posthog from './Posthog' 6 | import siteMetadata from '@/data/siteMetadata' 7 | 8 | const isProduction = process.env.NODE_ENV === 'production' 9 | 10 | const Analytics = () => { 11 | return ( 12 | <> 13 | {isProduction && siteMetadata.analytics.plausibleDataDomain && } 14 | {isProduction && siteMetadata.analytics.simpleAnalytics && } 15 | {isProduction && siteMetadata.analytics.umamiWebsiteId && } 16 | {isProduction && siteMetadata.analytics.googleAnalyticsId && } 17 | {isProduction && siteMetadata.analytics.posthogAnalyticsId && } 18 | 19 | ) 20 | } 21 | 22 | export default Analytics 23 | -------------------------------------------------------------------------------- /components/MDXComponents.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import { useMemo } from 'react' 3 | import { getMDXComponent } from 'mdx-bundler/client' 4 | import Image from './Image' 5 | import CustomLink from './Link' 6 | import TOCInline from './TOCInline' 7 | import Pre from './Pre' 8 | import { BlogNewsletterForm } from './NewsletterForm' 9 | 10 | export const MDXComponents = { 11 | Image, 12 | TOCInline, 13 | a: CustomLink, 14 | pre: Pre, 15 | BlogNewsletterForm: BlogNewsletterForm, 16 | wrapper: ({ components, layout, ...rest }) => { 17 | const Layout = require(`../layouts/${layout}`).default 18 | return 19 | }, 20 | } 21 | 22 | export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => { 23 | const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]) 24 | 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /lib/tags.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import matter from 'gray-matter' 3 | import path from 'path' 4 | import { getFiles } from './mdx' 5 | import kebabCase from './utils/kebabCase' 6 | 7 | const root = process.cwd() 8 | 9 | export async function getAllTags(type) { 10 | const files = await getFiles(type) 11 | 12 | let tagCount = {} 13 | // Iterate through each post, putting all found tags into `tags` 14 | files.forEach((file) => { 15 | const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8') 16 | const { data } = matter(source) 17 | if (data.tags && data.draft !== true) { 18 | data.tags.forEach((tag) => { 19 | const formattedTag = kebabCase(tag) 20 | if (formattedTag in tagCount) { 21 | tagCount[formattedTag] += 1 22 | } else { 23 | tagCount[formattedTag] = 1 24 | } 25 | }) 26 | } 27 | }) 28 | 29 | return tagCount 30 | } 31 | -------------------------------------------------------------------------------- /data/authors/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tails Azimuth 3 | avatar: /static/images/avatar.png 4 | occupation: Professor of Atmospheric Science 5 | company: Stanford University 6 | email: address@yoursite.com 7 | twitter: https://twitter.com/Twitter 8 | linkedin: https://www.linkedin.com 9 | github: https://github.com 10 | --- 11 | 12 | Tails Azimuth is a professor of atmospheric sciences at the Stanford AI Lab. His research interests includes complexity modelling of tailwinds, headwinds and crosswinds. 13 | 14 | He leads the clean energy group which develops 3D air pollution-climate models, writes differential equation solvers, and manufactures titanium plated air ballons. In his free time he bakes raspberry pi. 15 | 16 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate. 17 | -------------------------------------------------------------------------------- /data/authors/sparrowhawk.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sparrow Hawk 3 | avatar: /static/images/sparrowhawk-avatar.jpg 4 | occupation: Wizard of Earthsea 5 | company: Earthsea 6 | twitter: https://twitter.com/sparrowhawk 7 | linkedin: https://www.linkedin.com/sparrowhawk 8 | --- 9 | 10 | At birth, Ged was given the child-name Duny by his mother. He was born on the island of Gont, as a son of a bronzesmith. His mother died before he reached the age of one. As a small boy, Ged had overheard the village witch, his maternal aunt, using various words of power to call goats. Ged later used the words without an understanding of their meanings, to surprising effect. 11 | 12 | The witch knew that using words of power effectively without understanding them required innate power, so she endeavored to teach him what little she knew. After learning more from her, he was able to call animals to him. Particularly, he was seen in the company of wild sparrowhawks so often that his "use name" became Sparrowhawk. 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System Info (if dev / build issue):** 27 | - OS: [e.g. iOS] 28 | - Node version (please ensure you are using 14+) 29 | - Npm version 30 | 31 | **Browser Info (if display / formatting issue):** 32 | - Device [e.g. Desktop, iPhone6] 33 | - Browser [e.g. chrome, safari] 34 | - Version [e.g. 22] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /pages/api/revue.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-anonymous-default-export 2 | export default async (req, res) => { 3 | const { email } = req.body 4 | 5 | if (!email) { 6 | return res.status(400).json({ error: 'Email is required' }) 7 | } 8 | 9 | try { 10 | const API_KEY = process.env.REVUE_API_KEY 11 | const revueRoute = `${process.env.REVUE_API_URL}subscribers` 12 | 13 | const response = await fetch(revueRoute, { 14 | method: 'POST', 15 | headers: { 16 | Authorization: `Token ${API_KEY}`, 17 | 'Content-Type': 'application/json', 18 | }, 19 | body: JSON.stringify({ email, double_opt_in: false }), 20 | }) 21 | 22 | if (response.status >= 400) { 23 | return res.status(500).json({ error: `There was an error subscribing to the list.` }) 24 | } 25 | 26 | return res.status(201).json({ error: '' }) 27 | } catch (error) { 28 | return res.status(500).json({ error: error.message || error.toString() }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/remark-code-title.js: -------------------------------------------------------------------------------- 1 | import { visit } from 'unist-util-visit' 2 | 3 | export default function remarkCodeTitles() { 4 | return (tree) => 5 | visit(tree, 'code', (node, index, parent) => { 6 | const nodeLang = node.lang || '' 7 | let language = '' 8 | let title = '' 9 | 10 | if (nodeLang.includes(':')) { 11 | language = nodeLang.slice(0, nodeLang.search(':')) 12 | title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length) 13 | } 14 | 15 | if (!title) { 16 | return 17 | } 18 | 19 | const className = 'remark-code-title' 20 | 21 | const titleNode = { 22 | type: 'mdxJsxFlowElement', 23 | name: 'div', 24 | attributes: [{ type: 'mdxJsxAttribute', name: 'className', value: className }], 25 | children: [{ type: 'text', value: title }], 26 | data: { _xdmExplicitJsx: true }, 27 | } 28 | 29 | parent.children.splice(index, 0, titleNode) 30 | node.lang = language 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /pages/api/buttondown.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-anonymous-default-export 2 | export default async (req, res) => { 3 | const { email } = req.body 4 | if (!email) { 5 | return res.status(400).json({ error: 'Email is required' }) 6 | } 7 | 8 | try { 9 | const API_KEY = process.env.BUTTONDOWN_API_KEY 10 | const buttondownRoute = `${process.env.BUTTONDOWN_API_URL}subscribers` 11 | const response = await fetch(buttondownRoute, { 12 | body: JSON.stringify({ 13 | email, 14 | }), 15 | headers: { 16 | Authorization: `Token ${API_KEY}`, 17 | 'Content-Type': 'application/json', 18 | }, 19 | method: 'POST', 20 | }) 21 | 22 | if (response.status >= 400) { 23 | return res.status(500).json({ error: `There was an error subscribing to the list.` }) 24 | } 25 | 26 | return res.status(201).json({ error: '' }) 27 | } catch (error) { 28 | return res.status(500).json({ error: error.message || error.toString() }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import '@/css/tailwind.css' 2 | import '@/css/prism.css' 3 | import 'katex/dist/katex.css' 4 | 5 | import '@fontsource/inter/variable-full.css' 6 | 7 | import { ThemeProvider } from 'next-themes' 8 | import Head from 'next/head' 9 | 10 | import siteMetadata from '@/data/siteMetadata' 11 | import Analytics from '@/components/analytics' 12 | import LayoutWrapper from '@/components/LayoutWrapper' 13 | import { ClientReload } from '@/components/ClientReload' 14 | 15 | const isDevelopment = process.env.NODE_ENV === 'development' 16 | const isSocket = process.env.SOCKET 17 | 18 | export default function App({ Component, pageProps }) { 19 | return ( 20 | 21 | 22 | 23 | 24 | {isDevelopment && isSocket && } 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /pages/blog.js: -------------------------------------------------------------------------------- 1 | import { getAllFilesFrontMatter } from '@/lib/mdx' 2 | import siteMetadata from '@/data/siteMetadata' 3 | import ListLayout from '@/layouts/ListLayout' 4 | import { PageSEO } from '@/components/SEO' 5 | 6 | export const POSTS_PER_PAGE = 5 7 | 8 | export async function getStaticProps() { 9 | const posts = await getAllFilesFrontMatter('blog') 10 | const initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE) 11 | const pagination = { 12 | currentPage: 1, 13 | totalPages: Math.ceil(posts.length / POSTS_PER_PAGE), 14 | } 15 | 16 | return { 17 | props: { 18 | initialDisplayPosts, 19 | posts, 20 | pagination, 21 | }, 22 | } 23 | } 24 | 25 | export default function Blog({ posts, initialDisplayPosts, pagination }) { 26 | return ( 27 | <> 28 | 29 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /data/blog/code-sample.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sample .md file 3 | date: '2016-03-08' 4 | tags: ['markdown', 'code', 'features'] 5 | draft: false 6 | summary: Example of a markdown file with code blocks and syntax highlighting 7 | --- 8 | 9 | A sample post with markdown. 10 | 11 | ## Inline Highlighting 12 | 13 | Sample of inline highlighting `sum = parseInt(num1) + parseInt(num2)` 14 | 15 | ## Code Blocks 16 | 17 | Some Javascript code 18 | 19 | ```javascript 20 | var num1, num2, sum 21 | num1 = prompt('Enter first number') 22 | num2 = prompt('Enter second number') 23 | sum = parseInt(num1) + parseInt(num2) // "+" means "add" 24 | alert('Sum = ' + sum) // "+" means combine into a string 25 | ``` 26 | 27 | Some Python code 🐍 28 | 29 | ```python 30 | def fib(): 31 | a, b = 0, 1 32 | while True: # First iteration: 33 | yield a # yield 0 to start with and then 34 | a, b = b, a + b # a will now be 1, and b will also be 1, (0 + 1) 35 | 36 | for index, fibonacci_number in zip(range(10), fib()): 37 | print('{i:3}: {f:3}'.format(i=index, f=fibonacci_number)) 38 | ``` 39 | -------------------------------------------------------------------------------- /pages/api/convertkit.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | export default async (req, res) => { 3 | const { email } = req.body 4 | 5 | if (!email) { 6 | return res.status(400).json({ error: 'Email is required' }) 7 | } 8 | 9 | try { 10 | const FORM_ID = process.env.CONVERTKIT_FORM_ID 11 | const API_KEY = process.env.CONVERTKIT_API_KEY 12 | const API_URL = process.env.CONVERTKIT_API_URL 13 | 14 | // Send request to ConvertKit 15 | const data = { email, api_key: API_KEY } 16 | 17 | const response = await fetch(`${API_URL}forms/${FORM_ID}/subscribe`, { 18 | body: JSON.stringify(data), 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | }, 22 | method: 'POST', 23 | }) 24 | 25 | if (response.status >= 400) { 26 | return res.status(400).json({ 27 | error: `There was an error subscribing to the list.`, 28 | }) 29 | } 30 | 31 | return res.status(201).json({ error: '' }) 32 | } catch (error) { 33 | return res.status(500).json({ error: error.message || error.toString() }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pages/api/emailoctopus.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-anonymous-default-export 2 | export default async (req, res) => { 3 | const { email } = req.body 4 | if (!email) { 5 | return res.status(400).json({ error: 'Email is required' }) 6 | } 7 | 8 | try { 9 | const API_URL = process.env.EMAILOCTOPUS_API_URL 10 | const API_KEY = process.env.EMAILOCTOPUS_API_KEY 11 | const LIST_ID = process.env.EMAILOCTOPUS_LIST_ID 12 | 13 | const data = { email_address: email, api_key: API_KEY } 14 | 15 | const API_ROUTE = `${API_URL}lists/${LIST_ID}/contacts` 16 | 17 | const response = await fetch(API_ROUTE, { 18 | body: JSON.stringify(data), 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | }, 22 | method: 'POST', 23 | }) 24 | 25 | if (response.status >= 400) { 26 | return res.status(500).json({ error: `There was an error subscribing to the list.` }) 27 | } 28 | 29 | return res.status(201).json({ error: '' }) 30 | } catch (error) { 31 | return res.status(500).json({ error: error.message || error.toString() }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pages/blog/[...slug].js: -------------------------------------------------------------------------------- 1 | import PageTitle from '@/components/PageTitle' 2 | import { BuilderComponent, BuilderContent, builder } from '@builder.io/react' 3 | import '../../builder.config' 4 | 5 | builder.init('ccda6c7abf4c4b8195aa67d47de420dd') 6 | 7 | export async function getStaticPaths() { 8 | const posts = await builder.getAll('blog-post', { 9 | fields: 'data.slug', 10 | }) 11 | return { 12 | paths: posts.map(({ data }) => ({ 13 | params: { 14 | slug: data.slug?.split('/') || '', 15 | }, 16 | })), 17 | fallback: 'blocking', 18 | } 19 | } 20 | 21 | export async function getStaticProps({ params }) { 22 | const post = await builder 23 | .get('blog-post', { 24 | query: { 25 | slug: params.slug.join('/'), 26 | }, 27 | }) 28 | .promise() 29 | 30 | return { props: { post: post || null } } 31 | } 32 | 33 | export default function Blog({ post }) { 34 | // TODO: add your own 404 page/handling like described 35 | // here: https://www.builder.io/c/docs/integrating-builder-pages 36 | return 37 | } 38 | -------------------------------------------------------------------------------- /components/analytics/GoogleAnalytics.js: -------------------------------------------------------------------------------- 1 | import Script from 'next/script' 2 | 3 | import siteMetadata from '@/data/siteMetadata' 4 | 5 | const GAScript = () => { 6 | return ( 7 | <> 8 | 23 | 24 | ) 25 | } 26 | 27 | export default GAScript 28 | 29 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events 30 | export const logEvent = (action, category, label, value) => { 31 | window.gtag?.('event', action, { 32 | event_category: category, 33 | event_label: label, 34 | value: value, 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Timothy Lin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/comments/index.js: -------------------------------------------------------------------------------- 1 | import siteMetadata from '@/data/siteMetadata' 2 | import dynamic from 'next/dynamic' 3 | 4 | const UtterancesComponent = dynamic( 5 | () => { 6 | return import('@/components/comments/Utterances') 7 | }, 8 | { ssr: false } 9 | ) 10 | const GiscusComponent = dynamic( 11 | () => { 12 | return import('@/components/comments/Giscus') 13 | }, 14 | { ssr: false } 15 | ) 16 | const DisqusComponent = dynamic( 17 | () => { 18 | return import('@/components/comments/Disqus') 19 | }, 20 | { ssr: false } 21 | ) 22 | 23 | const Comments = ({ frontMatter }) => { 24 | const comment = siteMetadata?.comment 25 | if (!comment || Object.keys(comment).length === 0) return <> 26 | return ( 27 |
28 | {siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && } 29 | {siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && ( 30 | 31 | )} 32 | {siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && ( 33 | 34 | )} 35 |
36 | ) 37 | } 38 | 39 | export default Comments 40 | -------------------------------------------------------------------------------- /public/static/favicons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 16 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /pages/api/klaviyo.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | export default async (req, res) => { 3 | const { email } = req.body 4 | if (!email) { 5 | return res.status(400).json({ error: 'Email is required' }) 6 | } 7 | 8 | try { 9 | const API_KEY = process.env.KLAVIYO_API_KEY 10 | const LIST_ID = process.env.KLAVIYO_LIST_ID 11 | const response = await fetch( 12 | `https://a.klaviyo.com/api/v2/list/${LIST_ID}/subscribe?api_key=${API_KEY}`, 13 | { 14 | method: 'POST', 15 | headers: { 16 | Accept: 'application/json', 17 | 'Content-Type': 'application/json', 18 | }, 19 | // You can add additional params here i.e. SMS, etc. 20 | // https://developers.klaviyo.com/en/reference/subscribe 21 | body: JSON.stringify({ 22 | profiles: [{ email: email }], 23 | }), 24 | } 25 | ) 26 | if (response.status >= 400) { 27 | return res.status(400).json({ 28 | error: `There was an error subscribing to the list.`, 29 | }) 30 | } 31 | return res.status(201).json({ error: '' }) 32 | } catch (error) { 33 | return res.status(500).json({ error: error.message || error.toString() }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /components/Pagination.js: -------------------------------------------------------------------------------- 1 | import Link from '@/components/Link' 2 | 3 | export default function Pagination({ totalPages, currentPage }) { 4 | const prevPage = parseInt(currentPage) - 1 > 0 5 | const nextPage = parseInt(currentPage) + 1 <= parseInt(totalPages) 6 | 7 | return ( 8 |
9 | 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /components/comments/Disqus.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import siteMetadata from '@/data/siteMetadata' 4 | 5 | const Disqus = ({ frontMatter }) => { 6 | const [enableLoadComments, setEnabledLoadComments] = useState(true) 7 | 8 | const COMMENTS_ID = 'disqus_thread' 9 | 10 | function LoadComments() { 11 | setEnabledLoadComments(false) 12 | 13 | window.disqus_config = function () { 14 | this.page.url = window.location.href 15 | this.page.identifier = frontMatter.slug 16 | } 17 | if (window.DISQUS === undefined) { 18 | const script = document.createElement('script') 19 | script.src = 'https://' + siteMetadata.comment.disqusConfig.shortname + '.disqus.com/embed.js' 20 | script.setAttribute('data-timestamp', +new Date()) 21 | script.setAttribute('crossorigin', 'anonymous') 22 | script.async = true 23 | document.body.appendChild(script) 24 | } else { 25 | window.DISQUS.reset({ reload: true }) 26 | } 27 | } 28 | 29 | return ( 30 |
31 | {enableLoadComments && } 32 |
33 |
34 | ) 35 | } 36 | 37 | export default Disqus 38 | -------------------------------------------------------------------------------- /data/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /pages/projects.js: -------------------------------------------------------------------------------- 1 | import siteMetadata from '@/data/siteMetadata' 2 | import projectsData from '@/data/projectsData' 3 | import Card from '@/components/Card' 4 | import { PageSEO } from '@/components/SEO' 5 | 6 | export default function Projects() { 7 | return ( 8 | <> 9 | 10 |
11 |
12 |

13 | Projects 14 |

15 |

16 | Showcase your projects with a hero image (16 x 9) 17 |

18 |
19 |
20 |
21 | {projectsData.map((d) => ( 22 | 29 | ))} 30 |
31 |
32 |
33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /components/analytics/Posthog.js: -------------------------------------------------------------------------------- 1 | import Script from 'next/script' 2 | 3 | import siteMetadata from '@/data/siteMetadata' 4 | 5 | const PosthogScript = () => { 6 | return ( 7 | <> 8 | 14 | 15 | ) 16 | } 17 | 18 | export default PosthogScript 19 | -------------------------------------------------------------------------------- /lib/remark-img-to-jsx.js: -------------------------------------------------------------------------------- 1 | import { visit } from 'unist-util-visit' 2 | import sizeOf from 'image-size' 3 | import fs from 'fs' 4 | 5 | export default function remarkImgToJsx() { 6 | return (tree) => { 7 | visit( 8 | tree, 9 | // only visit p tags that contain an img element 10 | (node) => node.type === 'paragraph' && node.children.some((n) => n.type === 'image'), 11 | (node) => { 12 | const imageNode = node.children.find((n) => n.type === 'image') 13 | 14 | // only local files 15 | if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) { 16 | const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`) 17 | 18 | // Convert original node to next/image 19 | ;(imageNode.type = 'mdxJsxFlowElement'), 20 | (imageNode.name = 'Image'), 21 | (imageNode.attributes = [ 22 | { type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt }, 23 | { type: 'mdxJsxAttribute', name: 'src', value: imageNode.url }, 24 | { type: 'mdxJsxAttribute', name: 'width', value: dimensions.width }, 25 | { type: 'mdxJsxAttribute', name: 'height', value: dimensions.height }, 26 | ]) 27 | 28 | // Change node type from p to div to avoid nesting error 29 | node.type = 'div' 30 | node.children = [imageNode] 31 | } 32 | } 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /components/Footer.js: -------------------------------------------------------------------------------- 1 | import Link from './Link' 2 | import siteMetadata from '@/data/siteMetadata' 3 | import SocialIcon from '@/components/social-icons' 4 | 5 | export default function Footer() { 6 | return ( 7 |
8 |
9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
{siteMetadata.author}
19 |
{` • `}
20 |
{`© ${new Date().getFullYear()}`}
21 |
{` • `}
22 | {siteMetadata.title} 23 |
24 |
25 | 26 | Tailwind Nextjs Theme 27 | 28 |
29 |
30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /pages/404.js: -------------------------------------------------------------------------------- 1 | import Link from '@/components/Link' 2 | import { PageSEO } from '@/components/SEO' 3 | import siteMetadata from '@/data/siteMetadata' 4 | 5 | export default function FourZeroFour() { 6 | return ( 7 | <> 8 | 9 |
10 |
11 |

12 | 404 13 |

14 |
15 |
16 |

17 | Sorry we couldn't find this page. 18 |

19 |

20 | But dont worry, you can find plenty of other things on our homepage. 21 |

22 | 23 | 26 | 27 |
28 |
29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | class MyDocument extends Document { 3 | render() { 4 | return ( 5 | 6 | 7 | 8 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | ) 33 | } 34 | } 35 | 36 | export default MyDocument 37 | -------------------------------------------------------------------------------- /lib/generate-rss.js: -------------------------------------------------------------------------------- 1 | import { escape } from '@/lib/utils/htmlEscaper' 2 | 3 | import siteMetadata from '@/data/siteMetadata' 4 | 5 | const generateRssItem = (post) => ` 6 | 7 | ${siteMetadata.siteUrl}/blog/${post.slug} 8 | ${escape(post.title)} 9 | ${siteMetadata.siteUrl}/blog/${post.slug} 10 | ${post.summary && `${escape(post.summary)}`} 11 | ${new Date(post.date).toUTCString()} 12 | ${siteMetadata.email} (${siteMetadata.author}) 13 | ${post.tags && post.tags.map((t) => `${t}`).join('')} 14 | 15 | ` 16 | 17 | const generateRss = (posts, page = 'feed.xml') => ` 18 | 19 | 20 | ${escape(siteMetadata.title)} 21 | ${siteMetadata.siteUrl}/blog 22 | ${escape(siteMetadata.description)} 23 | ${siteMetadata.language} 24 | ${siteMetadata.email} (${siteMetadata.author}) 25 | ${siteMetadata.email} (${siteMetadata.author}) 26 | ${new Date(posts[0].date).toUTCString()} 27 | 28 | ${posts.map(generateRssItem).join('')} 29 | 30 | 31 | ` 32 | export default generateRss 33 | -------------------------------------------------------------------------------- /data/blog/nested-route/introducing-multi-part-posts-with-nested-routing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introducing Multi-part Posts with Nested Routing 3 | date: '2021-05-02' 4 | tags: ['multi-author', 'next-js', 'feature'] 5 | draft: false 6 | summary: 'The blog template supports posts in nested sub-folders. This can be used to group posts of similar content e.g. a multi-part course. This post is itself an example of a nested route!' 7 | --- 8 | 9 | # Nested Routes 10 | 11 | The blog template supports posts in nested sub-folders. This helps in organisation and can be used to group posts of similar content e.g. a multi-part series. This post is itself an example of a nested route! It's located in the `/data/blog/nested-route` folder. 12 | 13 | ## How 14 | 15 | Simplify create multiple folders inside the main `/data/blog` folder and add your `.md`/`.mdx` files to them. You can even create something like `/data/blog/nested-route/deeply-nested-route/my-post.md` 16 | 17 | We use Next.js catch all routes to handle the routing and path creations. 18 | 19 | ## Use Cases 20 | 21 | Here are some reasons to use nested routes 22 | 23 | - More logical content organisation (blogs will still be displayed based on the created date) 24 | - Multi-part posts 25 | - Different sub-routes for each author 26 | - Internationalization (though it would be recommended to use [Next.js built-in i8n routing](https://nextjs.org/docs/advanced-features/i18n-routing)) 27 | 28 | ## Note 29 | 30 | - The previous/next post links at bottom of the template are currently sorted by date. One could explore modifying the template to refer the reader to the previous/next post in the series, rather than by date. 31 | -------------------------------------------------------------------------------- /pages/blog/page/[page].js: -------------------------------------------------------------------------------- 1 | import { PageSEO } from '@/components/SEO' 2 | import siteMetadata from '@/data/siteMetadata' 3 | import { getAllFilesFrontMatter } from '@/lib/mdx' 4 | import ListLayout from '@/layouts/ListLayout' 5 | import { POSTS_PER_PAGE } from '../../blog' 6 | 7 | export async function getStaticPaths() { 8 | const totalPosts = await getAllFilesFrontMatter('blog') 9 | const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE) 10 | const paths = Array.from({ length: totalPages }, (_, i) => ({ 11 | params: { page: (i + 1).toString() }, 12 | })) 13 | 14 | return { 15 | paths, 16 | fallback: false, 17 | } 18 | } 19 | 20 | export async function getStaticProps(context) { 21 | const { 22 | params: { page }, 23 | } = context 24 | const posts = await getAllFilesFrontMatter('blog') 25 | const pageNumber = parseInt(page) 26 | const initialDisplayPosts = posts.slice( 27 | POSTS_PER_PAGE * (pageNumber - 1), 28 | POSTS_PER_PAGE * pageNumber 29 | ) 30 | const pagination = { 31 | currentPage: pageNumber, 32 | totalPages: Math.ceil(posts.length / POSTS_PER_PAGE), 33 | } 34 | 35 | return { 36 | props: { 37 | posts, 38 | initialDisplayPosts, 39 | pagination, 40 | }, 41 | } 42 | } 43 | 44 | export default function PostPage({ posts, initialDisplayPosts, pagination }) { 45 | return ( 46 | <> 47 | 48 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /components/ThemeSwitch.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useTheme } from 'next-themes' 3 | 4 | const ThemeSwitch = () => { 5 | const [mounted, setMounted] = useState(false) 6 | const { theme, setTheme, resolvedTheme } = useTheme() 7 | 8 | // When mounted on client, now we can show the UI 9 | useEffect(() => setMounted(true), []) 10 | 11 | return ( 12 | 35 | ) 36 | } 37 | 38 | export default ThemeSwitch 39 | -------------------------------------------------------------------------------- /components/TOCInline.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef TocHeading 3 | * @prop {string} value 4 | * @prop {number} depth 5 | * @prop {string} url 6 | */ 7 | 8 | /** 9 | * Generates an inline table of contents 10 | * Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')). 11 | * If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')). 12 | * 13 | * @param {{ 14 | * toc: TocHeading[], 15 | * indentDepth?: number, 16 | * fromHeading?: number, 17 | * toHeading?: number, 18 | * asDisclosure?: boolean, 19 | * exclude?: string|string[] 20 | * }} props 21 | * 22 | */ 23 | const TOCInline = ({ 24 | toc, 25 | indentDepth = 3, 26 | fromHeading = 1, 27 | toHeading = 6, 28 | asDisclosure = false, 29 | exclude = '', 30 | }) => { 31 | const re = Array.isArray(exclude) 32 | ? new RegExp('^(' + exclude.join('|') + ')$', 'i') 33 | : new RegExp('^(' + exclude + ')$', 'i') 34 | 35 | const filteredToc = toc.filter( 36 | (heading) => 37 | heading.depth >= fromHeading && heading.depth <= toHeading && !re.test(heading.value) 38 | ) 39 | 40 | const tocList = ( 41 |
    42 | {filteredToc.map((heading) => ( 43 |
  • = indentDepth && 'ml-6'}`}> 44 | {heading.value} 45 |
  • 46 | ))} 47 |
48 | ) 49 | 50 | return ( 51 | <> 52 | {asDisclosure ? ( 53 |
54 | Table of Contents 55 |
{tocList}
56 |
57 | ) : ( 58 | tocList 59 | )} 60 | 61 | ) 62 | } 63 | 64 | export default TOCInline 65 | -------------------------------------------------------------------------------- /pages/tags/[tag].js: -------------------------------------------------------------------------------- 1 | import { TagSEO } from '@/components/SEO' 2 | import siteMetadata from '@/data/siteMetadata' 3 | import ListLayout from '@/layouts/ListLayout' 4 | import generateRss from '@/lib/generate-rss' 5 | import { getAllFilesFrontMatter } from '@/lib/mdx' 6 | import { getAllTags } from '@/lib/tags' 7 | import kebabCase from '@/lib/utils/kebabCase' 8 | import fs from 'fs' 9 | import path from 'path' 10 | 11 | const root = process.cwd() 12 | 13 | export async function getStaticPaths() { 14 | const tags = await getAllTags('blog') 15 | 16 | return { 17 | paths: Object.keys(tags).map((tag) => ({ 18 | params: { 19 | tag, 20 | }, 21 | })), 22 | fallback: false, 23 | } 24 | } 25 | 26 | export async function getStaticProps({ params }) { 27 | const allPosts = await getAllFilesFrontMatter('blog') 28 | const filteredPosts = allPosts.filter( 29 | (post) => post.draft !== true && post.tags.map((t) => kebabCase(t)).includes(params.tag) 30 | ) 31 | 32 | // rss 33 | if (filteredPosts.length > 0) { 34 | const rss = generateRss(filteredPosts, `tags/${params.tag}/feed.xml`) 35 | const rssPath = path.join(root, 'public', 'tags', params.tag) 36 | fs.mkdirSync(rssPath, { recursive: true }) 37 | fs.writeFileSync(path.join(rssPath, 'feed.xml'), rss) 38 | } 39 | 40 | return { props: { posts: filteredPosts, tag: params.tag } } 41 | } 42 | 43 | export default function Tag({ posts, tag }) { 44 | // Capitalize first letter and convert space to dash 45 | const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1) 46 | return ( 47 | <> 48 | 52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /pages/tags.js: -------------------------------------------------------------------------------- 1 | import Link from '@/components/Link' 2 | import { PageSEO } from '@/components/SEO' 3 | import Tag from '@/components/Tag' 4 | import siteMetadata from '@/data/siteMetadata' 5 | import { getAllTags } from '@/lib/tags' 6 | import kebabCase from '@/lib/utils/kebabCase' 7 | 8 | export async function getStaticProps() { 9 | const tags = await getAllTags('blog') 10 | 11 | return { props: { tags } } 12 | } 13 | 14 | export default function Tags({ tags }) { 15 | const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a]) 16 | return ( 17 | <> 18 | 19 |
20 |
21 |

22 | Tags 23 |

24 |
25 |
26 | {Object.keys(tags).length === 0 && 'No tags found.'} 27 | {sortedTags.map((t) => { 28 | return ( 29 |
30 | 31 | 35 | {` (${tags[t]})`} 36 | 37 |
38 | ) 39 | })} 40 |
41 |
42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Builder.io Blog Example 2 | 3 | This is an example of a blog with [Builder.io](https://www.builder.io/m/developers), [Tailwind](https://github.com/tailwindlabs/tailwindcss), and [Next.js](https://github.com/vercel/next.js/), 4 | 5 | We forked [TailwindBlog](https://github.com/timlrx/tailwind-nextjs-starter-blog) and added a Builder.io integation [here](https://github.com/BuilderIO/blog-example/blob/main/pages/blog/%5B...slug%5D.js#L1), and configured some custom components [here](./builder.config.js) 6 | 7 | [Try it yourself here!](https://builder.io/demo/blog/example?demoHost=blog-example-builder-io.vercel.app&demoModel=blog-post&demoPath=/blog/example) 8 | 9 | ![Gif of using this example](https://user-images.githubusercontent.com/844291/188522506-1b3c77d7-6a17-471d-bff6-ba36fc097892.gif) 10 | 11 | ## Getting Started 12 | 13 | 1. Create a free account with [Builder.io](https://www.builder.io/) 14 | 2. In your Builder space, create a [section model](https://www.builder.io/c/docs/models-sections) named `blog-post` 15 | 3. Set the [Preview URL](https://www.builder.io/c/docs/guides/preview-url) of your model to `http://localhost:3000` 16 | 4. Clone this repo: `git clone https://github.com/BuilderIO/blog-example.git` 17 | 5. Install dependencies: `npm install` 18 | 6. Run the development server: `npm run dev` 19 | 7. Open Builder.io and make some blog posts! 20 | 21 | To go live, be sure to also add your public API key (find in your [account settings](builder.io/account)) [here](https://github.com/BuilderIO/blog-example/blob/main/pages/blog/%5B...slug%5D.js#L4). You additionally may want to add some [custom fields](https://www.builder.io/c/docs/custom-fields) for the blog post `title` and `slug` 22 | 23 | > ℹ️ **For an in-depth walkthrough on how to create a blog with Builder.io, see our [full tutorial](https://www.builder.io/blog/creating-blog)** 24 | -------------------------------------------------------------------------------- /components/Card.js: -------------------------------------------------------------------------------- 1 | import Image from './Image' 2 | import Link from './Link' 3 | 4 | const Card = ({ title, description, imgSrc, href }) => ( 5 |
6 |
11 | {imgSrc && 12 | (href ? ( 13 | 14 | {title} 21 | 22 | ) : ( 23 | {title} 30 | ))} 31 |
32 |

33 | {href ? ( 34 | 35 | {title} 36 | 37 | ) : ( 38 | title 39 | )} 40 |

41 |

{description}

42 | {href && ( 43 | 48 | Learn more → 49 | 50 | )} 51 |
52 |
53 |
54 | ) 55 | 56 | export default Card 57 | -------------------------------------------------------------------------------- /layouts/AuthorLayout.js: -------------------------------------------------------------------------------- 1 | import SocialIcon from '@/components/social-icons' 2 | import Image from '@/components/Image' 3 | import { PageSEO } from '@/components/SEO' 4 | 5 | export default function AuthorLayout({ children, frontMatter }) { 6 | const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter 7 | 8 | return ( 9 | <> 10 | 11 |
12 |
13 |

14 | About 15 |

16 |
17 |
18 |
19 | avatar 26 |

{name}

27 |
{occupation}
28 |
{company}
29 |
30 | 31 | 32 | 33 | 34 |
35 |
36 |
{children}
37 |
38 |
39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /components/social-icons/index.js: -------------------------------------------------------------------------------- 1 | import Mail from './mail.svg' 2 | import Github from './github.svg' 3 | import Facebook from './facebook.svg' 4 | import Youtube from './youtube.svg' 5 | import Linkedin from './linkedin.svg' 6 | import Twitter from './twitter.svg' 7 | import siteMetadata from '@/data/siteMetadata' 8 | 9 | // Icons taken from: https://simpleicons.org/ 10 | 11 | const components = { 12 | mail: Mail, 13 | github: Github, 14 | facebook: Facebook, 15 | youtube: Youtube, 16 | linkedin: Linkedin, 17 | twitter: Twitter, 18 | } 19 | 20 | const SocialIcon = ({ kind, href, size = 8 }) => { 21 | if (!href || (kind === 'mail' && !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href))) 22 | return null 23 | 24 | const SocialSvg = components[kind] 25 | 26 | return ( 27 | 33 | {kind} 34 | 37 | 38 | ) 39 | } 40 | 41 | export default SocialIcon 42 | 43 | export function SocialIconRow() { 44 | return ( 45 |
46 |
47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /components/comments/Utterances.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react' 2 | import { useTheme } from 'next-themes' 3 | 4 | import siteMetadata from '@/data/siteMetadata' 5 | 6 | const Utterances = () => { 7 | const [enableLoadComments, setEnabledLoadComments] = useState(true) 8 | const { theme, resolvedTheme } = useTheme() 9 | const commentsTheme = 10 | theme === 'dark' || resolvedTheme === 'dark' 11 | ? siteMetadata.comment.utterancesConfig.darkTheme 12 | : siteMetadata.comment.utterancesConfig.theme 13 | 14 | const COMMENTS_ID = 'comments-container' 15 | 16 | const LoadComments = useCallback(() => { 17 | setEnabledLoadComments(false) 18 | const script = document.createElement('script') 19 | script.src = 'https://utteranc.es/client.js' 20 | script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo) 21 | script.setAttribute('issue-term', siteMetadata.comment.utterancesConfig.issueTerm) 22 | script.setAttribute('label', siteMetadata.comment.utterancesConfig.label) 23 | script.setAttribute('theme', commentsTheme) 24 | script.setAttribute('crossorigin', 'anonymous') 25 | script.async = true 26 | 27 | const comments = document.getElementById(COMMENTS_ID) 28 | if (comments) comments.appendChild(script) 29 | 30 | return () => { 31 | const comments = document.getElementById(COMMENTS_ID) 32 | if (comments) comments.innerHTML = '' 33 | } 34 | }, [commentsTheme]) 35 | 36 | // Reload on theme change 37 | useEffect(() => { 38 | const iframe = document.querySelector('iframe.utterances-frame') 39 | if (!iframe) return 40 | LoadComments() 41 | }, [LoadComments]) 42 | 43 | // Added `relative` to fix a weird bug with `utterances-frame` position 44 | return ( 45 |
46 | {enableLoadComments && } 47 |
48 |
49 | ) 50 | } 51 | 52 | export default Utterances 53 | -------------------------------------------------------------------------------- /components/Pre.js: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react' 2 | 3 | const Pre = (props) => { 4 | const textInput = useRef(null) 5 | const [hovered, setHovered] = useState(false) 6 | const [copied, setCopied] = useState(false) 7 | 8 | const onEnter = () => { 9 | setHovered(true) 10 | } 11 | const onExit = () => { 12 | setHovered(false) 13 | setCopied(false) 14 | } 15 | const onCopy = () => { 16 | setCopied(true) 17 | navigator.clipboard.writeText(textInput.current.textContent) 18 | setTimeout(() => { 19 | setCopied(false) 20 | }, 2000) 21 | } 22 | 23 | return ( 24 |
25 | {hovered && ( 26 | 64 | )} 65 | 66 |
{props.children}
67 |
68 | ) 69 | } 70 | 71 | export default Pre 72 | -------------------------------------------------------------------------------- /scripts/generate-sitemap.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const globby = require('globby') 3 | const matter = require('gray-matter') 4 | const prettier = require('prettier') 5 | const siteMetadata = require('../data/siteMetadata') 6 | 7 | ;(async () => { 8 | const prettierConfig = await prettier.resolveConfig('./.prettierrc.js') 9 | const pages = await globby([ 10 | 'pages/*.js', 11 | 'pages/*.tsx', 12 | 'data/blog/**/*.mdx', 13 | 'data/blog/**/*.md', 14 | 'public/tags/**/*.xml', 15 | '!pages/_*.js', 16 | '!pages/_*.tsx', 17 | '!pages/api', 18 | ]) 19 | 20 | const sitemap = ` 21 | 22 | 23 | ${pages 24 | .map((page) => { 25 | // Exclude drafts from the sitemap 26 | if (page.search('.md') >= 1 && fs.existsSync(page)) { 27 | const source = fs.readFileSync(page, 'utf8') 28 | const fm = matter(source) 29 | if (fm.data.draft) { 30 | return 31 | } 32 | if (fm.data.canonicalUrl) { 33 | return 34 | } 35 | } 36 | const path = page 37 | .replace('pages/', '/') 38 | .replace('data/blog', '/blog') 39 | .replace('public/', '/') 40 | .replace('.js', '') 41 | .replace('.tsx', '') 42 | .replace('.mdx', '') 43 | .replace('.md', '') 44 | .replace('/feed.xml', '') 45 | const route = path === '/index' ? '' : path 46 | 47 | if (page.search('pages/404.') > -1 || page.search(`pages/blog/[...slug].`) > -1) { 48 | return 49 | } 50 | return ` 51 | 52 | ${siteMetadata.siteUrl}${route} 53 | 54 | ` 55 | }) 56 | .join('')} 57 | 58 | ` 59 | 60 | const formatted = prettier.format(sitemap, { 61 | ...prettierConfig, 62 | parser: 'html', 63 | }) 64 | 65 | // eslint-disable-next-line no-sync 66 | fs.writeFileSync('public/sitemap.xml', formatted) 67 | })() 68 | -------------------------------------------------------------------------------- /components/ScrollTopAndComment.js: -------------------------------------------------------------------------------- 1 | import siteMetadata from '@/data/siteMetadata' 2 | import { useEffect, useState } from 'react' 3 | 4 | const ScrollTopAndComment = () => { 5 | const [show, setShow] = useState(false) 6 | 7 | useEffect(() => { 8 | const handleWindowScroll = () => { 9 | if (window.scrollY > 50) setShow(true) 10 | else setShow(false) 11 | } 12 | 13 | window.addEventListener('scroll', handleWindowScroll) 14 | return () => window.removeEventListener('scroll', handleWindowScroll) 15 | }, []) 16 | 17 | const handleScrollTop = () => { 18 | window.scrollTo({ top: 0 }) 19 | } 20 | const handleScrollToComment = () => { 21 | document.getElementById('comment').scrollIntoView() 22 | } 23 | return ( 24 | 58 | ) 59 | } 60 | 61 | export default ScrollTopAndComment 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwind-nextjs-starter-blog", 3 | "version": "1.5.6", 4 | "private": true, 5 | "scripts": { 6 | "start": "cross-env SOCKET=true node ./scripts/next-remote-watch.js ./data", 7 | "dev": "next dev", 8 | "build": "next build && node ./scripts/generate-sitemap", 9 | "serve": "next start", 10 | "analyze": "cross-env ANALYZE=true next build", 11 | "lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts", 12 | "prepare": "husky install" 13 | }, 14 | "dependencies": { 15 | "@builder.io/react": "^2.0.3", 16 | "@fontsource/inter": "4.5.2", 17 | "@mailchimp/mailchimp_marketing": "^3.0.58", 18 | "@tailwindcss/forms": "^0.4.0", 19 | "@tailwindcss/typography": "^0.5.0", 20 | "autoprefixer": "^10.4.0", 21 | "esbuild": "^0.13.13", 22 | "github-slugger": "^1.3.0", 23 | "gray-matter": "^4.0.2", 24 | "image-size": "1.0.0", 25 | "mdx-bundler": "^8.0.0", 26 | "next": "12.1.4", 27 | "next-themes": "^0.0.14", 28 | "postcss": "^8.4.5", 29 | "preact": "^10.6.2", 30 | "react": "17.0.2", 31 | "react-dom": "17.0.2", 32 | "reading-time": "1.3.0", 33 | "rehype-autolink-headings": "^6.1.0", 34 | "rehype-citation": "^0.4.0", 35 | "rehype-katex": "^6.0.2", 36 | "rehype-preset-minify": "6.0.0", 37 | "rehype-prism-plus": "^1.1.3", 38 | "rehype-slug": "^5.0.0", 39 | "remark-footnotes": "^4.0.1", 40 | "remark-gfm": "^3.0.1", 41 | "remark-math": "^5.1.1", 42 | "sharp": "^0.28.3", 43 | "tailwindcss": "^3.0.23", 44 | "unist-util-visit": "^4.0.0" 45 | }, 46 | "devDependencies": { 47 | "@next/bundle-analyzer": "12.1.4", 48 | "@svgr/webpack": "^6.1.2", 49 | "cross-env": "^7.0.3", 50 | "dedent": "^0.7.0", 51 | "eslint": "^7.29.0", 52 | "eslint-config-next": "12.1.4", 53 | "eslint-config-prettier": "^8.3.0", 54 | "eslint-plugin-prettier": "^3.3.1", 55 | "file-loader": "^6.0.0", 56 | "globby": "11.0.3", 57 | "husky": "^6.0.0", 58 | "inquirer": "^8.1.1", 59 | "lint-staged": "^11.0.0", 60 | "next-remote-watch": "^1.0.0", 61 | "prettier": "^2.5.1", 62 | "prettier-plugin-tailwindcss": "^0.1.4", 63 | "socket.io": "^4.4.0", 64 | "socket.io-client": "^4.4.0" 65 | }, 66 | "lint-staged": { 67 | "*.+(js|jsx|ts|tsx)": [ 68 | "eslint --fix" 69 | ], 70 | "*.+(js|jsx|ts|tsx|json|css|md|mdx)": [ 71 | "prettier --write" 72 | ] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /components/comments/Giscus.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react' 2 | import { useTheme } from 'next-themes' 3 | 4 | import siteMetadata from '@/data/siteMetadata' 5 | 6 | const Giscus = () => { 7 | const [enableLoadComments, setEnabledLoadComments] = useState(true) 8 | const { theme, resolvedTheme } = useTheme() 9 | const commentsTheme = 10 | siteMetadata.comment.giscusConfig.themeURL === '' 11 | ? theme === 'dark' || resolvedTheme === 'dark' 12 | ? siteMetadata.comment.giscusConfig.darkTheme 13 | : siteMetadata.comment.giscusConfig.theme 14 | : siteMetadata.comment.giscusConfig.themeURL 15 | 16 | const COMMENTS_ID = 'comments-container' 17 | 18 | const LoadComments = useCallback(() => { 19 | setEnabledLoadComments(false) 20 | 21 | const { 22 | repo, 23 | repositoryId, 24 | category, 25 | categoryId, 26 | mapping, 27 | reactions, 28 | metadata, 29 | inputPosition, 30 | lang, 31 | } = siteMetadata?.comment?.giscusConfig 32 | 33 | const script = document.createElement('script') 34 | script.src = 'https://giscus.app/client.js' 35 | script.setAttribute('data-repo', repo) 36 | script.setAttribute('data-repo-id', repositoryId) 37 | script.setAttribute('data-category', category) 38 | script.setAttribute('data-category-id', categoryId) 39 | script.setAttribute('data-mapping', mapping) 40 | script.setAttribute('data-reactions-enabled', reactions) 41 | script.setAttribute('data-emit-metadata', metadata) 42 | script.setAttribute('data-input-position', inputPosition) 43 | script.setAttribute('data-lang', lang) 44 | script.setAttribute('data-theme', commentsTheme) 45 | script.setAttribute('crossorigin', 'anonymous') 46 | script.async = true 47 | 48 | const comments = document.getElementById(COMMENTS_ID) 49 | if (comments) comments.appendChild(script) 50 | 51 | return () => { 52 | const comments = document.getElementById(COMMENTS_ID) 53 | if (comments) comments.innerHTML = '' 54 | } 55 | }, [commentsTheme]) 56 | 57 | // Reload on theme change 58 | useEffect(() => { 59 | const iframe = document.querySelector('iframe.giscus-frame') 60 | if (!iframe) return 61 | LoadComments() 62 | }, [LoadComments]) 63 | 64 | return ( 65 |
66 | {enableLoadComments && } 67 |
68 |
69 | ) 70 | } 71 | 72 | export default Giscus 73 | -------------------------------------------------------------------------------- /components/LayoutWrapper.js: -------------------------------------------------------------------------------- 1 | import siteMetadata from '@/data/siteMetadata' 2 | import headerNavLinks from '@/data/headerNavLinks' 3 | import Logo from '@/data/logo.svg' 4 | import Link from './Link' 5 | import SectionContainer from './SectionContainer' 6 | import Footer from './Footer' 7 | import MobileNav from './MobileNav' 8 | import ThemeSwitch from './ThemeSwitch' 9 | import { Builder } from '@builder.io/react' 10 | 11 | const LayoutWrapper = ({ children }) => { 12 | return ( 13 | 14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 | {typeof siteMetadata.headerTitle === 'string' ? ( 23 |
24 | {siteMetadata.headerTitle} 25 |
26 | ) : ( 27 | siteMetadata.headerTitle 28 | )} 29 |
30 | 31 |
32 | 65 |
66 |
{children}
67 |
68 |
69 |
70 | ) 71 | } 72 | 73 | export default LayoutWrapper 74 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 2 | enabled: process.env.ANALYZE === 'true', 3 | }) 4 | 5 | // You might need to insert additional domains in script-src if you are using external services 6 | const ContentSecurityPolicy = ` 7 | default-src 'self'; 8 | script-src 'self' 'unsafe-eval' 'unsafe-inline' giscus.app; 9 | style-src 'self' 'unsafe-inline'; 10 | img-src * blob: data:; 11 | media-src 'none'; 12 | connect-src *; 13 | font-src 'self'; 14 | frame-src giscus.app 15 | ` 16 | 17 | const securityHeaders = [ 18 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy 19 | { 20 | key: 'Referrer-Policy', 21 | value: 'strict-origin-when-cross-origin', 22 | }, 23 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options 24 | { 25 | key: 'X-Content-Type-Options', 26 | value: 'nosniff', 27 | }, 28 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control 29 | { 30 | key: 'X-DNS-Prefetch-Control', 31 | value: 'on', 32 | }, 33 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security 34 | { 35 | key: 'Strict-Transport-Security', 36 | value: 'max-age=31536000; includeSubDomains', 37 | }, 38 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy 39 | { 40 | key: 'Permissions-Policy', 41 | value: 'camera=(), microphone=(), geolocation=()', 42 | }, 43 | ] 44 | 45 | module.exports = withBundleAnalyzer({ 46 | reactStrictMode: true, 47 | images: { 48 | domains: ['cdn.builder.io'], 49 | }, 50 | pageExtensions: ['js', 'jsx', 'md', 'mdx'], 51 | eslint: { 52 | dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'], 53 | }, 54 | async headers() { 55 | return [ 56 | { 57 | source: '/(.*)', 58 | headers: securityHeaders, 59 | }, 60 | ] 61 | }, 62 | async redirects() { 63 | return [ 64 | // TODO: remove this when Builder.io app updated to handle demoPath param correctly 65 | { 66 | source: '/blog', 67 | destination: '/blog/example', 68 | permanent: true, 69 | }, 70 | ] 71 | }, 72 | 73 | webpack: (config, { dev, isServer }) => { 74 | config.module.rules.push({ 75 | test: /\.svg$/, 76 | use: ['@svgr/webpack'], 77 | }) 78 | 79 | if (!dev && !isServer) { 80 | // Replace React with Preact only in client production build 81 | Object.assign(config.resolve.alias, { 82 | 'react/jsx-runtime.js': 'preact/compat/jsx-runtime', 83 | react: 'preact/compat', 84 | 'react-dom/test-utils': 'preact/test-utils', 85 | 'react-dom': 'preact/compat', 86 | }) 87 | } 88 | 89 | return config 90 | }, 91 | }) 92 | -------------------------------------------------------------------------------- /components/MobileNav.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import Link from './Link' 3 | import headerNavLinks from '@/data/headerNavLinks' 4 | 5 | const MobileNav = () => { 6 | const [navShow, setNavShow] = useState(false) 7 | 8 | const onToggleNav = () => { 9 | setNavShow((status) => { 10 | if (status) { 11 | document.body.style.overflow = 'auto' 12 | } else { 13 | // Prevent scrolling 14 | document.body.style.overflow = 'hidden' 15 | } 16 | return !status 17 | }) 18 | } 19 | 20 | return ( 21 |
22 | 41 |
46 |
47 | 66 |
67 | 80 |
81 |
82 | ) 83 | } 84 | 85 | export default MobileNav 86 | -------------------------------------------------------------------------------- /css/prism.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS Styles for code highlighting. 3 | * Feel free to customize token styles 4 | * by copying from a prismjs compatible theme: 5 | * https://github.com/PrismJS/prism-themes 6 | */ 7 | 8 | /* Code title styles */ 9 | .remark-code-title { 10 | @apply rounded-t bg-gray-700 px-5 py-3 font-mono text-sm font-bold text-gray-200; 11 | } 12 | 13 | .remark-code-title + div > pre { 14 | @apply mt-0 rounded-t-none; 15 | } 16 | 17 | /* Code block styles */ 18 | .code-highlight { 19 | @apply float-left min-w-full; 20 | } 21 | 22 | .code-line { 23 | @apply -mx-4 block border-l-4 border-transparent pl-4 pr-4; 24 | } 25 | 26 | .code-line.inserted { 27 | @apply bg-green-500 bg-opacity-20; 28 | } 29 | 30 | .code-line.deleted { 31 | @apply bg-red-500 bg-opacity-20; 32 | } 33 | 34 | .highlight-line { 35 | @apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50; 36 | } 37 | 38 | .line-number::before { 39 | @apply mr-4 -ml-2 inline-block w-4 text-right text-gray-400; 40 | content: attr(line); 41 | } 42 | 43 | /* Token styles */ 44 | /** 45 | * MIT License 46 | * Copyright (c) 2018 Sarah Drasner 47 | * Sarah Drasner's[@sdras] Night Owl 48 | * Ported by Sara vieria [@SaraVieira] 49 | * Added by Souvik Mandal [@SimpleIndian] 50 | */ 51 | .token.comment, 52 | .token.prolog, 53 | .token.cdata { 54 | color: rgb(99, 119, 119); 55 | font-style: italic; 56 | } 57 | 58 | .token.punctuation { 59 | color: rgb(199, 146, 234); 60 | } 61 | 62 | .namespace { 63 | color: rgb(178, 204, 214); 64 | } 65 | 66 | .token.deleted { 67 | color: rgba(239, 83, 80, 0.56); 68 | font-style: italic; 69 | } 70 | 71 | .token.symbol, 72 | .token.property { 73 | color: rgb(128, 203, 196); 74 | } 75 | 76 | .token.tag, 77 | .token.operator, 78 | .token.keyword { 79 | color: rgb(127, 219, 202); 80 | } 81 | 82 | .token.boolean { 83 | color: rgb(255, 88, 116); 84 | } 85 | 86 | .token.number { 87 | color: rgb(247, 140, 108); 88 | } 89 | 90 | .token.constant, 91 | .token.function, 92 | .token.builtin, 93 | .token.char { 94 | color: rgb(130, 170, 255); 95 | } 96 | 97 | .token.selector, 98 | .token.doctype { 99 | color: rgb(199, 146, 234); 100 | font-style: italic; 101 | } 102 | 103 | .token.attr-name, 104 | .token.inserted { 105 | color: rgb(173, 219, 103); 106 | font-style: italic; 107 | } 108 | 109 | .token.string, 110 | .token.url, 111 | .token.entity, 112 | .language-css .token.string, 113 | .style .token.string { 114 | color: rgb(173, 219, 103); 115 | } 116 | 117 | .token.class-name, 118 | .token.atrule, 119 | .token.attr-value { 120 | color: rgb(255, 203, 139); 121 | } 122 | 123 | .token.regex, 124 | .token.important, 125 | .token.variable { 126 | color: rgb(214, 222, 235); 127 | } 128 | 129 | .token.important, 130 | .token.bold { 131 | font-weight: bold; 132 | } 133 | 134 | .token.italic { 135 | font-style: italic; 136 | } 137 | 138 | .token.table { 139 | display: inline; 140 | } 141 | -------------------------------------------------------------------------------- /layouts/PostSimple.js: -------------------------------------------------------------------------------- 1 | import Link from '@/components/Link' 2 | import PageTitle from '@/components/PageTitle' 3 | import SectionContainer from '@/components/SectionContainer' 4 | import { BlogSEO } from '@/components/SEO' 5 | import siteMetadata from '@/data/siteMetadata' 6 | import formatDate from '@/lib/utils/formatDate' 7 | import Comments from '@/components/comments' 8 | import ScrollTopAndComment from '@/components/ScrollTopAndComment' 9 | 10 | export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) { 11 | const { date, title } = frontMatter 12 | 13 | return ( 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 |
22 |
23 |
Published on
24 |
25 | 26 |
27 |
28 |
29 |
30 | {title} 31 |
32 |
33 |
34 |
38 |
39 |
{children}
40 |
41 | 42 |
43 |
44 | {prev && ( 45 |
46 | 50 | ← {prev.title} 51 | 52 |
53 | )} 54 | {next && ( 55 |
56 | 60 | {next.title} → 61 | 62 |
63 | )} 64 |
65 |
66 |
67 |
68 |
69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /components/NewsletterForm.js: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react' 2 | 3 | import siteMetadata from '@/data/siteMetadata' 4 | 5 | const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => { 6 | const inputEl = useRef(null) 7 | const [error, setError] = useState(false) 8 | const [message, setMessage] = useState('') 9 | const [subscribed, setSubscribed] = useState(false) 10 | 11 | const subscribe = async (e) => { 12 | e.preventDefault() 13 | 14 | const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, { 15 | body: JSON.stringify({ 16 | email: inputEl.current.value, 17 | }), 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | }, 21 | method: 'POST', 22 | }) 23 | 24 | const { error } = await res.json() 25 | if (error) { 26 | setError(true) 27 | setMessage('Your e-mail address is invalid or you are already subscribed!') 28 | return 29 | } 30 | 31 | inputEl.current.value = '' 32 | setError(false) 33 | setSubscribed(true) 34 | setMessage('Successfully! 🎉 You are now subscribed.') 35 | } 36 | 37 | return ( 38 |
39 |
{title}
40 |
41 |
42 | 45 | 56 |
57 |
58 | 67 |
68 |
69 | {error && ( 70 |
{message}
71 | )} 72 |
73 | ) 74 | } 75 | 76 | export default NewsletterForm 77 | 78 | export const BlogNewsletterForm = ({ title }) => ( 79 |
80 |
81 | 82 |
83 |
84 | ) 85 | -------------------------------------------------------------------------------- /data/blog/guide-to-using-images-in-nextjs.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Images in Next.js 3 | date: '2020-11-11' 4 | tags: ['next js', 'guide'] 5 | draft: false 6 | summary: 'In this article we introduce adding images in the tailwind starter blog and the benefits and limitations of the next/image component.' 7 | authors: ['sparrowhawk'] 8 | --- 9 | 10 | # Introduction 11 | 12 | The tailwind starter blog has out of the box support for [Next.js's built-in image component](https://nextjs.org/docs/api-reference/next/image) and automatically swaps out default image tags in markdown or mdx documents to use the Image component provided. 13 | 14 | # Usage 15 | 16 | To use in a new page route / javascript file, simply import the image component and call it e.g. 17 | 18 | ```js 19 | import Image from 'next/image' 20 | 21 | function Home() { 22 | return ( 23 | <> 24 |

My Homepage

25 | Picture of the author 26 |

Welcome to my homepage!

27 | 28 | ) 29 | } 30 | 31 | export default Home 32 | ``` 33 | 34 | For a markdown file, the default image tag can be used and the default `img` tag gets replaced by the `Image` component in the build process. 35 | 36 | Assuming we have a file called `ocean.jpg` in `data/img/ocean.jpg`, the following line of code would generate the optimized image. 37 | 38 | ``` 39 | ![ocean](/static/images/ocean.jpg) 40 | ``` 41 | 42 | Alternatively, since we are using mdx, we can just use the image component directly! Note, that you would have to provide a fixed width and height. The `img` tag method parses the dimension automatically. 43 | 44 | ```js 45 | ocean 46 | ``` 47 | 48 | _Note_: If you try to save the image, it is in webp format, if your browser supports it! 49 | 50 | ![ocean](/static/images/ocean.jpeg) 51 | 52 |

53 | Photo by [YUCAR 54 | FotoGrafik](https://unsplash.com/@yucar?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 55 | on 56 | [Unsplash](https://unsplash.com/s/photos/sea?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 57 |

58 | 59 | # Benefits 60 | 61 | - Smaller image size with Webp (~30% smaller than jpeg) 62 | - Responsive images - the correct image size is served based on the user's viewport 63 | - Lazy loading - images load as they are scrolled to the viewport 64 | - Avoids [Cumulative Layout Shift](https://web.dev/cls/) 65 | - Optimization on demand instead of build-time - no increase in build time! 66 | 67 | # Limitations 68 | 69 | - Due to the reliance on `next/image`, unless you are using an external image CDN like Cloudinary or Imgix, it is practically required to use Vercel for hosting. This is because the component acts like a serverless function that calls a highly optimized image CDN. 70 | 71 | If you do not want to be tied to Vercel, you can remove `imgToJsx` in `remarkPlugins` in `lib/mdx.js`. This would avoid substituting the default `img` tag. 72 | 73 | Alternatively, one could wait for image optimization at build time to be supported. A different library, [next-optimized-images](https://github.com/cyrilwanner/next-optimized-images) does that, although it requires transforming the images through webpack which is not done here. 74 | 75 | - Images from external links are not passed through `next/image` 76 | - All images have to be stored in the `public` folder e.g `/static/images/ocean.jpeg` 77 | -------------------------------------------------------------------------------- /data/blog/pictures-of-canada.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: O Canada 3 | date: '2017-07-15' 4 | tags: ['holiday', 'canada', 'images'] 5 | draft: false 6 | summary: The scenic lands of Canada featuring maple leaves, snow-capped mountains, turquoise lakes and Toronto. Take in the sights in this photo gallery exhibition and see how easy it is to replicate with some MDX magic and tailwind classes. 7 | --- 8 | 9 | # O Canada 10 | 11 | The scenic lands of Canada featuring maple leaves, snow-capped mountains, turquoise lakes and Toronto. Take in the sights in this photo gallery exhibition and see how easy it is to replicate with some MDX magic and tailwind classes. 12 | 13 | Features images served using `next/image` component. The locally stored images are located in a folder with the following path: `/static/images/canada/[filename].jpg` 14 | 15 | Since we are using mdx, we can create a simple responsive flexbox grid to display our images with a few tailwind css classes. 16 | 17 | --- 18 | 19 | # Gallery 20 | 21 |
22 |
23 | ![Maple](/static/images/canada/maple.jpg) 24 |
25 |
26 | ![Lake](/static/images/canada/lake.jpg) 27 |
28 |
29 | ![Mountains](/static/images/canada/mountains.jpg) 30 |
31 |
32 | ![Toronto](/static/images/canada/toronto.jpg) 33 |
34 |
35 | 36 | # Implementation 37 | 38 | ```js 39 |
40 |
41 | ![Maple](/static/images/canada/maple.jpg) 42 |
43 |
44 | ![Lake](/static/images/canada/lake.jpg) 45 |
46 |
47 | ![Mountains](/static/images/canada/mountains.jpg) 48 |
49 |
50 | ![Toronto](/static/images/canada/toronto.jpg) 51 |
52 |
53 | ``` 54 | 55 | With MDX v2, one can interleave markdown in jsx as shown in the example code. 56 | 57 | ### Photo Credits 58 | 59 |
60 | Maple photo by [Guillaume 61 | Jaillet](https://unsplash.com/@i_am_g?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 62 | on 63 | [Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 64 |
65 |
66 | Mountains photo by [John 67 | Lee](https://unsplash.com/@john_artifexfilms?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 68 | on 69 | [Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 70 |
71 |
72 | Lake photo by [Tj 73 | Holowaychuk](https://unsplash.com/@tjholowaychuk?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 74 | on 75 | [Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 76 |
77 |
78 | Toronto photo by [Matthew 79 | Henry](https://unsplash.com/@matthewhenry?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 80 | on 81 | [Unsplash](https://unsplash.com/s/photos/canada?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 82 |
83 | -------------------------------------------------------------------------------- /scripts/compose.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const inquirer = require('inquirer') 4 | const dedent = require('dedent') 5 | 6 | const root = process.cwd() 7 | 8 | const getAuthors = () => { 9 | const authorPath = path.join(root, 'data', 'authors') 10 | const authorList = fs.readdirSync(authorPath).map((filename) => path.parse(filename).name) 11 | return authorList 12 | } 13 | 14 | const getLayouts = () => { 15 | const layoutPath = path.join(root, 'layouts') 16 | const layoutList = fs 17 | .readdirSync(layoutPath) 18 | .map((filename) => path.parse(filename).name) 19 | .filter((file) => file.toLowerCase().includes('post')) 20 | return layoutList 21 | } 22 | 23 | const genFrontMatter = (answers) => { 24 | let d = new Date() 25 | const date = [ 26 | d.getFullYear(), 27 | ('0' + (d.getMonth() + 1)).slice(-2), 28 | ('0' + d.getDate()).slice(-2), 29 | ].join('-') 30 | const tagArray = answers.tags.split(',') 31 | tagArray.forEach((tag, index) => (tagArray[index] = tag.trim())) 32 | const tags = "'" + tagArray.join("','") + "'" 33 | const authorArray = answers.authors.length > 0 ? "'" + answers.authors.join("','") + "'" : '' 34 | 35 | let frontMatter = dedent`--- 36 | title: ${answers.title ? answers.title : 'Untitled'} 37 | date: '${date}' 38 | tags: [${answers.tags ? tags : ''}] 39 | draft: ${answers.draft === 'yes' ? true : false} 40 | summary: ${answers.summary ? answers.summary : ' '} 41 | images: [] 42 | layout: ${answers.layout} 43 | canonicalUrl: ${answers.canonicalUrl} 44 | ` 45 | 46 | if (answers.authors.length > 0) { 47 | frontMatter = frontMatter + '\n' + `authors: [${authorArray}]` 48 | } 49 | 50 | frontMatter = frontMatter + '\n---' 51 | 52 | return frontMatter 53 | } 54 | 55 | inquirer 56 | .prompt([ 57 | { 58 | name: 'title', 59 | message: 'Enter post title:', 60 | type: 'input', 61 | }, 62 | { 63 | name: 'extension', 64 | message: 'Choose post extension:', 65 | type: 'list', 66 | choices: ['mdx', 'md'], 67 | }, 68 | { 69 | name: 'authors', 70 | message: 'Choose authors:', 71 | type: 'checkbox', 72 | choices: getAuthors, 73 | }, 74 | { 75 | name: 'summary', 76 | message: 'Enter post summary:', 77 | type: 'input', 78 | }, 79 | { 80 | name: 'draft', 81 | message: 'Set post as draft?', 82 | type: 'list', 83 | choices: ['yes', 'no'], 84 | }, 85 | { 86 | name: 'tags', 87 | message: 'Any Tags? Separate them with , or leave empty if no tags.', 88 | type: 'input', 89 | }, 90 | { 91 | name: 'layout', 92 | message: 'Select layout', 93 | type: 'list', 94 | choices: getLayouts, 95 | }, 96 | { 97 | name: 'canonicalUrl', 98 | message: 'Enter canonical url:', 99 | type: 'input', 100 | }, 101 | ]) 102 | .then((answers) => { 103 | // Remove special characters and replace space with - 104 | const fileName = answers.title 105 | .toLowerCase() 106 | .replace(/[^a-zA-Z0-9 ]/g, '') 107 | .replace(/ /g, '-') 108 | .replace(/-+/g, '-') 109 | const frontMatter = genFrontMatter(answers) 110 | if (!fs.existsSync('data/blog')) fs.mkdirSync('data/blog', { recursive: true }) 111 | const filePath = `data/blog/${fileName ? fileName : 'untitled'}.${ 112 | answers.extension ? answers.extension : 'md' 113 | }` 114 | fs.writeFile(filePath, frontMatter, { flag: 'wx' }, (err) => { 115 | if (err) { 116 | throw err 117 | } else { 118 | console.log(`Blog post generated successfully at ${filePath}`) 119 | } 120 | }) 121 | }) 122 | .catch((error) => { 123 | if (error.isTtyError) { 124 | console.log("Prompt couldn't be rendered in the current environment") 125 | } else { 126 | console.log('Something went wrong, sorry!') 127 | } 128 | }) 129 | -------------------------------------------------------------------------------- /data/siteMetadata.js: -------------------------------------------------------------------------------- 1 | const siteMetadata = { 2 | title: 'Next.js Starter Blog', 3 | author: 'Tails Azimuth', 4 | headerTitle: 'TailwindBlog', 5 | description: 'A blog created with Next.js and Tailwind.css', 6 | language: 'en-us', 7 | theme: 'system', // system, dark or light 8 | siteUrl: 'https://tailwind-nextjs-starter-blog.vercel.app', 9 | siteRepo: 'https://github.com/timlrx/tailwind-nextjs-starter-blog', 10 | siteLogo: '/static/images/logo.png', 11 | image: '/static/images/avatar.png', 12 | socialBanner: '/static/images/twitter-card.png', 13 | email: 'address@yoursite.com', 14 | github: 'https://github.com', 15 | twitter: 'https://twitter.com/Twitter', 16 | facebook: 'https://facebook.com', 17 | youtube: 'https://youtube.com', 18 | linkedin: 'https://www.linkedin.com', 19 | locale: 'en-US', 20 | analytics: { 21 | // If you want to use an analytics provider you have to add it to the 22 | // content security policy in the `next.config.js` file. 23 | // supports plausible, simpleAnalytics, umami or googleAnalytics 24 | plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app 25 | simpleAnalytics: false, // true or false 26 | umamiWebsiteId: '', // e.g. 123e4567-e89b-12d3-a456-426614174000 27 | googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX 28 | posthogAnalyticsId: '', // posthog.init e.g. phc_5yXvArzvRdqtZIsHkEm3Fkkhm3d0bEYUXCaFISzqPSQ 29 | }, 30 | newsletter: { 31 | // supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus 32 | // Please add your .env file and modify it according to your selection 33 | provider: 'buttondown', 34 | }, 35 | comment: { 36 | // If you want to use a commenting system other than giscus you have to add it to the 37 | // content security policy in the `next.config.js` file. 38 | // Select a provider and use the environment variables associated to it 39 | // https://vercel.com/docs/environment-variables 40 | provider: 'giscus', // supported providers: giscus, utterances, disqus 41 | giscusConfig: { 42 | // Visit the link below, and follow the steps in the 'configuration' section 43 | // https://giscus.app/ 44 | repo: process.env.NEXT_PUBLIC_GISCUS_REPO, 45 | repositoryId: process.env.NEXT_PUBLIC_GISCUS_REPOSITORY_ID, 46 | category: process.env.NEXT_PUBLIC_GISCUS_CATEGORY, 47 | categoryId: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID, 48 | mapping: 'pathname', // supported options: pathname, url, title 49 | reactions: '1', // Emoji reactions: 1 = enable / 0 = disable 50 | // Send discussion metadata periodically to the parent window: 1 = enable / 0 = disable 51 | metadata: '0', 52 | // theme example: light, dark, dark_dimmed, dark_high_contrast 53 | // transparent_dark, preferred_color_scheme, custom 54 | theme: 'light', 55 | // Place the comment box above the comments. options: bottom, top 56 | inputPosition: 'bottom', 57 | // Choose the language giscus will be displayed in. options: en, es, zh-CN, zh-TW, ko, ja etc 58 | lang: 'en', 59 | // theme when dark mode 60 | darkTheme: 'transparent_dark', 61 | // If the theme option above is set to 'custom` 62 | // please provide a link below to your custom theme css file. 63 | // example: https://giscus.app/themes/custom_example.css 64 | themeURL: '', 65 | }, 66 | utterancesConfig: { 67 | // Visit the link below, and follow the steps in the 'configuration' section 68 | // https://utteranc.es/ 69 | repo: process.env.NEXT_PUBLIC_UTTERANCES_REPO, 70 | issueTerm: '', // supported options: pathname, url, title 71 | label: '', // label (optional): Comment 💬 72 | // theme example: github-light, github-dark, preferred-color-scheme 73 | // github-dark-orange, icy-dark, dark-blue, photon-dark, boxy-light 74 | theme: '', 75 | // theme when dark mode 76 | darkTheme: '', 77 | }, 78 | disqusConfig: { 79 | // https://help.disqus.com/en/articles/1717111-what-s-a-shortname 80 | shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME, 81 | }, 82 | }, 83 | } 84 | 85 | module.exports = siteMetadata 86 | -------------------------------------------------------------------------------- /layouts/ListLayout.js: -------------------------------------------------------------------------------- 1 | import Link from '@/components/Link' 2 | import Tag from '@/components/Tag' 3 | import siteMetadata from '@/data/siteMetadata' 4 | import { useState } from 'react' 5 | import Pagination from '@/components/Pagination' 6 | import formatDate from '@/lib/utils/formatDate' 7 | 8 | export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }) { 9 | const [searchValue, setSearchValue] = useState('') 10 | const filteredBlogPosts = posts.filter((frontMatter) => { 11 | const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ') 12 | return searchContent.toLowerCase().includes(searchValue.toLowerCase()) 13 | }) 14 | 15 | // If initialDisplayPosts exist, display it if no searchValue is specified 16 | const displayPosts = 17 | initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts 18 | 19 | return ( 20 | <> 21 |
22 |
23 |

24 | {title} 25 |

26 |
27 | setSearchValue(e.target.value)} 31 | placeholder="Search articles" 32 | 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" 33 | /> 34 | 41 | 47 | 48 |
49 |
50 |
    51 | {!filteredBlogPosts.length && 'No posts found.'} 52 | {displayPosts.map((frontMatter) => { 53 | const { slug, date, title, summary, tags } = frontMatter 54 | return ( 55 |
  • 56 |
    57 |
    58 |
    Published on
    59 |
    60 | 61 |
    62 |
    63 |
    64 |
    65 |

    66 | 67 | {title} 68 | 69 |

    70 |
    71 | {tags.map((tag) => ( 72 | 73 | ))} 74 |
    75 |
    76 |
    77 | {summary} 78 |
    79 |
    80 |
    81 |
  • 82 | ) 83 | })} 84 |
85 |
86 | {pagination && pagination.totalPages > 1 && !searchValue && ( 87 | 88 | )} 89 | 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Link from '@/components/Link' 2 | import { PageSEO } from '@/components/SEO' 3 | import Tag from '@/components/Tag' 4 | import siteMetadata from '@/data/siteMetadata' 5 | import { getAllFilesFrontMatter } from '@/lib/mdx' 6 | import formatDate from '@/lib/utils/formatDate' 7 | 8 | import NewsletterForm from '@/components/NewsletterForm' 9 | 10 | const MAX_DISPLAY = 5 11 | 12 | export async function getStaticProps() { 13 | const posts = await getAllFilesFrontMatter('blog') 14 | 15 | return { props: { posts } } 16 | } 17 | 18 | export default function Home({ posts }) { 19 | return ( 20 | <> 21 | 22 |
23 |
24 |

25 | Latest 26 |

27 |

28 | {siteMetadata.description} 29 |

30 |
31 |
    32 | {!posts.length && 'No posts found.'} 33 | {posts.slice(0, MAX_DISPLAY).map((frontMatter) => { 34 | const { slug, date, title, summary, tags } = frontMatter 35 | return ( 36 |
  • 37 |
    38 |
    39 |
    40 |
    Published on
    41 |
    42 | 43 |
    44 |
    45 |
    46 |
    47 |
    48 |

    49 | 53 | {title} 54 | 55 |

    56 |
    57 | {tags.map((tag) => ( 58 | 59 | ))} 60 |
    61 |
    62 |
    63 | {summary} 64 |
    65 |
    66 |
    67 | 72 | Read more → 73 | 74 |
    75 |
    76 |
    77 |
    78 |
  • 79 | ) 80 | })} 81 |
82 |
83 | {posts.length > MAX_DISPLAY && ( 84 |
85 | 90 | All Posts → 91 | 92 |
93 | )} 94 | {siteMetadata.newsletter.provider !== '' && ( 95 |
96 | 97 |
98 | )} 99 | 100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /scripts/next-remote-watch.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Adapted from https://github.com/hashicorp/next-remote-watch 4 | // A copy of next-remote-watch with an additional ws reload emitter. 5 | // The app listens to the event and triggers a client-side router refresh 6 | // see components/ClientReload.js 7 | 8 | const chalk = require('chalk') 9 | const chokidar = require('chokidar') 10 | const program = require('commander') 11 | const http = require('http') 12 | const SocketIO = require('socket.io') 13 | const express = require('express') 14 | const spawn = require('child_process').spawn 15 | const next = require('next') 16 | const path = require('path') 17 | const { parse } = require('url') 18 | 19 | const pkg = require('../package.json') 20 | 21 | const defaultWatchEvent = 'change' 22 | 23 | program.storeOptionsAsProperties().version(pkg.version) 24 | program 25 | .option('-r, --root [dir]', 'root directory of your nextjs app') 26 | .option('-s, --script [path]', 'path to the script you want to trigger on a watcher event', false) 27 | .option('-c, --command [cmd]', 'command to execute on a watcher event', false) 28 | .option( 29 | '-e, --event [name]', 30 | `name of event to watch, defaults to ${defaultWatchEvent}`, 31 | defaultWatchEvent 32 | ) 33 | .option('-p, --polling [name]', `use polling for the watcher, defaults to false`, false) 34 | .parse(process.argv) 35 | 36 | const shell = process.env.SHELL 37 | const app = next({ dev: true, dir: program.root || process.cwd() }) 38 | const port = parseInt(process.env.PORT, 10) || 3000 39 | const handle = app.getRequestHandler() 40 | 41 | app.prepare().then(() => { 42 | // if directories are provided, watch them for changes and trigger reload 43 | if (program.args.length > 0) { 44 | chokidar 45 | .watch(program.args, { usePolling: Boolean(program.polling) }) 46 | .on(program.event, async (filePathContext, eventContext = defaultWatchEvent) => { 47 | // Emit changes via socketio 48 | io.sockets.emit('reload', filePathContext) 49 | app.server.hotReloader.send('building') 50 | 51 | if (program.command) { 52 | // Use spawn here so that we can pipe stdio from the command without buffering 53 | spawn( 54 | shell, 55 | [ 56 | '-c', 57 | program.command 58 | .replace(/\{event\}/gi, filePathContext) 59 | .replace(/\{path\}/gi, eventContext), 60 | ], 61 | { 62 | stdio: 'inherit', 63 | } 64 | ) 65 | } 66 | 67 | if (program.script) { 68 | try { 69 | // find the path of your --script script 70 | const scriptPath = path.join(process.cwd(), program.script.toString()) 71 | 72 | // require your --script script 73 | const executeFile = require(scriptPath) 74 | 75 | // run the exported function from your --script script 76 | executeFile(filePathContext, eventContext) 77 | } catch (e) { 78 | console.error('Remote script failed') 79 | console.error(e) 80 | return e 81 | } 82 | } 83 | 84 | app.server.hotReloader.send('reloadPage') 85 | }) 86 | } 87 | 88 | // create an express server 89 | const expressApp = express() 90 | const server = http.createServer(expressApp) 91 | 92 | // watch files with socketIO 93 | const io = SocketIO(server) 94 | 95 | // special handling for mdx reload route 96 | const reloadRoute = express.Router() 97 | reloadRoute.use(express.json()) 98 | reloadRoute.all('/', (req, res) => { 99 | // log message if present 100 | const msg = req.body.message 101 | const color = req.body.color 102 | msg && console.log(color ? chalk[color](msg) : msg) 103 | 104 | // reload the nextjs app 105 | app.server.hotReloader.send('building') 106 | app.server.hotReloader.send('reloadPage') 107 | res.end('Reload initiated') 108 | }) 109 | 110 | expressApp.use('/__next_reload', reloadRoute) 111 | 112 | // handle all other routes with next.js 113 | expressApp.all('*', (req, res) => handle(req, res, parse(req.url, true))) 114 | 115 | // fire it up 116 | server.listen(port, (err) => { 117 | if (err) throw err 118 | console.log(`> Ready on http://localhost:${port}`) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /data/blog/deriving-ols-estimator.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deriving the OLS Estimator 3 | date: '2020-12-21' 4 | tags: ['next js', 'math', 'ols'] 5 | draft: false 6 | summary: 'How to derive the OLS Estimator with matrix notation and a tour of math typesetting using markdown with the help of KaTeX.' 7 | --- 8 | 9 | # Introduction 10 | 11 | Parsing and display of math equations is included in this blog template. Parsing of math is enabled by `remark-math` and `rehype-katex`. 12 | KaTeX and its associated font is included in `_document.js` so feel free to use it on any page. 13 | ^[For the full list of supported TeX functions, check out the [KaTeX documentation](https://katex.org/docs/supported.html)] 14 | 15 | Inline math symbols can be included by enclosing the term between the `$` symbol. 16 | 17 | Math code blocks are denoted by `$$`. 18 | 19 | If you intend to use the `$` sign instead of math, you can escape it (`\$`), or specify the HTML entity (`$`) [^2] 20 | 21 | Inline or manually enumerated footnotes are also supported. Click on the links above to see them in action. 22 | 23 | [^2]: \$10 and $20. 24 | 25 | # Deriving the OLS Estimator 26 | 27 | Using matrix notation, let $n$ denote the number of observations and $k$ denote the number of regressors. 28 | 29 | The vector of outcome variables $\mathbf{Y}$ is a $n \times 1$ matrix, 30 | 31 | ```tex 32 | \mathbf{Y} = \left[\begin{array} 33 | {c} 34 | y_1 \\ 35 | . \\ 36 | . \\ 37 | . \\ 38 | y_n 39 | \end{array}\right] 40 | ``` 41 | 42 | $$ 43 | \mathbf{Y} = \left[\begin{array} 44 | {c} 45 | y_1 \\ 46 | . \\ 47 | . \\ 48 | . \\ 49 | y_n 50 | \end{array}\right] 51 | $$ 52 | 53 | The matrix of regressors $\mathbf{X}$ is a $n \times k$ matrix (or each row is a $k \times 1$ vector), 54 | 55 | ```latex 56 | \mathbf{X} = \left[\begin{array} 57 | {ccccc} 58 | x_{11} & . & . & . & x_{1k} \\ 59 | . & . & . & . & . \\ 60 | . & . & . & . & . \\ 61 | . & . & . & . & . \\ 62 | x_{n1} & . & . & . & x_{nn} 63 | \end{array}\right] = 64 | \left[\begin{array} 65 | {c} 66 | \mathbf{x}'_1 \\ 67 | . \\ 68 | . \\ 69 | . \\ 70 | \mathbf{x}'_n 71 | \end{array}\right] 72 | ``` 73 | 74 | $$ 75 | \mathbf{X} = \left[\begin{array} 76 | {ccccc} 77 | x_{11} & . & . & . & x_{1k} \\ 78 | . & . & . & . & . \\ 79 | . & . & . & . & . \\ 80 | . & . & . & . & . \\ 81 | x_{n1} & . & . & . & x_{nn} 82 | \end{array}\right] = 83 | \left[\begin{array} 84 | {c} 85 | \mathbf{x}'_1 \\ 86 | . \\ 87 | . \\ 88 | . \\ 89 | \mathbf{x}'_n 90 | \end{array}\right] 91 | $$ 92 | 93 | The vector of error terms $\mathbf{U}$ is also a $n \times 1$ matrix. 94 | 95 | At times it might be easier to use vector notation. For consistency, I will use the bold small x to denote a vector and capital letters to denote a matrix. Single observations are denoted by the subscript. 96 | 97 | ## Least Squares 98 | 99 | **Start**: 100 | $$y_i = \mathbf{x}'_i \beta + u_i$$ 101 | 102 | **Assumptions**: 103 | 104 | 1. Linearity (given above) 105 | 2. $E(\mathbf{U}|\mathbf{X}) = 0$ (conditional independence) 106 | 3. rank($\mathbf{X}$) = $k$ (no multi-collinearity i.e. full rank) 107 | 4. $Var(\mathbf{U}|\mathbf{X}) = \sigma^2 I_n$ (Homoskedascity) 108 | 109 | **Aim**: 110 | Find $\beta$ that minimises the sum of squared errors: 111 | 112 | $$ 113 | Q = \sum_{i=1}^{n}{u_i^2} = \sum_{i=1}^{n}{(y_i - \mathbf{x}'_i\beta)^2} = (Y-X\beta)'(Y-X\beta) 114 | $$ 115 | 116 | **Solution**: 117 | Hints: $Q$ is a $1 \times 1$ scalar, by symmetry $\frac{\partial b'Ab}{\partial b} = 2Ab$. 118 | 119 | Take matrix derivative w.r.t $\beta$: 120 | 121 | ```tex 122 | \begin{aligned} 123 | \min Q & = \min_{\beta} \mathbf{Y}'\mathbf{Y} - 2\beta'\mathbf{X}'\mathbf{Y} + 124 | \beta'\mathbf{X}'\mathbf{X}\beta \\ 125 | & = \min_{\beta} - 2\beta'\mathbf{X}'\mathbf{Y} + \beta'\mathbf{X}'\mathbf{X}\beta \\ 126 | \text{[FOC]}~~~0 & = - 2\mathbf{X}'\mathbf{Y} + 2\mathbf{X}'\mathbf{X}\hat{\beta} \\ 127 | \hat{\beta} & = (\mathbf{X}'\mathbf{X})^{-1}\mathbf{X}'\mathbf{Y} \\ 128 | & = (\sum^{n} \mathbf{x}_i \mathbf{x}'_i)^{-1} \sum^{n} \mathbf{x}_i y_i 129 | \end{aligned} 130 | ``` 131 | 132 | $$ 133 | \begin{aligned} 134 | \min Q & = \min_{\beta} \mathbf{Y}'\mathbf{Y} - 2\beta'\mathbf{X}'\mathbf{Y} + 135 | \beta'\mathbf{X}'\mathbf{X}\beta \\ 136 | & = \min_{\beta} - 2\beta'\mathbf{X}'\mathbf{Y} + \beta'\mathbf{X}'\mathbf{X}\beta \\ 137 | \text{[FOC]}~~~0 & = - 2\mathbf{X}'\mathbf{Y} + 2\mathbf{X}'\mathbf{X}\hat{\beta} \\ 138 | \hat{\beta} & = (\mathbf{X}'\mathbf{X})^{-1}\mathbf{X}'\mathbf{Y} \\ 139 | & = (\sum^{n} \mathbf{x}_i \mathbf{x}'_i)^{-1} \sum^{n} \mathbf{x}_i y_i 140 | \end{aligned} 141 | $$ 142 | -------------------------------------------------------------------------------- /lib/mdx.js: -------------------------------------------------------------------------------- 1 | import { bundleMDX } from 'mdx-bundler' 2 | import fs from 'fs' 3 | import matter from 'gray-matter' 4 | import path from 'path' 5 | import readingTime from 'reading-time' 6 | import { visit } from 'unist-util-visit' 7 | import getAllFilesRecursively from './utils/files' 8 | // Remark packages 9 | import remarkGfm from 'remark-gfm' 10 | import remarkFootnotes from 'remark-footnotes' 11 | import remarkMath from 'remark-math' 12 | import remarkExtractFrontmatter from './remark-extract-frontmatter' 13 | import remarkCodeTitles from './remark-code-title' 14 | import remarkTocHeadings from './remark-toc-headings' 15 | import remarkImgToJsx from './remark-img-to-jsx' 16 | // Rehype packages 17 | import rehypeSlug from 'rehype-slug' 18 | import rehypeAutolinkHeadings from 'rehype-autolink-headings' 19 | import rehypeKatex from 'rehype-katex' 20 | import rehypeCitation from 'rehype-citation' 21 | import rehypePrismPlus from 'rehype-prism-plus' 22 | import rehypePresetMinify from 'rehype-preset-minify' 23 | 24 | const root = process.cwd() 25 | 26 | export function getFiles(type) { 27 | const prefixPaths = path.join(root, 'data', type) 28 | const files = getAllFilesRecursively(prefixPaths) 29 | // Only want to return blog/path and ignore root, replace is needed to work on Windows 30 | return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/')) 31 | } 32 | 33 | export function formatSlug(slug) { 34 | return slug.replace(/\.(mdx|md)/, '') 35 | } 36 | 37 | export function dateSortDesc(a, b) { 38 | if (a > b) return -1 39 | if (a < b) return 1 40 | return 0 41 | } 42 | 43 | export async function getFileBySlug(type, slug) { 44 | const mdxPath = path.join(root, 'data', type, `${slug}.mdx`) 45 | const mdPath = path.join(root, 'data', type, `${slug}.md`) 46 | const source = fs.existsSync(mdxPath) 47 | ? fs.readFileSync(mdxPath, 'utf8') 48 | : fs.readFileSync(mdPath, 'utf8') 49 | 50 | // https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent 51 | if (process.platform === 'win32') { 52 | process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'esbuild.exe') 53 | } else { 54 | process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild') 55 | } 56 | 57 | let toc = [] 58 | 59 | const { code, frontmatter } = await bundleMDX({ 60 | source, 61 | // mdx imports can be automatically source from the components directory 62 | cwd: path.join(root, 'components'), 63 | xdmOptions(options, frontmatter) { 64 | // this is the recommended way to add custom remark/rehype plugins: 65 | // The syntax might look weird, but it protects you in case we add/remove 66 | // plugins in the future. 67 | options.remarkPlugins = [ 68 | ...(options.remarkPlugins ?? []), 69 | remarkExtractFrontmatter, 70 | [remarkTocHeadings, { exportRef: toc }], 71 | remarkGfm, 72 | remarkCodeTitles, 73 | [remarkFootnotes, { inlineNotes: true }], 74 | remarkMath, 75 | remarkImgToJsx, 76 | ] 77 | options.rehypePlugins = [ 78 | ...(options.rehypePlugins ?? []), 79 | rehypeSlug, 80 | rehypeAutolinkHeadings, 81 | rehypeKatex, 82 | [rehypeCitation, { path: path.join(root, 'data') }], 83 | [rehypePrismPlus, { ignoreMissing: true }], 84 | rehypePresetMinify, 85 | ] 86 | return options 87 | }, 88 | esbuildOptions: (options) => { 89 | options.loader = { 90 | ...options.loader, 91 | '.js': 'jsx', 92 | } 93 | return options 94 | }, 95 | }) 96 | 97 | return { 98 | mdxSource: code, 99 | toc, 100 | frontMatter: { 101 | readingTime: readingTime(code), 102 | slug: slug || null, 103 | fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`, 104 | ...frontmatter, 105 | date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null, 106 | }, 107 | } 108 | } 109 | 110 | export async function getAllFilesFrontMatter(folder) { 111 | const prefixPaths = path.join(root, 'data', folder) 112 | 113 | const files = getAllFilesRecursively(prefixPaths) 114 | 115 | const allFrontMatter = [] 116 | 117 | files.forEach((file) => { 118 | // Replace is needed to work on Windows 119 | const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/') 120 | // Remove Unexpected File 121 | if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') { 122 | return 123 | } 124 | const source = fs.readFileSync(file, 'utf8') 125 | const { data: frontmatter } = matter(source) 126 | if (frontmatter.draft !== true) { 127 | allFrontMatter.push({ 128 | ...frontmatter, 129 | slug: formatSlug(fileName), 130 | date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null, 131 | }) 132 | } 133 | }) 134 | 135 | return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date)) 136 | } 137 | -------------------------------------------------------------------------------- /data/blog/github-markdown-guide.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Markdown Guide' 3 | date: '2019-10-11' 4 | tags: ['github', 'guide'] 5 | draft: false 6 | summary: 'Markdown cheatsheet for all your blogging needs - headers, lists, images, tables and more! An illustrated guide based on GitHub Flavored Markdown.' 7 | --- 8 | 9 | # Introduction 10 | 11 | Markdown and Mdx parsing is supported via `unified`, and other remark and rehype packages. `next-mdx-remote` allows us to parse `.mdx` and `.md` files in a more flexible manner without touching webpack. 12 | 13 | GitHub flavored markdown is used. `mdx-prism` provides syntax highlighting capabilities for code blocks. Here's a demo of how everything looks. 14 | 15 | The following markdown cheatsheet is adapted from: https://guides.github.com/features/mastering-markdown/ 16 | 17 | # What is Markdown? 18 | 19 | Markdown is a way to style text on the web. You control the display of the document; formatting words as bold or italic, adding images, and creating lists are just a few of the things we can do with Markdown. Mostly, Markdown is just regular text with a few non-alphabetic characters thrown in, like `#` or `*`. 20 | 21 | # Syntax guide 22 | 23 | Here’s an overview of Markdown syntax that you can use anywhere on GitHub.com or in your own text files. 24 | 25 | ## Headers 26 | 27 | ``` 28 | # This is a h1 tag 29 | 30 | ## This is a h2 tag 31 | 32 | #### This is a h4 tag 33 | ``` 34 | 35 | # This is a h1 tag 36 | 37 | ## This is a h2 tag 38 | 39 | #### This is a h4 tag 40 | 41 | ## Emphasis 42 | 43 | ``` 44 | _This text will be italic_ 45 | 46 | **This text will be bold** 47 | 48 | _You **can** combine them_ 49 | ``` 50 | 51 | _This text will be italic_ 52 | 53 | **This text will be bold** 54 | 55 | _You **can** combine them_ 56 | 57 | ## Lists 58 | 59 | ### Unordered 60 | 61 | ``` 62 | - Item 1 63 | - Item 2 64 | - Item 2a 65 | - Item 2b 66 | ``` 67 | 68 | - Item 1 69 | - Item 2 70 | - Item 2a 71 | - Item 2b 72 | 73 | ### Ordered 74 | 75 | ``` 76 | 1. Item 1 77 | 1. Item 2 78 | 1. Item 3 79 | 1. Item 3a 80 | 1. Item 3b 81 | ``` 82 | 83 | 1. Item 1 84 | 1. Item 2 85 | 1. Item 3 86 | 1. Item 3a 87 | 1. Item 3b 88 | 89 | ## Images 90 | 91 | ``` 92 | ![GitHub Logo](https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png) 93 | Format: ![Alt Text](url) 94 | ``` 95 | 96 | ![GitHub Logo](https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png) 97 | 98 | ## Links 99 | 100 | ``` 101 | http://github.com - automatic! 102 | [GitHub](http://github.com) 103 | ``` 104 | 105 | http://github.com - automatic! 106 | [GitHub](http://github.com) 107 | 108 | ## Blockquotes 109 | 110 | ``` 111 | As Kanye West said: 112 | 113 | > We're living the future so 114 | > the present is our past. 115 | ``` 116 | 117 | As Kanye West said: 118 | 119 | > We're living the future so 120 | > the present is our past. 121 | 122 | ## Inline code 123 | 124 | ``` 125 | I think you should use an 126 | `` element here instead. 127 | ``` 128 | 129 | I think you should use an 130 | `` element here instead. 131 | 132 | ## Syntax highlighting 133 | 134 | Here’s an example of how you can use syntax highlighting with [GitHub Flavored Markdown](https://help.github.com/articles/basic-writing-and-formatting-syntax/): 135 | 136 | ```` 137 | ```js:fancyAlert.js 138 | function fancyAlert(arg) { 139 | if (arg) { 140 | $.facebox({ div: '#foo' }) 141 | } 142 | } 143 | ``` 144 | ```` 145 | 146 | And here's how it looks - nicely colored with styled code titles! 147 | 148 | ```js:fancyAlert.js 149 | function fancyAlert(arg) { 150 | if (arg) { 151 | $.facebox({ div: '#foo' }) 152 | } 153 | } 154 | ``` 155 | 156 | ## Footnotes 157 | 158 | ``` 159 | Here is a simple footnote[^1]. With some additional text after it. 160 | 161 | [^1]: My reference. 162 | ``` 163 | 164 | Here is a simple footnote[^1]. With some additional text after it. 165 | 166 | [^1]: My reference. 167 | 168 | ## Task Lists 169 | 170 | ``` 171 | - [x] list syntax required (any unordered or ordered list supported) 172 | - [x] this is a complete item 173 | - [ ] this is an incomplete item 174 | ``` 175 | 176 | - [x] list syntax required (any unordered or ordered list supported) 177 | - [x] this is a complete item 178 | - [ ] this is an incomplete item 179 | 180 | ## Tables 181 | 182 | You can create tables by assembling a list of words and dividing them with hyphens `-` (for the first row), and then separating each column with a pipe `|`: 183 | 184 | ``` 185 | | First Header | Second Header | 186 | | --------------------------- | ---------------------------- | 187 | | Content from cell 1 | Content from cell 2 | 188 | | Content in the first column | Content in the second column | 189 | ``` 190 | 191 | | First Header | Second Header | 192 | | --------------------------- | ---------------------------- | 193 | | Content from cell 1 | Content from cell 2 | 194 | | Content in the first column | Content in the second column | 195 | 196 | ## Strikethrough 197 | 198 | Any word wrapped with two tildes (like `~~this~~`) will appear ~~crossed out~~. 199 | -------------------------------------------------------------------------------- /components/SEO.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import { useRouter } from 'next/router' 3 | import siteMetadata from '@/data/siteMetadata' 4 | 5 | const CommonSEO = ({ title, description, ogType, ogImage, twImage, canonicalUrl }) => { 6 | const router = useRouter() 7 | return ( 8 | 9 | {title} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {ogImage.constructor.name === 'Array' ? ( 18 | ogImage.map(({ url }) => ) 19 | ) : ( 20 | 21 | )} 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | ) 33 | } 34 | 35 | export const PageSEO = ({ title, description }) => { 36 | const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner 37 | const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner 38 | return ( 39 | 46 | ) 47 | } 48 | 49 | export const TagSEO = ({ title, description }) => { 50 | const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner 51 | const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner 52 | const router = useRouter() 53 | return ( 54 | <> 55 | 62 | 63 | 69 | 70 | 71 | ) 72 | } 73 | 74 | export const BlogSEO = ({ 75 | authorDetails, 76 | title, 77 | summary, 78 | date, 79 | lastmod, 80 | url, 81 | images = [], 82 | canonicalUrl, 83 | }) => { 84 | const router = useRouter() 85 | const publishedAt = new Date(date).toISOString() 86 | const modifiedAt = new Date(lastmod || date).toISOString() 87 | let imagesArr = 88 | images.length === 0 89 | ? [siteMetadata.socialBanner] 90 | : typeof images === 'string' 91 | ? [images] 92 | : images 93 | 94 | const featuredImages = imagesArr.map((img) => { 95 | return { 96 | '@type': 'ImageObject', 97 | url: img.includes('http') ? img : siteMetadata.siteUrl + img, 98 | } 99 | }) 100 | 101 | let authorList 102 | if (authorDetails) { 103 | authorList = authorDetails.map((author) => { 104 | return { 105 | '@type': 'Person', 106 | name: author.name, 107 | } 108 | }) 109 | } else { 110 | authorList = { 111 | '@type': 'Person', 112 | name: siteMetadata.author, 113 | } 114 | } 115 | 116 | const structuredData = { 117 | '@context': 'https://schema.org', 118 | '@type': 'Article', 119 | mainEntityOfPage: { 120 | '@type': 'WebPage', 121 | '@id': url, 122 | }, 123 | headline: title, 124 | image: featuredImages, 125 | datePublished: publishedAt, 126 | dateModified: modifiedAt, 127 | author: authorList, 128 | publisher: { 129 | '@type': 'Organization', 130 | name: siteMetadata.author, 131 | logo: { 132 | '@type': 'ImageObject', 133 | url: `${siteMetadata.siteUrl}${siteMetadata.siteLogo}`, 134 | }, 135 | }, 136 | description: summary, 137 | } 138 | 139 | const twImageUrl = featuredImages[0].url 140 | 141 | return ( 142 | <> 143 | 151 | 152 | {date && } 153 | {lastmod && } 154 |