├── .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
to get your form ID
19 | CONVERTKIT_FORM_ID=
20 |
21 | KLAVIYO_API_KEY=
22 | KLAVIYO_LIST_ID=
23 |
24 | REVUE_API_URL=https://www.getrevue.co/api/v2/
25 | REVUE_API_KEY=
26 |
27 | EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/
28 | EMAILOCTOPUS_API_KEY=
29 | EMAILOCTOPUS_LIST_ID=
--------------------------------------------------------------------------------
/pages/api/mailchimp.js:
--------------------------------------------------------------------------------
1 | import mailchimp from '@mailchimp/mailchimp_marketing'
2 |
3 | mailchimp.setConfig({
4 | apiKey: process.env.MAILCHIMP_API_KEY,
5 | server: process.env.MAILCHIMP_API_SERVER, // E.g. us1
6 | })
7 |
8 | // eslint-disable-next-line import/no-anonymous-default-export
9 | export default async (req, res) => {
10 | const { email } = req.body
11 |
12 | if (!email) {
13 | return res.status(400).json({ error: 'Email is required' })
14 | }
15 |
16 | try {
17 | const test = await mailchimp.lists.addListMember(process.env.MAILCHIMP_AUDIENCE_ID, {
18 | email_address: email,
19 | status: 'subscribed',
20 | })
21 | return res.status(201).json({ error: '' })
22 | } catch (error) {
23 | return res.status(500).json({ error: error.message || error.toString() })
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/components/social-icons/github.svg:
--------------------------------------------------------------------------------
1 | GitHub icon
--------------------------------------------------------------------------------
/components/analytics/SimpleAnalytics.js:
--------------------------------------------------------------------------------
1 | import Script from 'next/script'
2 |
3 | const SimpleAnalyticsScript = () => {
4 | return (
5 | <>
6 |
11 |
12 | >
13 | )
14 | }
15 |
16 | // https://docs.simpleanalytics.com/events
17 | export const logEvent = (eventName, callback) => {
18 | if (callback) {
19 | return window.sa_event?.(eventName, callback)
20 | } else {
21 | return window.sa_event?.(eventName)
22 | }
23 | }
24 |
25 | export default SimpleAnalyticsScript
26 |
--------------------------------------------------------------------------------
/components/analytics/Plausible.js:
--------------------------------------------------------------------------------
1 | import Script from 'next/script'
2 |
3 | import siteMetadata from '@/data/siteMetadata'
4 |
5 | const PlausibleScript = () => {
6 | return (
7 | <>
8 |
13 |
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 |
12 |
13 |
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 |
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 |
10 | {!prevPage && (
11 |
12 | Previous
13 |
14 | )}
15 | {prevPage && (
16 |
17 | Previous
18 |
19 | )}
20 |
21 | {currentPage} of {totalPages}
22 |
23 | {!nextPage && (
24 |
25 | Next
26 |
27 | )}
28 | {nextPage && (
29 |
30 | Next
31 |
32 | )}
33 |
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 && Load Comments }
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 |
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 |
24 | Back to homepage
25 |
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 | setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
17 | >
18 |
24 | {mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
25 |
30 | ) : (
31 |
32 | )}
33 |
34 |
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 | 
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 |
21 |
22 | ) : (
23 |
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 |
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 |
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 && Load Comments }
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 |
36 |
43 | {copied ? (
44 | <>
45 |
51 | >
52 | ) : (
53 | <>
54 |
60 | >
61 | )}
62 |
63 |
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 |
27 | {siteMetadata.comment.provider && (
28 |
34 |
35 |
40 |
41 |
42 | )}
43 |
49 |
50 |
55 |
56 |
57 |
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 && Load Comments }
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 |
28 |
34 |
39 |
40 |
41 |
46 |
47 |
53 |
59 |
64 |
65 |
66 |
67 |
68 | {headerNavLinks.map((link) => (
69 |
70 |
75 | {link.title}
76 |
77 |
78 | ))}
79 |
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 | {formatDate(date)}
26 |
27 |
28 |
29 |
32 |
33 |
34 |
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 |
69 | {error && (
70 |
{message}
71 | )}
72 |
73 | )
74 | }
75 |
76 | export default NewsletterForm
77 |
78 | export const BlogNewsletterForm = ({ title }) => (
79 |
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 |
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 | 
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 |
46 | ```
47 |
48 | _Note_: If you try to save the image, it is in webp format, if your browser supports it!
49 |
50 | 
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 | 
24 |
25 |
26 | 
27 |
28 |
29 | 
30 |
31 |
32 | 
33 |
34 |
35 |
36 | # Implementation
37 |
38 | ```js
39 |
40 |
41 | 
42 |
43 |
44 | 
45 |
46 |
47 | 
48 |
49 |
50 | 
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 | {formatDate(date)}
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 | {formatDate(date)}
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 | 
93 | Format: 
94 | ```
95 |
96 | 
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 |
160 |
161 | >
162 | )
163 | }
164 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require('tailwindcss/defaultTheme')
2 | const colors = require('tailwindcss/colors')
3 |
4 | module.exports = {
5 | experimental: {
6 | optimizeUniversalDefaults: true,
7 | },
8 | content: [
9 | './pages/**/*.js',
10 | './components/**/*.js',
11 | './layouts/**/*.js',
12 | './lib/**/*.js',
13 | './data/**/*.mdx',
14 | ],
15 | darkMode: 'class',
16 | theme: {
17 | extend: {
18 | spacing: {
19 | '9/16': '56.25%',
20 | },
21 | lineHeight: {
22 | 11: '2.75rem',
23 | 12: '3rem',
24 | 13: '3.25rem',
25 | 14: '3.5rem',
26 | },
27 | fontFamily: {
28 | sans: ['InterVariable', ...defaultTheme.fontFamily.sans],
29 | },
30 | colors: {
31 | primary: colors.teal,
32 | gray: colors.neutral,
33 | },
34 | typography: (theme) => ({
35 | DEFAULT: {
36 | css: {
37 | color: theme('colors.gray.700'),
38 | a: {
39 | color: theme('colors.primary.500'),
40 | '&:hover': {
41 | color: `${theme('colors.primary.600')} !important`,
42 | },
43 | code: { color: theme('colors.primary.400') },
44 | },
45 | h1: {
46 | fontWeight: '700',
47 | letterSpacing: theme('letterSpacing.tight'),
48 | color: theme('colors.gray.900'),
49 | },
50 | h2: {
51 | fontWeight: '700',
52 | letterSpacing: theme('letterSpacing.tight'),
53 | color: theme('colors.gray.900'),
54 | },
55 | h3: {
56 | fontWeight: '600',
57 | color: theme('colors.gray.900'),
58 | },
59 | 'h4,h5,h6': {
60 | color: theme('colors.gray.900'),
61 | },
62 | pre: {
63 | backgroundColor: theme('colors.gray.800'),
64 | },
65 | code: {
66 | color: theme('colors.pink.500'),
67 | backgroundColor: theme('colors.gray.100'),
68 | paddingLeft: '4px',
69 | paddingRight: '4px',
70 | paddingTop: '2px',
71 | paddingBottom: '2px',
72 | borderRadius: '0.25rem',
73 | },
74 | 'code::before': {
75 | content: 'none',
76 | },
77 | 'code::after': {
78 | content: 'none',
79 | },
80 | details: {
81 | backgroundColor: theme('colors.gray.100'),
82 | paddingLeft: '4px',
83 | paddingRight: '4px',
84 | paddingTop: '2px',
85 | paddingBottom: '2px',
86 | borderRadius: '0.25rem',
87 | },
88 | hr: { borderColor: theme('colors.gray.200') },
89 | 'ol li::marker': {
90 | fontWeight: '600',
91 | color: theme('colors.gray.500'),
92 | },
93 | 'ul li::marker': {
94 | backgroundColor: theme('colors.gray.500'),
95 | },
96 | strong: { color: theme('colors.gray.600') },
97 | blockquote: {
98 | color: theme('colors.gray.900'),
99 | borderLeftColor: theme('colors.gray.200'),
100 | },
101 | },
102 | },
103 | dark: {
104 | css: {
105 | color: theme('colors.gray.300'),
106 | a: {
107 | color: theme('colors.primary.500'),
108 | '&:hover': {
109 | color: `${theme('colors.primary.400')} !important`,
110 | },
111 | code: { color: theme('colors.primary.400') },
112 | },
113 | h1: {
114 | fontWeight: '700',
115 | letterSpacing: theme('letterSpacing.tight'),
116 | color: theme('colors.gray.100'),
117 | },
118 | h2: {
119 | fontWeight: '700',
120 | letterSpacing: theme('letterSpacing.tight'),
121 | color: theme('colors.gray.100'),
122 | },
123 | h3: {
124 | fontWeight: '600',
125 | color: theme('colors.gray.100'),
126 | },
127 | 'h4,h5,h6': {
128 | color: theme('colors.gray.100'),
129 | },
130 | pre: {
131 | backgroundColor: theme('colors.gray.800'),
132 | },
133 | code: {
134 | backgroundColor: theme('colors.gray.800'),
135 | },
136 | details: {
137 | backgroundColor: theme('colors.gray.800'),
138 | },
139 | hr: { borderColor: theme('colors.gray.700') },
140 | 'ol li::marker': {
141 | fontWeight: '600',
142 | color: theme('colors.gray.400'),
143 | },
144 | 'ul li::marker': {
145 | backgroundColor: theme('colors.gray.400'),
146 | },
147 | strong: { color: theme('colors.gray.100') },
148 | thead: {
149 | th: {
150 | color: theme('colors.gray.100'),
151 | },
152 | },
153 | tbody: {
154 | tr: {
155 | borderBottomColor: theme('colors.gray.700'),
156 | },
157 | },
158 | blockquote: {
159 | color: theme('colors.gray.100'),
160 | borderLeftColor: theme('colors.gray.700'),
161 | },
162 | },
163 | },
164 | }),
165 | },
166 | },
167 | plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
168 | }
169 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | ## Source: https://github.com/alexkaratarakis/gitattributes
2 | ## Modified * text=auto to * text=auto eol=lf to force LF endings.
3 |
4 | ## GITATTRIBUTES FOR WEB PROJECTS
5 | #
6 | # These settings are for any web project.
7 | #
8 | # Details per file setting:
9 | # text These files should be normalized (i.e. convert CRLF to LF).
10 | # binary These files are binary and should be left untouched.
11 | #
12 | # Note that binary is a macro for -text -diff.
13 | ######################################################################
14 |
15 | # Auto detect
16 | ## Force LF line endings automatically for files detected as
17 | ## text and leave all files detected as binary untouched.
18 | ## This will handle all files NOT defined below.
19 | * text=auto eol=lf
20 |
21 | # Source code
22 | *.bash text eol=lf
23 | *.bat text eol=crlf
24 | *.cmd text eol=crlf
25 | *.coffee text
26 | *.css text
27 | *.htm text diff=html
28 | *.html text diff=html
29 | *.inc text
30 | *.ini text
31 | *.js text
32 | *.json text
33 | *.jsx text
34 | *.less text
35 | *.ls text
36 | *.map text -diff
37 | *.od text
38 | *.onlydata text
39 | *.php text diff=php
40 | *.pl text
41 | *.ps1 text eol=crlf
42 | *.py text diff=python
43 | *.rb text diff=ruby
44 | *.sass text
45 | *.scm text
46 | *.scss text diff=css
47 | *.sh text eol=lf
48 | *.sql text
49 | *.styl text
50 | *.tag text
51 | *.ts text
52 | *.tsx text
53 | *.xml text
54 | *.xhtml text diff=html
55 |
56 | # Docker
57 | Dockerfile text
58 |
59 | # Documentation
60 | *.ipynb text
61 | *.markdown text
62 | *.md text
63 | *.mdwn text
64 | *.mdown text
65 | *.mkd text
66 | *.mkdn text
67 | *.mdtxt text
68 | *.mdtext text
69 | *.txt text
70 | AUTHORS text
71 | CHANGELOG text
72 | CHANGES text
73 | CONTRIBUTING text
74 | COPYING text
75 | copyright text
76 | *COPYRIGHT* text
77 | INSTALL text
78 | license text
79 | LICENSE text
80 | NEWS text
81 | readme text
82 | *README* text
83 | TODO text
84 |
85 | # Templates
86 | *.dot text
87 | *.ejs text
88 | *.haml text
89 | *.handlebars text
90 | *.hbs text
91 | *.hbt text
92 | *.jade text
93 | *.latte text
94 | *.mustache text
95 | *.njk text
96 | *.phtml text
97 | *.tmpl text
98 | *.tpl text
99 | *.twig text
100 | *.vue text
101 |
102 | # Configs
103 | *.cnf text
104 | *.conf text
105 | *.config text
106 | .editorconfig text
107 | .env text
108 | .gitattributes text
109 | .gitconfig text
110 | .htaccess text
111 | *.lock text -diff
112 | package-lock.json text -diff
113 | *.toml text
114 | *.yaml text
115 | *.yml text
116 | browserslist text
117 | Makefile text
118 | makefile text
119 |
120 | # Heroku
121 | Procfile text
122 |
123 | # Graphics
124 | *.ai binary
125 | *.bmp binary
126 | *.eps binary
127 | *.gif binary
128 | *.gifv binary
129 | *.ico binary
130 | *.jng binary
131 | *.jp2 binary
132 | *.jpg binary
133 | *.jpeg binary
134 | *.jpx binary
135 | *.jxr binary
136 | *.pdf binary
137 | *.png binary
138 | *.psb binary
139 | *.psd binary
140 | # SVG treated as an asset (binary) by default.
141 | *.svg text
142 | # If you want to treat it as binary,
143 | # use the following line instead.
144 | # *.svg binary
145 | *.svgz binary
146 | *.tif binary
147 | *.tiff binary
148 | *.wbmp binary
149 | *.webp binary
150 |
151 | # Audio
152 | *.kar binary
153 | *.m4a binary
154 | *.mid binary
155 | *.midi binary
156 | *.mp3 binary
157 | *.ogg binary
158 | *.ra binary
159 |
160 | # Video
161 | *.3gpp binary
162 | *.3gp binary
163 | *.as binary
164 | *.asf binary
165 | *.asx binary
166 | *.fla binary
167 | *.flv binary
168 | *.m4v binary
169 | *.mng binary
170 | *.mov binary
171 | *.mp4 binary
172 | *.mpeg binary
173 | *.mpg binary
174 | *.ogv binary
175 | *.swc binary
176 | *.swf binary
177 | *.webm binary
178 |
179 | # Archives
180 | *.7z binary
181 | *.gz binary
182 | *.jar binary
183 | *.rar binary
184 | *.tar binary
185 | *.zip binary
186 |
187 | # Fonts
188 | *.ttf binary
189 | *.eot binary
190 | *.otf binary
191 | *.woff binary
192 | *.woff2 binary
193 |
194 | # Executables
195 | *.exe binary
196 | *.pyc binary
197 |
198 | # RC files (like .babelrc or .eslintrc)
199 | *.*rc text
200 |
201 | # Ignore files (like .npmignore or .gitignore)
202 | *.*ignore text
203 |
--------------------------------------------------------------------------------
/layouts/PostLayout.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 Image from '@/components/Image'
6 | import Tag from '@/components/Tag'
7 | import siteMetadata from '@/data/siteMetadata'
8 | import Comments from '@/components/comments'
9 | import ScrollTopAndComment from '@/components/ScrollTopAndComment'
10 |
11 | const editUrl = (fileName) => `${siteMetadata.siteRepo}/blob/master/data/blog/${fileName}`
12 | const discussUrl = (slug) =>
13 | `https://mobile.twitter.com/search?q=${encodeURIComponent(
14 | `${siteMetadata.siteUrl}/blog/${slug}`
15 | )}`
16 |
17 | const postDateTemplate = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
18 |
19 | export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
20 | const { slug, fileName, date, title, images, tags } = frontMatter
21 |
22 | return (
23 |
24 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
Published on
37 |
38 |
39 | {new Date(date).toLocaleDateString(siteMetadata.locale, postDateTemplate)}
40 |
41 |
42 |
43 |
44 |
47 |
48 |
49 |
53 |
54 | Authors
55 |
56 |
57 | {authorDetails.map((author) => (
58 |
59 | {author.avatar && (
60 |
67 | )}
68 |
69 | Name
70 | {author.name}
71 | Twitter
72 |
73 | {author.twitter && (
74 |
78 | {author.twitter.replace('https://twitter.com/', '@')}
79 |
80 | )}
81 |
82 |
83 |
84 | ))}
85 |
86 |
87 |
88 |
89 |
{children}
90 |
91 |
92 | {'Discuss on Twitter'}
93 |
94 | {` • `}
95 | {'View on GitHub'}
96 |
97 |
98 |
99 |
147 |
148 |
149 |
150 |
151 | )
152 | }
153 |
--------------------------------------------------------------------------------
/data/blog/the-time-machine.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'The Time Machine'
3 | date: '2018-08-15'
4 | tags: ['writings', 'book', 'reflection']
5 | draft: false
6 | summary: 'The Time Traveller (for so it will be convenient to speak of him) was
7 | expounding a recondite matter to us. His pale grey eyes shone and
8 | twinkled, and his usually pale face was flushed and animated...'
9 | ---
10 |
11 | # The Time Machine by H. G. Wells
12 |
13 | _Title_: The Time Machine
14 |
15 | _Author_: H. G. Wells
16 |
17 | _Subject_: Science Fiction
18 |
19 | _Language_: English
20 |
21 | _Source_: [Project Gutenberg](https://www.gutenberg.org/ebooks/35)
22 |
23 | ## Introduction
24 |
25 | The Time Traveller (for so it will be convenient to speak of him) was
26 | expounding a recondite matter to us. His pale grey eyes shone and
27 | twinkled, and his usually pale face was flushed and animated. The fire
28 | burnt brightly, and the soft radiance of the incandescent lights in the
29 | lilies of silver caught the bubbles that flashed and passed in our
30 | glasses. Our chairs, being his patents, embraced and caressed us rather
31 | than submitted to be sat upon, and there was that luxurious
32 | after-dinner atmosphere, when thought runs gracefully free of the
33 | trammels of precision. And he put it to us in this way—marking the
34 | points with a lean forefinger—as we sat and lazily admired his
35 | earnestness over this new paradox (as we thought it) and his fecundity.
36 |
37 | “You must follow me carefully. I shall have to controvert one or two
38 | ideas that are almost universally accepted. The geometry, for instance,
39 | they taught you at school is founded on a misconception.”
40 |
41 | “Is not that rather a large thing to expect us to begin upon?” said
42 | Filby, an argumentative person with red hair.
43 |
44 | “I do not mean to ask you to accept anything without reasonable ground
45 | for it. You will soon admit as much as I need from you. You know of
46 | course that a mathematical line, a line of thickness _nil_, has no real
47 | existence. They taught you that? Neither has a mathematical plane.
48 | These things are mere abstractions.”
49 |
50 | “That is all right,” said the Psychologist.
51 |
52 | “Nor, having only length, breadth, and thickness, can a cube have a
53 | real existence.”
54 |
55 | “There I object,” said Filby. “Of course a solid body may exist. All
56 | real things—”
57 |
58 | “So most people think. But wait a moment. Can an _instantaneous_ cube
59 | exist?”
60 |
61 | “Don’t follow you,” said Filby.
62 |
63 | “Can a cube that does not last for any time at all, have a real
64 | existence?”
65 |
66 | Filby became pensive. “Clearly,” the Time Traveller proceeded, “any
67 | real body must have extension in _four_ directions: it must have
68 | Length, Breadth, Thickness, and—Duration. But through a natural
69 | infirmity of the flesh, which I will explain to you in a moment, we
70 | incline to overlook this fact. There are really four dimensions, three
71 | which we call the three planes of Space, and a fourth, Time. There is,
72 | however, a tendency to draw an unreal distinction between the former
73 | three dimensions and the latter, because it happens that our
74 | consciousness moves intermittently in one direction along the latter
75 | from the beginning to the end of our lives.”
76 |
77 | “That,” said a very young man, making spasmodic efforts to relight his
78 | cigar over the lamp; “that . . . very clear indeed.”
79 |
80 | “Now, it is very remarkable that this is so extensively overlooked,”
81 | continued the Time Traveller, with a slight accession of cheerfulness.
82 | “Really this is what is meant by the Fourth Dimension, though some
83 | people who talk about the Fourth Dimension do not know they mean it. It
84 | is only another way of looking at Time. _There is no difference between
85 | Time and any of the three dimensions of Space except that our
86 | consciousness moves along it_. But some foolish people have got hold of
87 | the wrong side of that idea. You have all heard what they have to say
88 | about this Fourth Dimension?”
89 |
90 | “_I_ have not,” said the Provincial Mayor.
91 |
92 | “It is simply this. That Space, as our mathematicians have it, is
93 | spoken of as having three dimensions, which one may call Length,
94 | Breadth, and Thickness, and is always definable by reference to three
95 | planes, each at right angles to the others. But some philosophical
96 | people have been asking why _three_ dimensions particularly—why not
97 | another direction at right angles to the other three?—and have even
98 | tried to construct a Four-Dimensional geometry. Professor Simon Newcomb
99 | was expounding this to the New York Mathematical Society only a month
100 | or so ago. You know how on a flat surface, which has only two
101 | dimensions, we can represent a figure of a three-dimensional solid, and
102 | similarly they think that by models of three dimensions they could
103 | represent one of four—if they could master the perspective of the
104 | thing. See?”
105 |
106 | “I think so,” murmured the Provincial Mayor; and, knitting his brows,
107 | he lapsed into an introspective state, his lips moving as one who
108 | repeats mystic words. “Yes, I think I see it now,” he said after some
109 | time, brightening in a quite transitory manner.
110 |
111 | “Well, I do not mind telling you I have been at work upon this geometry
112 | of Four Dimensions for some time. Some of my results are curious. For
113 | instance, here is a portrait of a man at eight years old, another at
114 | fifteen, another at seventeen, another at twenty-three, and so on. All
115 | these are evidently sections, as it were, Three-Dimensional
116 | representations of his Four-Dimensioned being, which is a fixed and
117 | unalterable thing.
118 |
119 | “Scientific people,” proceeded the Time Traveller, after the pause
120 | required for the proper assimilation of this, “know very well that Time
121 | is only a kind of Space. Here is a popular scientific diagram, a
122 | weather record. This line I trace with my finger shows the movement of
123 | the barometer. Yesterday it was so high, yesterday night it fell, then
124 | this morning it rose again, and so gently upward to here. Surely the
125 | mercury did not trace this line in any of the dimensions of Space
126 | generally recognised? But certainly it traced such a line, and that
127 | line, therefore, we must conclude, was along the Time-Dimension.”
128 |
129 | “But,” said the Medical Man, staring hard at a coal in the fire, “if
130 | Time is really only a fourth dimension of Space, why is it, and why has
131 | it always been, regarded as something different? And why cannot we move
132 | in Time as we move about in the other dimensions of Space?”
133 |
134 | The Time Traveller smiled. “Are you so sure we can move freely in
135 | Space? Right and left we can go, backward and forward freely enough,
136 | and men always have done so. I admit we move freely in two dimensions.
137 | But how about up and down? Gravitation limits us there.”
138 |
139 | “Not exactly,” said the Medical Man. “There are balloons.”
140 |
141 | “But before the balloons, save for spasmodic jumping and the
142 | inequalities of the surface, man had no freedom of vertical movement.”
143 |
144 | “Still they could move a little up and down,” said the Medical Man.
145 |
146 | “Easier, far easier down than up.”
147 |
148 | “And you cannot move at all in Time, you cannot get away from the
149 | present moment.”
150 |
151 | “My dear sir, that is just where you are wrong. That is just where the
152 | whole world has gone wrong. We are always getting away from the present
153 | moment. Our mental existences, which are immaterial and have no
154 | dimensions, are passing along the Time-Dimension with a uniform
155 | velocity from the cradle to the grave. Just as we should travel _down_
156 | if we began our existence fifty miles above the earth’s surface.”
157 |
158 | “But the great difficulty is this,” interrupted the Psychologist. ’You
159 | _can_ move about in all directions of Space, but you cannot move about
160 | in Time.”
161 |
162 | “That is the germ of my great discovery. But you are wrong to say that
163 | we cannot move about in Time. For instance, if I am recalling an
164 | incident very vividly I go back to the instant of its occurrence: I
165 | become absent-minded, as you say. I jump back for a moment. Of course
166 | we have no means of staying back for any length of Time, any more than
167 | a savage or an animal has of staying six feet above the ground. But a
168 | civilised man is better off than the savage in this respect. He can go
169 | up against gravitation in a balloon, and why should he not hope that
170 | ultimately he may be able to stop or accelerate his drift along the
171 | Time-Dimension, or even turn about and travel the other way?”
172 |
173 | “Oh, _this_,” began Filby, “is all—”
174 |
175 | “Why not?” said the Time Traveller.
176 |
177 | “It’s against reason,” said Filby.
178 |
179 | “What reason?” said the Time Traveller.
180 |
181 | “You can show black is white by argument,” said Filby, “but you will
182 | never convince me.”
183 |
184 | “Possibly not,” said the Time Traveller. “But now you begin to see the
185 | object of my investigations into the geometry of Four Dimensions. Long
186 | ago I had a vague inkling of a machine—”
187 |
188 | “To travel through Time!” exclaimed the Very Young Man.
189 |
190 | “That shall travel indifferently in any direction of Space and Time, as
191 | the driver determines.”
192 |
193 | Filby contented himself with laughter.
194 |
195 | “But I have experimental verification,” said the Time Traveller.
196 |
197 | “It would be remarkably convenient for the historian,” the Psychologist
198 | suggested. “One might travel back and verify the accepted account of
199 | the Battle of Hastings, for instance!”
200 |
201 | “Don’t you think you would attract attention?” said the Medical Man.
202 | “Our ancestors had no great tolerance for anachronisms.”
203 |
204 | “One might get one’s Greek from the very lips of Homer and Plato,” the
205 | Very Young Man thought.
206 |
207 | “In which case they would certainly plough you for the Little-go. The
208 | German scholars have improved Greek so much.”
209 |
210 | “Then there is the future,” said the Very Young Man. “Just think! One
211 | might invest all one’s money, leave it to accumulate at interest, and
212 | hurry on ahead!”
213 |
214 | “To discover a society,” said I, “erected on a strictly communistic
215 | basis.”
216 |
217 | “Of all the wild extravagant theories!” began the Psychologist.
218 |
219 | “Yes, so it seemed to me, and so I never talked of it until—”
220 |
221 | “Experimental verification!” cried I. “You are going to verify _that_?”
222 |
223 | “The experiment!” cried Filby, who was getting brain-weary.
224 |
225 | “Let’s see your experiment anyhow,” said the Psychologist, “though it’s
226 | all humbug, you know.”
227 |
228 | The Time Traveller smiled round at us. Then, still smiling faintly, and
229 | with his hands deep in his trousers pockets, he walked slowly out of
230 | the room, and we heard his slippers shuffling down the long passage to
231 | his laboratory.
232 |
233 | The Psychologist looked at us. “I wonder what he’s got?”
234 |
235 | “Some sleight-of-hand trick or other,” said the Medical Man, and Filby
236 | tried to tell us about a conjuror he had seen at Burslem, but before he
237 | had finished his preface the Time Traveller came back, and Filby’s
238 | anecdote collapsed.
239 |
--------------------------------------------------------------------------------
/data/blog/introducing-tailwind-nextjs-starter-blog.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Introducing Tailwind Nextjs Starter Blog'
3 | date: '2021-01-12'
4 | lastmod: '2021-02-01'
5 | tags: ['next-js', 'tailwind', 'guide']
6 | draft: false
7 | summary: 'Looking for a performant, out of the box template, with all the best in web technology to support your blogging needs? Checkout the Tailwind Nextjs Starter Blog template.'
8 | images: ['/static/images/canada/mountains.jpg', '/static/images/canada/toronto.jpg']
9 | authors: ['default', 'sparrowhawk']
10 | ---
11 |
12 | 
13 |
14 | # Tailwind Nextjs Starter Blog
15 |
16 | [](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
17 |
18 | This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Probably the most feature-rich Next.js markdown blogging template out there. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
19 |
20 | Check out the documentation below to get started.
21 |
22 | Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-starter-blog/wiki) and do a search on past issues. Feel free to open a new issue if none has been posted previously.
23 |
24 | Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed!
25 |
26 | ## Examples
27 |
28 | - [Demo Blog](https://tailwind-nextjs-starter-blog.vercel.app/) - this repo
29 | - [My personal blog](https://www.timlrx.com) - modified to auto-generate blog posts with dates
30 | - [Aloisdg's cookbook](https://tambouille.vercel.app/) - with pictures and recipes!
31 | - [GautierArcin's demo with next translate](https://tailwind-nextjs-starter-blog-seven.vercel.app/) - includes translation of mdx posts, [source code](https://github.com/GautierArcin/tailwind-nextjs-starter-blog/tree/demo/next-translate)
32 | - [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
33 | - [Thinh's Corner](https://thinhcorner.com/) - [customized layout](https://github.com/Th1nhNg0/th1nhng0.vercel.app/blob/5e73a420828d82f01e7147512a2c3273c4ec19f8/layouts/PostLayout.js) with sticky side table of contents
34 |
35 | Using the template? Feel free to create a PR and add your blog to this list.
36 |
37 | ## Motivation
38 |
39 | I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
40 |
41 | I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
42 |
43 | ## Features
44 |
45 | - Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
46 | - Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/210111_DiC1_08f3670c3430bf4a9b76fc3b927716c5/)
47 | - Lightweight, 45kB first load JS, uses Preact in production build
48 | - Mobile-friendly view
49 | - Light and dark theme
50 | - Self-hosted font with [Fontsource](https://fontsource.org/)
51 | - Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
52 | - [MDX - write JSX in markdown documents!](https://mdxjs.com/)
53 | - Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
54 | - Math display supported via [KaTeX](https://katex.org/)
55 | - Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation)
56 | - Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
57 | - Flexible data retrieval with [mdx-bundler](https://github.com/kentcdodds/mdx-bundler)
58 | - Support for tags - each unique tag will be its own page
59 | - Support for multiple authors
60 | - Blog templates
61 | - TOC component
62 | - Support for nested routing of blog posts
63 | - Newsletter component with support for mailchimp, buttondown, convertkit, klaviyo, revue, and emailoctopus
64 | - Supports [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus
65 | - Projects page
66 | - Preconfigured security headers
67 | - SEO friendly with RSS feed, sitemaps and more!
68 |
69 | ## Sample posts
70 |
71 | - [A markdown guide](https://tailwind-nextjs-starter-blog.vercel.app/blog/github-markdown-guide)
72 | - [Learn more about images in Next.js](https://tailwind-nextjs-starter-blog.vercel.app/blog/guide-to-using-images-in-nextjs)
73 | - [A tour of math typesetting](https://tailwind-nextjs-starter-blog.vercel.app/blog/deriving-ols-estimator)
74 | - [Simple MDX image grid](https://tailwind-nextjs-starter-blog.vercel.app/blog/pictures-of-canada)
75 | - [Example of long prose](https://tailwind-nextjs-starter-blog.vercel.app/blog/the-time-machine)
76 | - [Example of Nested Route Post](https://tailwind-nextjs-starter-blog.vercel.app/blog/nested-route/introducing-multi-part-posts-with-nested-routing)
77 |
78 | ## Quick Start Guide
79 |
80 | 1. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny):
81 |
82 | ```bash
83 | npm i -g @pliny/cli
84 | pliny new --template=starter-blog my-blog
85 | ```
86 |
87 | It supports the updated version of the blog with Contentlayer, optional choice of TS/JS and different package managers as well as more modularized components which will be the basis of the template going forward.
88 |
89 | Alternatively to stick with the current version, TypeScript and Contentlayer:
90 |
91 | ```bash
92 | npx degit 'timlrx/tailwind-nextjs-starter-blog#contentlayer'
93 | ```
94 |
95 | or JS (official support)
96 |
97 | ```bash
98 | npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
99 | ```
100 |
101 | 2. Personalize `siteMetadata.js` (site related information)
102 | 3. Modify the content security policy in `next.config.js` if you want to use
103 | any analytics provider or a commenting solution other than giscus.
104 | 4. Personalize `authors/default.md` (main author)
105 | 5. Modify `projectsData.js`
106 | 6. Modify `headerNavLinks.js` to customize navigation links
107 | 7. Add blog posts
108 | 8. Deploy on Vercel
109 |
110 | ## Development
111 |
112 | First, run the development server:
113 |
114 | ```bash
115 | npm start
116 | # or
117 | npm run dev
118 | ```
119 |
120 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
121 |
122 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
123 |
124 | ## Extend / Customize
125 |
126 | `data/siteMetadata.js` - contains most of the site related information which should be modified for a user's need.
127 |
128 | `data/authors/default.md` - default author information (required). Additional authors can be added as files in `data/authors`.
129 |
130 | `data/projectsData.js` - data used to generate styled card on the projects page.
131 |
132 | `data/headerNavLinks.js` - navigation links.
133 |
134 | `data/logo.svg` - replace with your own logo.
135 |
136 | `data/blog` - replace with your own blog posts.
137 |
138 | `public/static` - store assets such as images and favicons.
139 |
140 | `tailwind.config.js` and `css/tailwind.css` - contain the tailwind stylesheet which can be modified to change the overall look and feel of the site.
141 |
142 | `css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
143 |
144 | `components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons use [heroicons](https://heroicons.com/).
145 |
146 | `components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then call them directly in the `.mdx` or `.md` file. By default, a custom link and image component is passed.
147 |
148 | `layouts` - main templates used in pages.
149 |
150 | `pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
151 |
152 | `next.config.js` - configuration related to Next.js. You need to adapt the Content Security Policy if you want to load scripts, images etc. from other domains.
153 |
154 | ## Post
155 |
156 | ### Frontmatter
157 |
158 | Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
159 |
160 | Currently 7 fields are supported.
161 |
162 | ```
163 | title (required)
164 | date (required)
165 | tags (required, can be empty array)
166 | lastmod (optional)
167 | draft (optional)
168 | summary (optional)
169 | images (optional, if none provided defaults to socialBanner in siteMetadata config)
170 | authors (optional list which should correspond to the file names in `data/authors`. Uses `default` if none is specified)
171 | layout (optional list which should correspond to the file names in `data/layouts`)
172 | canonicalUrl (optional, canonical url for the post for SEO)
173 | ```
174 |
175 | Here's an example of a post's frontmatter:
176 |
177 | ```
178 | ---
179 | title: 'Introducing Tailwind Nexjs Starter Blog'
180 | date: '2021-01-12'
181 | lastmod: '2021-01-18'
182 | tags: ['next-js', 'tailwind', 'guide']
183 | draft: false
184 | summary: 'Looking for a performant, out of the box template, with all the best in web technology to support your blogging needs? Checkout the Tailwind Nextjs Starter Blog template.'
185 | images: ['/static/images/canada/mountains.jpg', '/static/images/canada/toronto.jpg']
186 | authors: ['default', 'sparrowhawk']
187 | layout: PostLayout
188 | canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/introducing-tailwind-nextjs-starter-blog
189 | ---
190 | ```
191 |
192 | ### Compose
193 |
194 | Run `node ./scripts/compose.js` to bootstrap a new post.
195 |
196 | Follow the interactive prompt to generate a post with pre-filled front matter.
197 |
198 | ## Deploy
199 |
200 | **Vercel**
201 | The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
202 |
203 | **Netlify / GitHub Pages / Firebase etc.**
204 | As the template uses `next/image` for image optimization, additional configurations have to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [GitHub Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard ` ` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details.
205 |
206 | The API routes used in the newsletter component cannot be used in a static site export. You will need to use a form API endpoint provider and substitute the route in the newsletter component accordingly. Other hosting platforms such as Netlify also offer alternative solutions - please refer to their docs for more information.
207 |
208 | ## Support
209 |
210 | Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or be a project [sponsor](https://github.com/sponsors/timlrx).
211 |
212 | ## Licence
213 |
214 | [MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timrlx.com)
215 |
--------------------------------------------------------------------------------
/data/blog/new-features-in-v1.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'New features in v1'
3 | date: 2021-08-07T15:32:14Z
4 | lastmod: '2021-02-01'
5 | tags: ['next-js', 'tailwind', 'guide']
6 | draft: false
7 | summary: 'An overview of the new features released in v1 - code block copy, multiple authors, frontmatter layout and more'
8 | layout: PostSimple
9 | bibliography: references-data.bib
10 | canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/new-features-in-v1/
11 | ---
12 |
13 | ## Overview
14 |
15 | A post on the new features introduced in v1.0. New features:
16 |
17 |
18 |
19 | First load JS decreased from 43kB to 39kB despite all the new features added!^[With the new changes in Nextjs 12, first load JS increase to 45kB.]
20 |
21 | See [upgrade guide](#upgrade-guide) below if you are migrating from v0 version of the template.
22 |
23 | ## Theme colors
24 |
25 | You can easily modify the theme color by changing the primary attribute in the tailwind config file:
26 |
27 | ```js:tailwind.config.js
28 | theme: {
29 | colors: {
30 | primary: colors.teal,
31 | gray: colors.neutral,
32 | ...
33 | }
34 | ...
35 | }
36 | ```
37 |
38 | The primary color attribute should be assigned an object with keys from 50, 100, 200 ... 900 and the corresponding color code values.
39 |
40 | Tailwind includes great default color palettes that can be used for theming your own website. Check out [customizing colors documentation page](https://tailwindcss.com/docs/customizing-colors) for the full range of options.
41 |
42 | Migrating from v1? You can revert to the previous theme by setting `primary` to `colors.sky` (Tailwind 2.2.2 and above, otherwise `colors.lightBlue`) and changing gray to `colors.gray`.
43 |
44 | From v1.1.2+, you can also customize the style of your code blocks easily by modifying the `css/prism.css` stylesheet. Token classnames are compatible with prismjs
45 | so you can copy and adapt token styles from a prismjs stylesheet e.g. [prism themes](https://github.com/PrismJS/prism-themes).
46 |
47 | ## Xdm MDX compiler
48 |
49 | We switched the MDX bundler from [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) to [mdx-bundler](https://github.com/kentcdodds/mdx-bundler).
50 | This uses [xdm](https://github.com/wooorm/xdm) under the hood, the latest micromark 3 and remark, rehype libraries.
51 |
52 | **Warning:** If you were using custom remark or rehype libraries, please upgrade to micromark 3 compatible ones. If you are upgrading, please delete `node_modules` and `package-lock.json` to avoid having past dependencies related issues.
53 |
54 | [xdm](https://github.com/wooorm/xdm) contains multiple improvements over [@mdx-js/mdx](https://github.com/mdx-js/mdx), the compiler used internally by next-mdx-remote, but there might be some breaking behaviour changes.
55 | Please check your markdown output to verify.
56 |
57 | Some new possibilities include loading components directly in the mdx file using the import syntax and including js code which could be compiled and bundled at the build step.
58 |
59 | For example, the following jsx snippet can be used directly in an MDX file to render the page title component:
60 |
61 | ```jsx
62 | import PageTitle from './PageTitle.js'
63 | ; Using JSX components in MDX
64 | ```
65 |
66 | import PageTitle from './PageTitle.js'
67 |
68 | Using JSX components in MDX
69 |
70 | The default configuration resolves all components relative to the `components` directory.
71 |
72 | **Note**:
73 | Components which require external image loaders also require additional esbuild configuration.
74 | Components which are dependent on global application state on lifecycle like the Nextjs `Link` component would also not work with this setup as each mdx file is built independently.
75 | For such cases, it is better to use component substitution.
76 |
77 | ## Table of contents component
78 |
79 | Inspired by [Docusaurus](https://docusaurus.io/docs/next/markdown-features/inline-toc) and Gatsby's [gatsby-remark-table-of-contents](https://www.gatsbyjs.com/plugins/gatsby-remark-table-of-contents/),
80 | the `toc` variable containing all the top level headings of the document is passed to the MDX file and can be styled accordingly.
81 | To make generating a table of contents (TOC) simple, you can use the existing `TOCInline` component.
82 |
83 | For example, the TOC in this post was generated with the following code:
84 |
85 | ```jsx
86 |
87 | ```
88 |
89 | You can customise the headings that are displayed by configuring the `fromHeading` and `toHeading` props, or exclude particular headings
90 | by passing a string or a string array to the `exclude` prop. By default, all headings that are of depth 3 or smaller are indented. This can be configured by changing the `indentDepth` property.
91 | A `asDisclosure` prop can be used to render the TOC within an expandable disclosure element.
92 |
93 | Here's the full TOC rendered in a disclosure element.
94 |
95 | ```jsx
96 |
97 | ```
98 |
99 |
100 |
101 | ## Layouts
102 |
103 | You can map mdx blog content to layout components by configuring the frontmatter field. For example, this post is written with the new `PostSimple` layout!
104 |
105 | ### Adding new templates
106 |
107 | layout templates are stored in the `./layouts` folder. You can add your React components that you want to map to markdown content in this folder.
108 | The component file name must match that specified in the markdown frontmatter `layout` field.
109 |
110 | The only required field is `children` which contains the rendered MDX content, though you would probably want to pass in the frontMatter contents and render it in the template.
111 |
112 | You can configure the template to take in other fields - see `PostLayout` component for an example.
113 |
114 | Here's an example layout which you can further customise:
115 |
116 | ```jsx
117 | export default function ExampleLayout({ frontMatter, children }) {
118 | const { date, title } = frontMatter
119 |
120 | return (
121 |
122 | {date}
123 | {title}
124 | {children}
125 |
126 | )
127 | }
128 | ```
129 |
130 | ### Configuring a blog post frontmatter
131 |
132 | Use the `layout` frontmatter field to specify the template you want to map the markdown post to. Here's how the frontmatter of this post looks like:
133 |
134 | ```
135 | ---
136 | title: 'New features in v1'
137 | date: '2021-05-26 '
138 | tags: ['next-js', 'tailwind', 'guide']
139 | draft: false
140 | summary: 'Introducing the new layout features - you can map mdx blog content to layout components by configuring the frontmatter field'
141 | layout: PostSimple
142 | ---
143 | ```
144 |
145 | You can configure the default layout in the respective page section by modifying the `DEFAULT_LAYOUT` variable.
146 | The `DEFAULT_LAYOUT` for blog posts page is set to `PostLayout`.
147 |
148 | ### Extend
149 |
150 | `layout` is mapped to wrapper which wraps the entire MDX content.
151 |
152 | ```jsx
153 | export const MDXComponents = {
154 | Image,
155 | a: CustomLink,
156 | pre: Pre,
157 | wrapper: ({ components, layout, ...rest }) => {
158 | const Layout = require(`../layouts/${layout}`).default
159 | return
160 | },
161 | }
162 |
163 | export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => {
164 | const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
165 |
166 | return
167 | }
168 | ```
169 |
170 | Use the `MDXLayoutRenderer` component on a page where you want to accept a layout name to map to the desired layout.
171 | You need to pass the layout name from the layout folder (it has to be an exact match).
172 |
173 | ## Analytics
174 |
175 | The template now supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics.
176 | Configure `siteMetadata.js` with the settings that correspond with the desired analytics provider.
177 |
178 | ```js
179 | analytics: {
180 | // supports plausible, simpleAnalytics or googleAnalytics
181 | plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
182 | simpleAnalytics: false, // true or false
183 | googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
184 | },
185 | ```
186 |
187 | Custom events are also supported. You can import the `logEvent` function from `@components/analytics/[ANALYTICS-PROVIDER]` file and call it when
188 | triggering certain events of interest. _Note_: Additional configuration might be required depending on the analytics provider, please check their official
189 | documentation for more information.
190 |
191 | ## Blog comments system
192 |
193 | We have also added support for [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus.
194 | To enable, simply configure `siteMetadata.js` comments property with the desired provider and settings as specified in the config file.
195 |
196 | ```js
197 | comment: {
198 | // Select a provider and use the environment variables associated to it
199 | // https://vercel.com/docs/environment-variables
200 | provider: 'giscus', // supported providers: giscus, utterances, disqus
201 | giscusConfig: {
202 | // Visit the link below, and follow the steps in the 'configuration' section
203 | // https://giscus.app/
204 | repo: process.env.NEXT_PUBLIC_GISCUS_REPO,
205 | repositoryId: process.env.NEXT_PUBLIC_GISCUS_REPOSITORY_ID,
206 | category: process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
207 | categoryId: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
208 | mapping: 'pathname', // supported options: pathname, url, title
209 | reactions: '1', // Emoji reactions: 1 = enable / 0 = disable
210 | // Send discussion metadata periodically to the parent window: 1 = enable / 0 = disable
211 | metadata: '0',
212 | // theme example: light, dark, dark_dimmed, dark_high_contrast
213 | // transparent_dark, preferred_color_scheme, custom
214 | theme: 'light',
215 | // theme when dark mode
216 | darkTheme: 'transparent_dark',
217 | // If the theme option above is set to 'custom`
218 | // please provide a link below to your custom theme css file.
219 | // example: https://giscus.app/themes/custom_example.css
220 | themeURL: '',
221 | },
222 | utterancesConfig: {
223 | // Visit the link below, and follow the steps in the 'configuration' section
224 | // https://utteranc.es/
225 | repo: process.env.NEXT_PUBLIC_UTTERANCES_REPO,
226 | issueTerm: '', // supported options: pathname, url, title
227 | label: '', // label (optional): Comment 💬
228 | // theme example: github-light, github-dark, preferred-color-scheme
229 | // github-dark-orange, icy-dark, dark-blue, photon-dark, boxy-light
230 | theme: '',
231 | // theme when dark mode
232 | darkTheme: '',
233 | },
234 | disqus: {
235 | // https://help.disqus.com/en/articles/1717111-what-s-a-shortname
236 | shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
237 | },
238 | },
239 | ```
240 |
241 | ## Multiple authors
242 |
243 | Information on authors is now split from `siteMetadata.js` and stored in its own `data/authors` folder as a markdown file. Minimally, you will need to have a `default.md` file with authorship information. You can create additional files as required and the file name will be used as the reference to the author.
244 |
245 | Here's how an author markdown file might look like:
246 |
247 | ```md:default.md
248 | ---
249 | name: Tails Azimuth
250 | avatar: /static/images/avatar.png
251 | occupation: Professor of Atmospheric Science
252 | company: Stanford University
253 | email: address@yoursite.com
254 | twitter: https://twitter.com/Twitter
255 | linkedin: https://www.linkedin.com
256 | github: https://github.com
257 | ---
258 |
259 | A long description of yourself...
260 | ```
261 |
262 | You can use this information in multiple places across the template. For example in the about section of the page, we grab the default author information with this line of code:
263 |
264 | ```js
265 | const authorDetails = await getFileBySlug('authors', ['default'])
266 | ```
267 |
268 | This is rendered in the `AuthorLayout` template.
269 |
270 | ### Multiple authors in blog post
271 |
272 | The frontmatter of a blog post accepts an optional `authors` array field. If no field is specified, it is assumed that the default author is used. Simply pass in an array of authors to render multiple authors associated with a post.
273 |
274 | For example, the following frontmatter will display the authors given by `data/authors/default.md` and `data/authors/sparrowhawk.md`
275 |
276 | ```yaml
277 | title: 'My first post'
278 | date: '2021-01-12'
279 | draft: false
280 | summary: 'My first post'
281 | authors: ['default', 'sparrowhawk']
282 | ```
283 |
284 | A demo of a multiple authors post is shown in [Introducing Tailwind Nextjs Starter Blog post](/blog/introducing-tailwind-nextjs-starter-blog).
285 |
286 | ## Copy button for code blocks
287 |
288 | Hover over a code block and you will notice a GitHub-inspired copy button! You can modify `./components/Pre.js` to further customise it.
289 | The component is passed to `MDXComponents` and modifies all `` blocks.
290 |
291 | ## Line highlighting and line numbers
292 |
293 | Line highlighting and line numbers are now supported out of the box thanks to the new [rehype-prism-plus plugin](https://github.com/timlrx/rehype-prism-plus)
294 |
295 | The following javascript code block:
296 |
297 | ````
298 | ```js {1, 3-4} showLineNumbers
299 | var num1, num2, sum
300 | num1 = prompt('Enter first number')
301 | num2 = prompt('Enter second number')
302 | sum = parseInt(num1) + parseInt(num2) // "+" means "add"
303 | alert('Sum = ' + sum) // "+" means combine into a string
304 | ```
305 | ````
306 |
307 | will appear as:
308 |
309 | ```js {1,3-4} showLineNumbers
310 | var num1, num2, sum
311 | num1 = prompt('Enter first number')
312 | num2 = prompt('Enter second number')
313 | sum = parseInt(num1) + parseInt(num2) // "+" means "add"
314 | alert('Sum = ' + sum) // "+" means combine into a string
315 | ```
316 |
317 | To modify the styles, change the following class selectors in the `prism.css` file:
318 |
319 | ```css
320 | .code-highlight {
321 | @apply float-left min-w-full;
322 | }
323 |
324 | .code-line {
325 | @apply -mx-4 block border-l-4 border-opacity-0 pl-4 pr-4;
326 | }
327 |
328 | .code-line.inserted {
329 | @apply bg-green-500 bg-opacity-20;
330 | }
331 |
332 | .code-line.deleted {
333 | @apply bg-red-500 bg-opacity-20;
334 | }
335 |
336 | .highlight-line {
337 | @apply -mx-4 border-l-4 border-primary-500 bg-gray-700 bg-opacity-50;
338 | }
339 |
340 | .line-number::before {
341 | @apply mr-4 -ml-2 inline-block w-4 text-right text-gray-400;
342 | content: attr(line);
343 | }
344 | ```
345 |
346 | ## Newletter component (v1.1.3)
347 |
348 | Introduced in v1.1.3, the newsletter component gives you an easy way to build an audience. It integrates with the following providers:
349 |
350 | - [Mailchimp](https://mailchimp.com/)
351 | - [Buttondown](https://buttondown.email/)
352 | - [Convertkit](https://convertkit.com/)
353 |
354 | To use it, specify the provider which you are using in the config file and add the necessary environment variables to the `.env` file.
355 | For more information on the required variables, check out `.env.sample.`
356 |
357 | Two components are exported, a default `NewsletterForm` and a `BlogNewsletterForm` component, which is also passed in as an MDX component
358 | and can be used in a blog post:
359 |
360 | ```jsx
361 |
362 | ```
363 |
364 |
365 |
366 | The component relies on nextjs's [API routes](https://nextjs.org/docs/api-routes/introduction) which requires a server-side instance of nextjs to be setup
367 | and is not compatible with a 100% static site export. Users should either self-host or use a compatible platform like Vercel or Netlify which supports this functionality.
368 |
369 | A static site compatible alternative is to substitute the route in the newsletter component with a form API endpoint provider.
370 |
371 | ## Bibliography and Citations (v1.2.1)
372 |
373 | `rehype-citation` plugin is added to the xdm processing pipeline in v1.2.1. This allows you to easily format citations and insert bibliography from an existing bibtex or CSL-json file.
374 |
375 | For example, the following markdown code sample:
376 |
377 | ```md
378 | Standard citation [@Nash1950]
379 | In-text citations e.g. @Nash1951
380 | Multiple citations [see @Nash1950; @Nash1951, page 50]
381 |
382 | **References:**
383 |
384 | [^ref]
385 | ```
386 |
387 | is rendered to the following:
388 |
389 | Standard citation [@Nash1950]
390 | In-text citations e.g. @Nash1951
391 | Multiple citations [see @Nash1950; @Nash1951, page 50]
392 |
393 | **References:**
394 |
395 | [^ref]
396 |
397 | A bibliography will be inserted at the end of the document, but this can be overwritten by specifying a `[^Ref]` tag at the intended location.
398 | The plugin uses APA citation formation, but also supports the following CSLs, 'apa', 'vancouver', 'harvard1', 'chicago', 'mla', or a path to a user-specified CSL file.
399 |
400 | See [rehype-citation readme](https://github.com/timlrx/rehype-citation) for more information on the configuration options.
401 |
402 | ## Self-hosted font (v1.5.0)
403 |
404 | Google font has been replaced with self-hosted font from [Fontsource](https://fontsource.org/). This gives the following [advantages](https://fontsource.org/docs/introduction):
405 |
406 | > Self-hosting brings significant performance gains as loading fonts from hosted services, such as Google Fonts, lead to an extra (render blocking) network request. To provide perspective, for simple websites it has been seen to double visual load times.
407 | >
408 | > Fonts remain version locked. Google often pushes updates to their fonts without notice, which may interfere with your live production projects. Manage your fonts like any other NPM dependency.
409 | >
410 | > Commit to privacy. Google does track the usage of their fonts and for those who are extremely privacy concerned, self-hosting is an alternative.
411 |
412 | This leads to a smaller font bundle and a 0.1s faster load time ([webpagetest comparison](https://www.webpagetest.org/video/compare.php?tests=220201_AiDcFH_f68a69b758454dd52d8e67493fdef7da,220201_BiDcMC_bf2d53f14483814ba61e794311dfa771)).
413 |
414 | To change the default Inter font:
415 |
416 | 1. Install the preferred [font](https://fontsource.org/fonts) - `npm install -save @fontsource/`
417 | 2. Update the import at `pages/_app.js`- `import '@fontsource/.css'`
418 | 3. Update the `fontfamily` property in the tailwind css config file
419 |
420 | ## Upgrade guide
421 |
422 | There are significant portions of the code that has been changed from v0 to v1 including support for layouts and a new mdx engine.
423 |
424 | There's also no real reason to change if the previous one serves your needs and it might be easier to copy
425 | the component changes you are interested in to your existing blog rather than migrating everything over.
426 |
427 | Nonetheless, if you want to do so and have not changed much of the template, you could clone the new version and copy over the blog post over to the new template.
428 |
429 | Another alternative would be to pull the latest template version with the following code:
430 |
431 | ```bash
432 | git remote add template git@github.com:timlrx/tailwind-nextjs-starter-blog.git
433 | git pull template v1 --allow-unrelated-histories
434 | rm -rf node_modules
435 | ```
436 |
437 | You can see an example of such a migration in this [commit](https://github.com/timlrx/timlrx.com/commit/bba1c185384fd6d5cdaac15abf802fdcff027286) for my personal blog.
438 |
439 | v1 also uses `feed.xml` rather than `index.xml`, to avoid some build issues with Vercel. If you are migrating you should add a redirect to `next.config.js` like so:
440 |
441 | ```js
442 | async redirects() {
443 | return [
444 | {
445 | source: '/:path/index.xml',
446 | destination: '/:path/feed.xml',
447 | permanent: true,
448 | }
449 | ]
450 | }
451 | ```
452 |
--------------------------------------------------------------------------------