Scaffold basic workshop frontend.
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,3 @@
 | 
			
		||||
node_modules
 | 
			
		||||
public
 | 
			
		||||
.next
 | 
			
		||||
							
								
								
									
										51
									
								
								components/Card.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,51 @@
 | 
			
		||||
import Image from './Image'
 | 
			
		||||
import Link from './Link'
 | 
			
		||||
 | 
			
		||||
const Card = ({ title, description, imgSrc, href }) => (
 | 
			
		||||
  <div className="p-4 md:w-1/2 md" style={{ maxWidth: '544px' }}>
 | 
			
		||||
    <div className="h-full overflow-hidden border-2 border-gray-200 rounded-md border-opacity-60 dark:border-gray-700">
 | 
			
		||||
      {href ? (
 | 
			
		||||
        <Link href={href} aria-label={`Link to ${title}`}>
 | 
			
		||||
          <Image
 | 
			
		||||
            alt={title}
 | 
			
		||||
            src={imgSrc}
 | 
			
		||||
            className="object-cover object-center lg:h-48 md:h-36"
 | 
			
		||||
            width={544}
 | 
			
		||||
            height={306}
 | 
			
		||||
          />
 | 
			
		||||
        </Link>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Image
 | 
			
		||||
          alt={title}
 | 
			
		||||
          src={imgSrc}
 | 
			
		||||
          className="object-cover object-center lg:h-48 md:h-36"
 | 
			
		||||
          width={544}
 | 
			
		||||
          height={306}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      <div className="p-6">
 | 
			
		||||
        <h2 className="mb-3 text-2xl font-bold leading-8 tracking-tight">
 | 
			
		||||
          {href ? (
 | 
			
		||||
            <Link href={href} aria-label={`Link to ${title}`}>
 | 
			
		||||
              {title}
 | 
			
		||||
            </Link>
 | 
			
		||||
          ) : (
 | 
			
		||||
            title
 | 
			
		||||
          )}
 | 
			
		||||
        </h2>
 | 
			
		||||
        <p className="mb-3 prose text-gray-500 max-w-none dark:text-gray-400">{description}</p>
 | 
			
		||||
        {href && (
 | 
			
		||||
          <Link
 | 
			
		||||
            href={href}
 | 
			
		||||
            className="text-base font-medium leading-6 text-primary-800 hover:text-primary-900 dark:hover:text-primary-400"
 | 
			
		||||
            aria-label={`Link to ${title}`}
 | 
			
		||||
          >
 | 
			
		||||
            Learn more →
 | 
			
		||||
          </Link>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
export default Card
 | 
			
		||||
							
								
								
									
										23
									
								
								components/ClientReload.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,23 @@
 | 
			
		||||
import { useEffect } from 'react'
 | 
			
		||||
import Router from 'next/router'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Client-side complement to next-remote-watch
 | 
			
		||||
 * Re-triggers getStaticProps when watched mdx files change
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
export const ClientReload = () => {
 | 
			
		||||
  // Exclude socket.io from prod bundle
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    import('socket.io-client').then((module) => {
 | 
			
		||||
      const socket = module.io()
 | 
			
		||||
      socket.on('reload', (data) => {
 | 
			
		||||
        Router.replace(Router.asPath, undefined, {
 | 
			
		||||
          scroll: false,
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  return null
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								components/Footer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,17 @@
 | 
			
		||||
import Link from './Link'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import SocialIcon from '@/components/social-icons'
 | 
			
		||||
 | 
			
		||||
export default function Footer() {
 | 
			
		||||
  return (
 | 
			
		||||
    <footer>
 | 
			
		||||
      <div className="flex flex-col items-center mt-16">
 | 
			
		||||
        <div className="flex mb-2 space-x-2 text-sm text-gray-500 dark:text-gray-400">
 | 
			
		||||
          <div>{`© ${new Date().getFullYear()}`}</div>
 | 
			
		||||
          <div>{` • `}</div>
 | 
			
		||||
          <Link href="/">{siteMetadata.author}</Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </footer>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								components/Image.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,6 @@
 | 
			
		||||
import NextImage from 'next/image'
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line jsx-a11y/alt-text
 | 
			
		||||
const Image = ({ ...rest }) => <NextImage {...rest} />
 | 
			
		||||
 | 
			
		||||
export default Image
 | 
			
		||||
							
								
								
									
										54
									
								
								components/LayoutWrapper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,54 @@
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import headerNavLinks from '@/data/headerNavLinks'
 | 
			
		||||
import Logo from '@/data/logo.svg'
 | 
			
		||||
import Link from './Link'
 | 
			
		||||
import SectionContainer from './SectionContainer'
 | 
			
		||||
import Footer from './Footer'
 | 
			
		||||
import MobileNav from './MobileNav'
 | 
			
		||||
import ThemeSwitch from './ThemeSwitch'
 | 
			
		||||
 | 
			
		||||
const LayoutWrapper = ({ children }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <SectionContainer>
 | 
			
		||||
      <div className="flex flex-col justify-between h-screen">
 | 
			
		||||
        <header className="flex items-center justify-between py-10">
 | 
			
		||||
          <div>
 | 
			
		||||
            <Link href="/" aria-label="Tailwind CSS Blog">
 | 
			
		||||
              <div className="flex items-center justify-between">
 | 
			
		||||
                <div className="mr-3">
 | 
			
		||||
                  <Logo />
 | 
			
		||||
                </div>
 | 
			
		||||
                {typeof siteMetadata.headerTitle === 'string' ? (
 | 
			
		||||
                  <div className="hidden h-6 text-2xl font-semibold sm:block">
 | 
			
		||||
                    {siteMetadata.headerTitle}
 | 
			
		||||
                  </div>
 | 
			
		||||
                ) : (
 | 
			
		||||
                  siteMetadata.headerTitle
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            </Link>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="flex items-center text-base leading-5">
 | 
			
		||||
            <div className="hidden sm:block">
 | 
			
		||||
              {headerNavLinks.map((link) => (
 | 
			
		||||
                <Link
 | 
			
		||||
                  key={link.title}
 | 
			
		||||
                  href={link.href}
 | 
			
		||||
                  className="p-1 font-medium text-gray-900 sm:p-4 dark:text-gray-100"
 | 
			
		||||
                >
 | 
			
		||||
                  {link.title}
 | 
			
		||||
                </Link>
 | 
			
		||||
              ))}
 | 
			
		||||
            </div>
 | 
			
		||||
            <ThemeSwitch />
 | 
			
		||||
            <MobileNav />
 | 
			
		||||
          </div>
 | 
			
		||||
        </header>
 | 
			
		||||
        <main className="mb-auto">{children}</main>
 | 
			
		||||
        <Footer />
 | 
			
		||||
      </div>
 | 
			
		||||
    </SectionContainer>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default LayoutWrapper
 | 
			
		||||
							
								
								
									
										23
									
								
								components/Link.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,23 @@
 | 
			
		||||
/* eslint-disable jsx-a11y/anchor-has-content */
 | 
			
		||||
import Link from 'next/link'
 | 
			
		||||
 | 
			
		||||
const CustomLink = ({ href, ...rest }) => {
 | 
			
		||||
  const isInternalLink = href && href.startsWith('/')
 | 
			
		||||
  const isAnchorLink = href && href.startsWith('#')
 | 
			
		||||
 | 
			
		||||
  if (isInternalLink) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Link href={href}>
 | 
			
		||||
        <a {...rest} />
 | 
			
		||||
      </Link>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isAnchorLink) {
 | 
			
		||||
    return <a href={href} {...rest} />
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default CustomLink
 | 
			
		||||
							
								
								
									
										32
									
								
								components/MDXComponents.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,32 @@
 | 
			
		||||
/* eslint-disable react/display-name */
 | 
			
		||||
import { useMemo } from 'react'
 | 
			
		||||
import { getMDXComponent } from 'mdx-bundler/client'
 | 
			
		||||
import Image from './Image'
 | 
			
		||||
import CustomLink from './Link'
 | 
			
		||||
import TOCInline from './TOCInline'
 | 
			
		||||
import Pre from './Pre'
 | 
			
		||||
import Quote from './Quote'
 | 
			
		||||
import YoutubeEmbed from './YoutubeEmbed'
 | 
			
		||||
 | 
			
		||||
import Zoom from 'react-medium-image-zoom'
 | 
			
		||||
import 'react-medium-image-zoom/dist/styles.css'
 | 
			
		||||
 | 
			
		||||
export const MDXComponents = {
 | 
			
		||||
  Image,
 | 
			
		||||
  TOCInline,
 | 
			
		||||
  a: CustomLink,
 | 
			
		||||
  pre: Pre,
 | 
			
		||||
  Quote,
 | 
			
		||||
  YoutubeEmbed,
 | 
			
		||||
  Zoom,
 | 
			
		||||
  wrapper: ({ components, layout, ...rest }) => {
 | 
			
		||||
    const Layout = require(`../layouts/${layout}`).default
 | 
			
		||||
    return <Layout {...rest} />
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }) => {
 | 
			
		||||
  const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource])
 | 
			
		||||
 | 
			
		||||
  return <MDXLayout layout={layout} components={MDXComponents} {...rest} />
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										78
									
								
								components/MobileNav.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,78 @@
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import Link from './Link'
 | 
			
		||||
import headerNavLinks from '@/data/headerNavLinks'
 | 
			
		||||
 | 
			
		||||
const MobileNav = () => {
 | 
			
		||||
  const [navShow, setNavShow] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const onToggleNav = () => {
 | 
			
		||||
    setNavShow((status) => {
 | 
			
		||||
      if (status) {
 | 
			
		||||
        document.body.style.overflow = 'auto'
 | 
			
		||||
      } else {
 | 
			
		||||
        // Prevent scrolling
 | 
			
		||||
        document.body.style.overflow = 'hidden'
 | 
			
		||||
      }
 | 
			
		||||
      return !status
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="sm:hidden">
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
        className="w-8 h-8 ml-1 mr-1 rounded"
 | 
			
		||||
        aria-label="Toggle Menu"
 | 
			
		||||
        onClick={onToggleNav}
 | 
			
		||||
      >
 | 
			
		||||
        <svg
 | 
			
		||||
          xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
          viewBox="0 0 20 20"
 | 
			
		||||
          fill="currentColor"
 | 
			
		||||
          className="text-gray-900 dark:text-gray-100"
 | 
			
		||||
        >
 | 
			
		||||
          {navShow ? (
 | 
			
		||||
            <path
 | 
			
		||||
              fillRule="evenodd"
 | 
			
		||||
              d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
 | 
			
		||||
              clipRule="evenodd"
 | 
			
		||||
            />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <path
 | 
			
		||||
              fillRule="evenodd"
 | 
			
		||||
              d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
 | 
			
		||||
              clipRule="evenodd"
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </svg>
 | 
			
		||||
      </button>
 | 
			
		||||
      <div
 | 
			
		||||
        className={`fixed w-full h-full top-24 right-0 bg-gray-200 dark:bg-gray-800 opacity-95 z-10 transform ease-in-out duration-300 ${
 | 
			
		||||
          navShow ? 'translate-x-0' : 'translate-x-full'
 | 
			
		||||
        }`}
 | 
			
		||||
      >
 | 
			
		||||
        <button
 | 
			
		||||
          type="button"
 | 
			
		||||
          aria-label="toggle modal"
 | 
			
		||||
          className="fixed w-full h-full cursor-auto focus:outline-none"
 | 
			
		||||
          onClick={onToggleNav}
 | 
			
		||||
        ></button>
 | 
			
		||||
        <nav className="fixed h-full mt-8">
 | 
			
		||||
          {headerNavLinks.map((link) => (
 | 
			
		||||
            <div key={link.title} className="px-12 py-4">
 | 
			
		||||
              <Link
 | 
			
		||||
                href={link.href}
 | 
			
		||||
                className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
 | 
			
		||||
                onClick={onToggleNav}
 | 
			
		||||
              >
 | 
			
		||||
                {link.title}
 | 
			
		||||
              </Link>
 | 
			
		||||
            </div>
 | 
			
		||||
          ))}
 | 
			
		||||
        </nav>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default MobileNav
 | 
			
		||||
							
								
								
									
										7
									
								
								components/PageTitle.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,7 @@
 | 
			
		||||
export default function PageTitle({ children }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-5xl md:leading-14">
 | 
			
		||||
      {children}
 | 
			
		||||
    </h1>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										36
									
								
								components/Pagination.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,36 @@
 | 
			
		||||
import Link from '@/components/Link'
 | 
			
		||||
 | 
			
		||||
export default function Pagination({ totalPages, currentPage }) {
 | 
			
		||||
  const prevPage = parseInt(currentPage) - 1 > 0
 | 
			
		||||
  const nextPage = parseInt(currentPage) + 1 <= parseInt(totalPages)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="pt-6 pb-8 space-y-2 md:space-y-5">
 | 
			
		||||
      <nav className="flex justify-between">
 | 
			
		||||
        {!prevPage && (
 | 
			
		||||
          <button rel="previous" className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
 | 
			
		||||
            Previous
 | 
			
		||||
          </button>
 | 
			
		||||
        )}
 | 
			
		||||
        {prevPage && (
 | 
			
		||||
          <Link href={currentPage - 1 === 1 ? `/workshop/` : `/workshop/page/${currentPage - 1}`}>
 | 
			
		||||
            <button rel="previous">Previous</button>
 | 
			
		||||
          </Link>
 | 
			
		||||
        )}
 | 
			
		||||
        <span>
 | 
			
		||||
          {currentPage} of {totalPages}
 | 
			
		||||
        </span>
 | 
			
		||||
        {!nextPage && (
 | 
			
		||||
          <button rel="next" className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
 | 
			
		||||
            Next
 | 
			
		||||
          </button>
 | 
			
		||||
        )}
 | 
			
		||||
        {nextPage && (
 | 
			
		||||
          <Link href={`/workshop/page/${currentPage + 1}`}>
 | 
			
		||||
            <button rel="next">Next</button>
 | 
			
		||||
          </Link>
 | 
			
		||||
        )}
 | 
			
		||||
      </nav>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										71
									
								
								components/Pre.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,71 @@
 | 
			
		||||
import { useState, useRef } from 'react'
 | 
			
		||||
 | 
			
		||||
const Pre = (props) => {
 | 
			
		||||
  const textInput = useRef(null)
 | 
			
		||||
  const [hovered, setHovered] = useState(false)
 | 
			
		||||
  const [copied, setCopied] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const onEnter = () => {
 | 
			
		||||
    setHovered(true)
 | 
			
		||||
  }
 | 
			
		||||
  const onExit = () => {
 | 
			
		||||
    setHovered(false)
 | 
			
		||||
    setCopied(false)
 | 
			
		||||
  }
 | 
			
		||||
  const onCopy = () => {
 | 
			
		||||
    setCopied(true)
 | 
			
		||||
    navigator.clipboard.writeText(textInput.current.innerText)
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      setCopied(false)
 | 
			
		||||
    }, 2000)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div ref={textInput} onMouseEnter={onEnter} onMouseLeave={onExit} className="relative">
 | 
			
		||||
      {hovered && (
 | 
			
		||||
        <button
 | 
			
		||||
          aria-label="Copy code"
 | 
			
		||||
          type="button"
 | 
			
		||||
          className={`absolute right-2 top-2 w-8 h-8 p-1 rounded border-2 bg-gray-700 dark:bg-gray-800 ${
 | 
			
		||||
            copied
 | 
			
		||||
              ? 'focus:outline-none focus:border-green-400 border-green-400'
 | 
			
		||||
              : 'border-gray-300'
 | 
			
		||||
          }`}
 | 
			
		||||
          onClick={onCopy}
 | 
			
		||||
        >
 | 
			
		||||
          <svg
 | 
			
		||||
            xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
            viewBox="0 0 24 24"
 | 
			
		||||
            stroke="currentColor"
 | 
			
		||||
            fill="none"
 | 
			
		||||
            className={copied ? 'text-green-400' : 'text-gray-300'}
 | 
			
		||||
          >
 | 
			
		||||
            {copied ? (
 | 
			
		||||
              <>
 | 
			
		||||
                <path
 | 
			
		||||
                  strokeLinecap="round"
 | 
			
		||||
                  strokeLinejoin="round"
 | 
			
		||||
                  strokeWidth={2}
 | 
			
		||||
                  d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
 | 
			
		||||
                />
 | 
			
		||||
              </>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <>
 | 
			
		||||
                <path
 | 
			
		||||
                  strokeLinecap="round"
 | 
			
		||||
                  strokeLinejoin="round"
 | 
			
		||||
                  strokeWidth={2}
 | 
			
		||||
                  d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
 | 
			
		||||
                />
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
          </svg>
 | 
			
		||||
        </button>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <pre>{props.children}</pre>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Pre
 | 
			
		||||
							
								
								
									
										13
									
								
								components/Quote.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,13 @@
 | 
			
		||||
import PropTypes from 'prop-types'
 | 
			
		||||
 | 
			
		||||
const Quote = ({ quote }) => (
 | 
			
		||||
  <blockquote className="relative p-4 text-xl italic border-l-4 bg-neutral-100 text-neutral-600 border-neutral-500 quote">
 | 
			
		||||
    <p className="mb-4">{ quote }</p>
 | 
			
		||||
  </blockquote>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
Quote.propTypes = {
 | 
			
		||||
 quote: PropTypes.string,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Quote
 | 
			
		||||
							
								
								
									
										150
									
								
								components/SEO.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,150 @@
 | 
			
		||||
import Head from 'next/head'
 | 
			
		||||
import { useRouter } from 'next/router'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const CommonSEO = ({ title, description, ogType, ogImage, twImage }) => {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  return (
 | 
			
		||||
    <Head>
 | 
			
		||||
      <title>{title}</title>
 | 
			
		||||
      <meta name="robots" content="follow, index" />
 | 
			
		||||
      <meta name="description" content={description} />
 | 
			
		||||
      <meta property="og:url" content={`${siteMetadata.siteUrl}${router.asPath}`} />
 | 
			
		||||
      <meta property="og:type" content={ogType} />
 | 
			
		||||
      <meta property="og:site_name" content={siteMetadata.title} />
 | 
			
		||||
      <meta property="og:description" content={description} />
 | 
			
		||||
      <meta property="og:title" content={title} />
 | 
			
		||||
      {ogImage.constructor.name === 'Array' ? (
 | 
			
		||||
        ogImage.map(({ url }) => <meta property="og:image" content={url} key={url} />)
 | 
			
		||||
      ) : (
 | 
			
		||||
        <meta property="og:image" content={ogImage} key={ogImage} />
 | 
			
		||||
      )}
 | 
			
		||||
      <meta name="twitter:card" content="summary" />
 | 
			
		||||
      <meta name="twitter:site" content={siteMetadata.twitter} />
 | 
			
		||||
      <meta name="twitter:title" content={title} />
 | 
			
		||||
      <meta name="twitter:description" content={description} />
 | 
			
		||||
      <meta name="twitter:image" content={twImage} />
 | 
			
		||||
    </Head>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const PageSEO = ({ title, description }) => {
 | 
			
		||||
  const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
 | 
			
		||||
  const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
 | 
			
		||||
  return (
 | 
			
		||||
    <CommonSEO
 | 
			
		||||
      title={title}
 | 
			
		||||
      description={description}
 | 
			
		||||
      ogType="website"
 | 
			
		||||
      ogImage={ogImageUrl}
 | 
			
		||||
      twImage={twImageUrl}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const TagSEO = ({ title, description }) => {
 | 
			
		||||
  const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
 | 
			
		||||
  const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <CommonSEO
 | 
			
		||||
        title={title}
 | 
			
		||||
        description={description}
 | 
			
		||||
        ogType="website"
 | 
			
		||||
        ogImage={ogImageUrl}
 | 
			
		||||
        twImage={twImageUrl}
 | 
			
		||||
      />
 | 
			
		||||
      <Head>
 | 
			
		||||
        <link
 | 
			
		||||
          rel="alternate"
 | 
			
		||||
          type="application/rss+xml"
 | 
			
		||||
          title={`${description} - RSS feed`}
 | 
			
		||||
          href={`${siteMetadata.siteUrl}${router.asPath}/feed.xml`}
 | 
			
		||||
        />
 | 
			
		||||
      </Head>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const BlogSEO = ({ authorDetails, title, summary, date, lastmod, url, images = [] }) => {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  const publishedAt = new Date(date).toISOString()
 | 
			
		||||
  const modifiedAt = new Date(lastmod || date).toISOString()
 | 
			
		||||
  let imagesArr =
 | 
			
		||||
    images.length === 0
 | 
			
		||||
      ? [siteMetadata.socialBanner]
 | 
			
		||||
      : typeof images === 'string'
 | 
			
		||||
      ? [images]
 | 
			
		||||
      : images
 | 
			
		||||
 | 
			
		||||
  const featuredImages = imagesArr.map((img) => {
 | 
			
		||||
    return {
 | 
			
		||||
      '@type': 'ImageObject',
 | 
			
		||||
      url: `${siteMetadata.siteUrl}${img}`,
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  let authorList
 | 
			
		||||
  if (authorDetails) {
 | 
			
		||||
    authorList = authorDetails.map((author) => {
 | 
			
		||||
      return {
 | 
			
		||||
        '@type': 'Person',
 | 
			
		||||
        name: author.name,
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  } else {
 | 
			
		||||
    authorList = {
 | 
			
		||||
      '@type': 'Person',
 | 
			
		||||
      name: siteMetadata.author,
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const structuredData = {
 | 
			
		||||
    '@context': 'https://schema.org',
 | 
			
		||||
    '@type': 'Article',
 | 
			
		||||
    mainEntityOfPage: {
 | 
			
		||||
      '@type': 'WebPage',
 | 
			
		||||
      '@id': url,
 | 
			
		||||
    },
 | 
			
		||||
    headline: title,
 | 
			
		||||
    image: featuredImages,
 | 
			
		||||
    datePublished: publishedAt,
 | 
			
		||||
    dateModified: modifiedAt,
 | 
			
		||||
    author: authorList,
 | 
			
		||||
    publisher: {
 | 
			
		||||
      '@type': 'Organization',
 | 
			
		||||
      name: siteMetadata.author,
 | 
			
		||||
      logo: {
 | 
			
		||||
        '@type': 'ImageObject',
 | 
			
		||||
        url: `${siteMetadata.siteUrl}${siteMetadata.siteLogo}`,
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    description: summary,
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const twImageUrl = featuredImages[0].url
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <CommonSEO
 | 
			
		||||
        title={title}
 | 
			
		||||
        description={summary}
 | 
			
		||||
        ogType="article"
 | 
			
		||||
        ogImage={featuredImages}
 | 
			
		||||
        twImage={twImageUrl}
 | 
			
		||||
      />
 | 
			
		||||
      <Head>
 | 
			
		||||
        {date && <meta property="article:published_time" content={publishedAt} />}
 | 
			
		||||
        {lastmod && <meta property="article:modified_time" content={modifiedAt} />}
 | 
			
		||||
        <link rel="canonical" href={`${siteMetadata.siteUrl}${router.asPath}`} />
 | 
			
		||||
        <script
 | 
			
		||||
          type="application/ld+json"
 | 
			
		||||
          dangerouslySetInnerHTML={{
 | 
			
		||||
            __html: JSON.stringify(structuredData, null, 2),
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </Head>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										61
									
								
								components/ScrollTopAndComment.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,61 @@
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
 | 
			
		||||
const ScrollTopAndComment = () => {
 | 
			
		||||
  const [show, setShow] = useState(false)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleWindowScroll = () => {
 | 
			
		||||
      if (window.scrollY > 50) setShow(true)
 | 
			
		||||
      else setShow(false)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    window.addEventListener('scroll', handleWindowScroll)
 | 
			
		||||
    return () => window.removeEventListener('scroll', handleWindowScroll)
 | 
			
		||||
  }, [])
 | 
			
		||||
 | 
			
		||||
  const handleScrollTop = () => {
 | 
			
		||||
    window.scrollTo({ top: 0 })
 | 
			
		||||
  }
 | 
			
		||||
  const handleScrollToComment = () => {
 | 
			
		||||
    document.getElementById('comment').scrollIntoView()
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
 | 
			
		||||
    >
 | 
			
		||||
      {siteMetadata.comment.provider && (
 | 
			
		||||
        <button
 | 
			
		||||
          aria-label="Scroll To Comment"
 | 
			
		||||
          type="button"
 | 
			
		||||
          onClick={handleScrollToComment}
 | 
			
		||||
          className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
 | 
			
		||||
        >
 | 
			
		||||
          <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
            <path
 | 
			
		||||
              fillRule="evenodd"
 | 
			
		||||
              d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
 | 
			
		||||
              clipRule="evenodd"
 | 
			
		||||
            />
 | 
			
		||||
          </svg>
 | 
			
		||||
        </button>
 | 
			
		||||
      )}
 | 
			
		||||
      <button
 | 
			
		||||
        aria-label="Scroll To Top"
 | 
			
		||||
        type="button"
 | 
			
		||||
        onClick={handleScrollTop}
 | 
			
		||||
        className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
 | 
			
		||||
      >
 | 
			
		||||
        <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
          <path
 | 
			
		||||
            fillRule="evenodd"
 | 
			
		||||
            d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z"
 | 
			
		||||
            clipRule="evenodd"
 | 
			
		||||
          />
 | 
			
		||||
        </svg>
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ScrollTopAndComment
 | 
			
		||||
							
								
								
									
										3
									
								
								components/SectionContainer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,3 @@
 | 
			
		||||
export default function SectionContainer({ children }) {
 | 
			
		||||
  return <div className="max-w-3xl px-4 mx-auto sm:px-6 xl:max-w-5xl xl:px-0">{children}</div>
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										64
									
								
								components/TOCInline.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,64 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef TocHeading
 | 
			
		||||
 * @prop {string} value
 | 
			
		||||
 * @prop {number} depth
 | 
			
		||||
 * @prop {string} url
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Generates an inline table of contents
 | 
			
		||||
 * Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
 | 
			
		||||
 * If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
 | 
			
		||||
 *
 | 
			
		||||
 * @param {{
 | 
			
		||||
 *  toc: TocHeading[],
 | 
			
		||||
 *  indentDepth?: number,
 | 
			
		||||
 *  fromHeading?: number,
 | 
			
		||||
 *  toHeading?: number,
 | 
			
		||||
 *  asDisclosure?: boolean,
 | 
			
		||||
 *  exclude?: string|string[]
 | 
			
		||||
 * }} props
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
const TOCInline = ({
 | 
			
		||||
  toc,
 | 
			
		||||
  indentDepth = 3,
 | 
			
		||||
  fromHeading = 1,
 | 
			
		||||
  toHeading = 6,
 | 
			
		||||
  asDisclosure = false,
 | 
			
		||||
  exclude = '',
 | 
			
		||||
}) => {
 | 
			
		||||
  const re = Array.isArray(exclude)
 | 
			
		||||
    ? new RegExp('^(' + exclude.join('|') + ')$', 'i')
 | 
			
		||||
    : new RegExp('^(' + exclude + ')$', 'i')
 | 
			
		||||
 | 
			
		||||
  const filteredToc = toc.filter(
 | 
			
		||||
    (heading) =>
 | 
			
		||||
      heading.depth >= fromHeading && heading.depth <= toHeading && !re.test(heading.value)
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const tocList = (
 | 
			
		||||
    <ul>
 | 
			
		||||
      {filteredToc.map((heading) => (
 | 
			
		||||
        <li key={heading.value} className={`${heading.depth >= indentDepth && 'ml-6'}`}>
 | 
			
		||||
          <a href={heading.url}>{heading.value}</a>
 | 
			
		||||
        </li>
 | 
			
		||||
      ))}
 | 
			
		||||
    </ul>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {asDisclosure ? (
 | 
			
		||||
        <details open>
 | 
			
		||||
          <summary className="pt-2 pb-2 ml-6 text-xl font-bold">Table of Contents</summary>
 | 
			
		||||
          <div className="ml-6">{tocList}</div>
 | 
			
		||||
        </details>
 | 
			
		||||
      ) : (
 | 
			
		||||
        tocList
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default TOCInline
 | 
			
		||||
							
								
								
									
										14
									
								
								components/Tag.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,14 @@
 | 
			
		||||
import Link from 'next/link'
 | 
			
		||||
import kebabCase from '@/lib/utils/kebabCase'
 | 
			
		||||
 | 
			
		||||
const Tag = ({ text }) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <Link href={`/tags/${kebabCase(text)}`}>
 | 
			
		||||
      <a className="mr-3 text-sm font-medium uppercase text-primary-800 hover:text-primary-900 dark:hover:text-primary-400">
 | 
			
		||||
        {text.split(' ').join('-')}
 | 
			
		||||
      </a>
 | 
			
		||||
    </Link>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Tag
 | 
			
		||||
							
								
								
									
										38
									
								
								components/ThemeSwitch.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,38 @@
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
import { useTheme } from 'next-themes'
 | 
			
		||||
 | 
			
		||||
const ThemeSwitch = () => {
 | 
			
		||||
  const [mounted, setMounted] = useState(false)
 | 
			
		||||
  const { theme, setTheme, resolvedTheme } = useTheme()
 | 
			
		||||
 | 
			
		||||
  // When mounted on client, now we can show the UI
 | 
			
		||||
  useEffect(() => setMounted(true), [])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button
 | 
			
		||||
      aria-label="Toggle Dark Mode"
 | 
			
		||||
      type="button"
 | 
			
		||||
      className="w-8 h-8 p-1 ml-1 mr-1 rounded sm:ml-4"
 | 
			
		||||
      onClick={() => setTheme(theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark')}
 | 
			
		||||
    >
 | 
			
		||||
      <svg
 | 
			
		||||
        xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
        viewBox="0 0 20 20"
 | 
			
		||||
        fill="currentColor"
 | 
			
		||||
        className="text-gray-900 dark:text-gray-100"
 | 
			
		||||
      >
 | 
			
		||||
        {mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
 | 
			
		||||
          <path
 | 
			
		||||
            fillRule="evenodd"
 | 
			
		||||
            d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
 | 
			
		||||
            clipRule="evenodd"
 | 
			
		||||
          />
 | 
			
		||||
        ) : (
 | 
			
		||||
          <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
 | 
			
		||||
        )}
 | 
			
		||||
      </svg>
 | 
			
		||||
    </button>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ThemeSwitch
 | 
			
		||||
							
								
								
									
										22
									
								
								components/YoutubeEmbed.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,22 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import PropTypes from "prop-types";
 | 
			
		||||
 | 
			
		||||
const YoutubeEmbed = ({ embedId }) => (
 | 
			
		||||
  <div className="video-responsive">
 | 
			
		||||
    <iframe
 | 
			
		||||
      width="853"
 | 
			
		||||
      height="480"
 | 
			
		||||
      src={`https://www.youtube.com/embed/${embedId}`}
 | 
			
		||||
      frameBorder="0"
 | 
			
		||||
      allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
 | 
			
		||||
      allowFullScreen
 | 
			
		||||
      title="Embedded youtube"
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
YoutubeEmbed.propTypes = {
 | 
			
		||||
  embedId: PropTypes.string.isRequired
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default YoutubeEmbed;
 | 
			
		||||
							
								
								
									
										36
									
								
								components/analytics/GoogleAnalytics.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,36 @@
 | 
			
		||||
import Script from 'next/script'
 | 
			
		||||
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const GAScript = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Script
 | 
			
		||||
        strategy="lazyOnload"
 | 
			
		||||
        src={`https://www.googletagmanager.com/gtag/js?id=${siteMetadata.analytics.googleAnalyticsId}`}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <Script strategy="lazyOnload">
 | 
			
		||||
        {`
 | 
			
		||||
            window.dataLayer = window.dataLayer || [];
 | 
			
		||||
            function gtag(){dataLayer.push(arguments);}
 | 
			
		||||
            gtag('js', new Date());
 | 
			
		||||
            gtag('config', '${siteMetadata.analytics.googleAnalyticsId}', {
 | 
			
		||||
              page_path: window.location.pathname,
 | 
			
		||||
            });
 | 
			
		||||
        `}
 | 
			
		||||
      </Script>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default GAScript
 | 
			
		||||
 | 
			
		||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
 | 
			
		||||
export const logEvent = (action, category, label, value) => {
 | 
			
		||||
  window.gtag?.('event', action, {
 | 
			
		||||
    event_category: category,
 | 
			
		||||
    event_label: label,
 | 
			
		||||
    value: value,
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								components/analytics/Plausible.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,27 @@
 | 
			
		||||
import Script from 'next/script'
 | 
			
		||||
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const PlausibleScript = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Script
 | 
			
		||||
        strategy="lazyOnload"
 | 
			
		||||
        data-domain={siteMetadata.analytics.plausibleDataDomain}
 | 
			
		||||
        src="https://plausible.io/js/plausible.js"
 | 
			
		||||
      />
 | 
			
		||||
      <Script strategy="lazyOnload">
 | 
			
		||||
        {`
 | 
			
		||||
            window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
 | 
			
		||||
        `}
 | 
			
		||||
      </Script>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default PlausibleScript
 | 
			
		||||
 | 
			
		||||
// https://plausible.io/docs/custom-event-goals
 | 
			
		||||
export const logEvent = (eventName, ...rest) => {
 | 
			
		||||
  return window.plausible?.(eventName, ...rest)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								components/analytics/SimpleAnalytics.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,25 @@
 | 
			
		||||
import Script from 'next/script'
 | 
			
		||||
 | 
			
		||||
const SimpleAnalyticsScript = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Script strategy="lazyOnload">
 | 
			
		||||
        {`
 | 
			
		||||
            window.sa_event=window.sa_event||function(){var a=[].slice.call(arguments);window.sa_event.q?window.sa_event.q.push(a):window.sa_event.q=[a]};
 | 
			
		||||
        `}
 | 
			
		||||
      </Script>
 | 
			
		||||
      <Script strategy="lazyOnload" src="https://scripts.simpleanalyticscdn.com/latest.js" />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// https://docs.simpleanalytics.com/events
 | 
			
		||||
export const logEvent = (eventName, callback) => {
 | 
			
		||||
  if (callback) {
 | 
			
		||||
    return window.sa_event?.(eventName, callback)
 | 
			
		||||
  } else {
 | 
			
		||||
    return window.sa_event?.(eventName)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default SimpleAnalyticsScript
 | 
			
		||||
							
								
								
									
										18
									
								
								components/analytics/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,18 @@
 | 
			
		||||
import GA from './GoogleAnalytics'
 | 
			
		||||
import Plausible from './Plausible'
 | 
			
		||||
import SimpleAnalytics from './SimpleAnalytics'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const isProduction = process.env.NODE_ENV === 'production'
 | 
			
		||||
 | 
			
		||||
const Analytics = () => {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {isProduction && siteMetadata.analytics.plausibleDataDomain && <Plausible />}
 | 
			
		||||
      {isProduction && siteMetadata.analytics.simpleAnalytics && <SimpleAnalytics />}
 | 
			
		||||
      {isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Analytics
 | 
			
		||||
							
								
								
									
										37
									
								
								components/comments/Disqus.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,37 @@
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const Disqus = ({ frontMatter }) => {
 | 
			
		||||
  const [enableLoadComments, setEnabledLoadComments] = useState(true)
 | 
			
		||||
 | 
			
		||||
  const COMMENTS_ID = 'disqus_thread'
 | 
			
		||||
 | 
			
		||||
  function LoadComments() {
 | 
			
		||||
    setEnabledLoadComments(false)
 | 
			
		||||
 | 
			
		||||
    window.disqus_config = function () {
 | 
			
		||||
      this.page.url = window.location.href
 | 
			
		||||
      this.page.identifier = frontMatter.slug
 | 
			
		||||
    }
 | 
			
		||||
    if (window.DISQUS === undefined) {
 | 
			
		||||
      const script = document.createElement('script')
 | 
			
		||||
      script.src = 'https://' + siteMetadata.comment.disqus.shortname + '.disqus.com/embed.js'
 | 
			
		||||
      script.setAttribute('data-timestamp', +new Date())
 | 
			
		||||
      script.setAttribute('crossorigin', 'anonymous')
 | 
			
		||||
      script.async = true
 | 
			
		||||
      document.body.appendChild(script)
 | 
			
		||||
    } else {
 | 
			
		||||
      window.DISQUS.reset({ reload: true })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
 | 
			
		||||
      {enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
 | 
			
		||||
      <div className="disqus-frame" id={COMMENTS_ID} />
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Disqus
 | 
			
		||||
							
								
								
									
										50
									
								
								components/comments/Giscus.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,50 @@
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
import { useTheme } from 'next-themes'
 | 
			
		||||
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const Giscus = ({ mapping }) => {
 | 
			
		||||
  const [enableLoadComments, setEnabledLoadComments] = useState(true)
 | 
			
		||||
  const { theme, resolvedTheme } = useTheme()
 | 
			
		||||
  const commentsTheme =
 | 
			
		||||
    siteMetadata.comment.giscusConfig.themeURL === ''
 | 
			
		||||
      ? theme === 'dark' || resolvedTheme === 'dark'
 | 
			
		||||
        ? siteMetadata.comment.giscusConfig.darkTheme
 | 
			
		||||
        : siteMetadata.comment.giscusConfig.theme
 | 
			
		||||
      : siteMetadata.comment.giscusConfig.themeURL
 | 
			
		||||
 | 
			
		||||
  const COMMENTS_ID = 'comments-container'
 | 
			
		||||
 | 
			
		||||
  function LoadComments() {
 | 
			
		||||
    setEnabledLoadComments(false)
 | 
			
		||||
    const script = document.createElement('script')
 | 
			
		||||
    script.src = 'https://giscus.app/client.js'
 | 
			
		||||
    script.setAttribute('data-repo', siteMetadata.comment.giscusConfig.repo)
 | 
			
		||||
    script.setAttribute('data-repo-id', siteMetadata.comment.giscusConfig.repositoryId)
 | 
			
		||||
    script.setAttribute('data-category', siteMetadata.comment.giscusConfig.category)
 | 
			
		||||
    script.setAttribute('data-category-id', siteMetadata.comment.giscusConfig.categoryId)
 | 
			
		||||
    script.setAttribute('data-mapping', mapping)
 | 
			
		||||
    script.setAttribute('data-reactions-enabled', siteMetadata.comment.giscusConfig.reactions)
 | 
			
		||||
    script.setAttribute('data-emit-metadata', siteMetadata.comment.giscusConfig.metadata)
 | 
			
		||||
    script.setAttribute('data-theme', commentsTheme)
 | 
			
		||||
    script.setAttribute('crossorigin', 'anonymous')
 | 
			
		||||
    script.async = true
 | 
			
		||||
 | 
			
		||||
    const comments = document.getElementById(COMMENTS_ID)
 | 
			
		||||
    if (comments) comments.appendChild(script)
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      const comments = document.getElementById(COMMENTS_ID)
 | 
			
		||||
      if (comments) comments.innerHTML = ''
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
 | 
			
		||||
      {enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
 | 
			
		||||
      <div className="giscus" id={COMMENTS_ID} />
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Giscus
 | 
			
		||||
							
								
								
									
										45
									
								
								components/comments/Utterances.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,45 @@
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
import { useTheme } from 'next-themes'
 | 
			
		||||
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const Utterances = ({ issueTerm }) => {
 | 
			
		||||
  const [enableLoadComments, setEnabledLoadComments] = useState(true)
 | 
			
		||||
  const { theme, resolvedTheme } = useTheme()
 | 
			
		||||
  const commentsTheme =
 | 
			
		||||
    theme === 'dark' || resolvedTheme === 'dark'
 | 
			
		||||
      ? siteMetadata.comment.utterancesConfig.darkTheme
 | 
			
		||||
      : siteMetadata.comment.utterancesConfig.theme
 | 
			
		||||
 | 
			
		||||
  const COMMENTS_ID = 'comments-container'
 | 
			
		||||
 | 
			
		||||
  function LoadComments() {
 | 
			
		||||
    setEnabledLoadComments(false)
 | 
			
		||||
    const script = document.createElement('script')
 | 
			
		||||
    script.src = 'https://utteranc.es/client.js'
 | 
			
		||||
    script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo)
 | 
			
		||||
    script.setAttribute('issue-term', issueTerm)
 | 
			
		||||
    script.setAttribute('label', siteMetadata.comment.utterancesConfig.label)
 | 
			
		||||
    script.setAttribute('theme', commentsTheme)
 | 
			
		||||
    script.setAttribute('crossorigin', 'anonymous')
 | 
			
		||||
    script.async = true
 | 
			
		||||
 | 
			
		||||
    const comments = document.getElementById(COMMENTS_ID)
 | 
			
		||||
    if (comments) comments.appendChild(script)
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      const comments = document.getElementById(COMMENTS_ID)
 | 
			
		||||
      if (comments) comments.innerHTML = ''
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Added `relative` to fix a weird bug with `utterances-frame` position
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
 | 
			
		||||
      {enableLoadComments && <button onClick={LoadComments}>Load Comments</button>}
 | 
			
		||||
      <div className="utterances-frame relative" id={COMMENTS_ID} />
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Utterances
 | 
			
		||||
							
								
								
									
										54
									
								
								components/comments/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,54 @@
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import dynamic from 'next/dynamic'
 | 
			
		||||
 | 
			
		||||
const UtterancesComponent = dynamic(
 | 
			
		||||
  () => {
 | 
			
		||||
    return import('@/components/comments/Utterances')
 | 
			
		||||
  },
 | 
			
		||||
  { ssr: false }
 | 
			
		||||
)
 | 
			
		||||
const GiscusComponent = dynamic(
 | 
			
		||||
  () => {
 | 
			
		||||
    return import('@/components/comments/Giscus')
 | 
			
		||||
  },
 | 
			
		||||
  { ssr: false }
 | 
			
		||||
)
 | 
			
		||||
const DisqusComponent = dynamic(
 | 
			
		||||
  () => {
 | 
			
		||||
    return import('@/components/comments/Disqus')
 | 
			
		||||
  },
 | 
			
		||||
  { ssr: false }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const Comments = ({ frontMatter }) => {
 | 
			
		||||
  let term
 | 
			
		||||
  switch (
 | 
			
		||||
    siteMetadata.comment.giscusConfig.mapping ||
 | 
			
		||||
    siteMetadata.comment.utterancesConfig.issueTerm
 | 
			
		||||
  ) {
 | 
			
		||||
    case 'pathname':
 | 
			
		||||
      term = frontMatter.slug
 | 
			
		||||
      break
 | 
			
		||||
    case 'url':
 | 
			
		||||
      term = window.location.href
 | 
			
		||||
      break
 | 
			
		||||
    case 'title':
 | 
			
		||||
      term = frontMatter.title
 | 
			
		||||
      break
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && (
 | 
			
		||||
        <GiscusComponent mapping={term} />
 | 
			
		||||
      )}
 | 
			
		||||
      {siteMetadata.comment && siteMetadata.comment.provider === 'utterances' && (
 | 
			
		||||
        <UtterancesComponent issueTerm={term} />
 | 
			
		||||
      )}
 | 
			
		||||
      {siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
 | 
			
		||||
        <DisqusComponent frontMatter={frontMatter} />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default Comments
 | 
			
		||||
							
								
								
									
										1
									
								
								components/social-icons/facebook.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1 @@
 | 
			
		||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Facebook icon</title><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 403 B  | 
							
								
								
									
										1
									
								
								components/social-icons/github.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1 @@
 | 
			
		||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 827 B  | 
							
								
								
									
										39
									
								
								components/social-icons/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,39 @@
 | 
			
		||||
import Mail from './mail.svg'
 | 
			
		||||
import Github from './github.svg'
 | 
			
		||||
import Facebook from './facebook.svg'
 | 
			
		||||
import Youtube from './youtube.svg'
 | 
			
		||||
import Linkedin from './linkedin.svg'
 | 
			
		||||
import Twitter from './twitter.svg'
 | 
			
		||||
 | 
			
		||||
// Icons taken from: https://simpleicons.org/
 | 
			
		||||
 | 
			
		||||
const components = {
 | 
			
		||||
  mail: Mail,
 | 
			
		||||
  github: Github,
 | 
			
		||||
  facebook: Facebook,
 | 
			
		||||
  youtube: Youtube,
 | 
			
		||||
  linkedin: Linkedin,
 | 
			
		||||
  twitter: Twitter,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SocialIcon = ({ kind, href, size = 8 }) => {
 | 
			
		||||
  if (!href) return null
 | 
			
		||||
 | 
			
		||||
  const SocialSvg = components[kind]
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <a
 | 
			
		||||
      className="text-sm text-gray-500 transition hover:text-gray-600"
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      rel="noopener noreferrer"
 | 
			
		||||
      href={href}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="sr-only">{kind}</span>
 | 
			
		||||
      <SocialSvg
 | 
			
		||||
        className={`fill-current text-gray-700 dark:text-gray-200 hover:text-primary-800 dark:hover:text-primary-400 h-${size} w-${size}`}
 | 
			
		||||
      />
 | 
			
		||||
    </a>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default SocialIcon
 | 
			
		||||
							
								
								
									
										1
									
								
								components/social-icons/linkedin.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1 @@
 | 
			
		||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn icon</title><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 615 B  | 
							
								
								
									
										4
									
								
								components/social-icons/mail.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,4 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
 | 
			
		||||
  <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
 | 
			
		||||
  <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 224 B  | 
							
								
								
									
										1
									
								
								components/social-icons/twitter.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1 @@
 | 
			
		||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Twitter icon</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 607 B  | 
							
								
								
									
										1
									
								
								components/social-icons/youtube.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1 @@
 | 
			
		||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>YouTube icon</title><path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 474 B  | 
							
								
								
									
										158
									
								
								css/prism.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,158 @@
 | 
			
		||||
/**
 | 
			
		||||
 * MIT License
 | 
			
		||||
 * Copyright (c) 2018 Sarah Drasner
 | 
			
		||||
 * Sarah Drasner's[@sdras] Night Owl
 | 
			
		||||
 * Ported by Sara vieria [@SaraVieira]
 | 
			
		||||
 * Added by Souvik Mandal [@SimpleIndian]
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
code[class*="language-"],
 | 
			
		||||
pre[class*="language-"] {
 | 
			
		||||
	color: #d6deeb;
 | 
			
		||||
	font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
 | 
			
		||||
	text-align: left;
 | 
			
		||||
	white-space: pre;
 | 
			
		||||
	word-spacing: normal;
 | 
			
		||||
	word-break: normal;
 | 
			
		||||
	word-wrap: normal;
 | 
			
		||||
	line-height: 1.5;
 | 
			
		||||
	font-size: 1em;
 | 
			
		||||
 | 
			
		||||
	-moz-tab-size: 4;
 | 
			
		||||
	-o-tab-size: 4;
 | 
			
		||||
	tab-size: 4;
 | 
			
		||||
 | 
			
		||||
	-webkit-hyphens: none;
 | 
			
		||||
	-moz-hyphens: none;
 | 
			
		||||
	-ms-hyphens: none;
 | 
			
		||||
	hyphens: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre[class*="language-"]::-moz-selection,
 | 
			
		||||
pre[class*="language-"] ::-moz-selection,
 | 
			
		||||
code[class*="language-"]::-moz-selection,
 | 
			
		||||
code[class*="language-"] ::-moz-selection {
 | 
			
		||||
	text-shadow: none;
 | 
			
		||||
	background: rgba(29, 59, 83, 0.99);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre[class*="language-"]::selection,
 | 
			
		||||
pre[class*="language-"] ::selection,
 | 
			
		||||
code[class*="language-"]::selection,
 | 
			
		||||
code[class*="language-"] ::selection {
 | 
			
		||||
	text-shadow: none;
 | 
			
		||||
	background: rgba(29, 59, 83, 0.99);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media print {
 | 
			
		||||
	code[class*="language-"],
 | 
			
		||||
	pre[class*="language-"] {
 | 
			
		||||
		text-shadow: none;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Code blocks */
 | 
			
		||||
pre[class*="language-"] {
 | 
			
		||||
	padding: 1em;
 | 
			
		||||
	margin: 0.5em 0;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:not(pre) > code[class*="language-"],
 | 
			
		||||
pre[class*="language-"] {
 | 
			
		||||
	color: white;
 | 
			
		||||
	background: #011627;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:not(pre) > code[class*="language-"] {
 | 
			
		||||
	padding: 0.1em;
 | 
			
		||||
	border-radius: 0.3em;
 | 
			
		||||
	white-space: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.comment,
 | 
			
		||||
.token.prolog,
 | 
			
		||||
.token.cdata {
 | 
			
		||||
	color: rgb(99, 119, 119);
 | 
			
		||||
	font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.punctuation {
 | 
			
		||||
	color: rgb(199, 146, 234);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.namespace {
 | 
			
		||||
	color: rgb(178, 204, 214);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.deleted {
 | 
			
		||||
	color: rgba(239, 83, 80, 0.56);
 | 
			
		||||
	font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.symbol,
 | 
			
		||||
.token.property {
 | 
			
		||||
	color: rgb(128, 203, 196);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.tag,
 | 
			
		||||
.token.operator,
 | 
			
		||||
.token.keyword {
 | 
			
		||||
	color: rgb(127, 219, 202);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.boolean {
 | 
			
		||||
	color: rgb(255, 88, 116);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.number {
 | 
			
		||||
	color: rgb(247, 140, 108);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.constant,
 | 
			
		||||
.token.function,
 | 
			
		||||
.token.builtin,
 | 
			
		||||
.token.char {
 | 
			
		||||
	color: rgb(130, 170, 255);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.selector,
 | 
			
		||||
.token.doctype {
 | 
			
		||||
	color: rgb(199, 146, 234);
 | 
			
		||||
	font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.attr-name,
 | 
			
		||||
.token.inserted {
 | 
			
		||||
	color: rgb(173, 219, 103);
 | 
			
		||||
	font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.string,
 | 
			
		||||
.token.url,
 | 
			
		||||
.token.entity,
 | 
			
		||||
.language-css .token.string,
 | 
			
		||||
.style .token.string {
 | 
			
		||||
	color: rgb(173, 219, 103);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.class-name,
 | 
			
		||||
.token.atrule,
 | 
			
		||||
.token.attr-value {
 | 
			
		||||
	color: rgb(255, 203, 139);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.regex,
 | 
			
		||||
.token.important,
 | 
			
		||||
.token.variable {
 | 
			
		||||
	color: rgb(214, 222, 235);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.important,
 | 
			
		||||
.token.bold {
 | 
			
		||||
	font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.token.italic {
 | 
			
		||||
	font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										63
									
								
								css/tailwind.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,63 @@
 | 
			
		||||
@tailwind base;
 | 
			
		||||
@tailwind components;
 | 
			
		||||
@tailwind utilities;
 | 
			
		||||
 | 
			
		||||
.remark-code-title {
 | 
			
		||||
  @apply px-5 py-3 font-mono text-sm font-bold text-gray-200 bg-gray-700 rounded-t;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer base {
 | 
			
		||||
  html {
 | 
			
		||||
    scroll-behavior: smooth;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.remark-code-title + div > pre {
 | 
			
		||||
  @apply mt-0 rounded-t-none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.task-list-item:before {
 | 
			
		||||
  @apply hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.code-line {
 | 
			
		||||
  @apply pl-4 -mx-4 border-l-4 border-gray-800;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.highlight-line {
 | 
			
		||||
  @apply -mx-4 bg-gray-700 bg-opacity-50 border-l-4 border-primary-500;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.line-number::before {
 | 
			
		||||
  @apply pr-4 -ml-2 text-gray-400;
 | 
			
		||||
  content: attr(line);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Some of these fonts don't exist 
 | 
			
		||||
 * https://www.coltborg.com/style-a-blockquote-using-tailwind-css/
 | 
			
		||||
.stylistic-quote-mark {
 | 
			
		||||
  font-size: 5rem;
 | 
			
		||||
  right: 100%;
 | 
			
		||||
  @apply mr-2 hidden font-dank-mono text-neutral-500 absolute top-0 leading-none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@screen sm {
 | 
			
		||||
  .stylistic-quote-mark {
 | 
			
		||||
    @apply block;
 | 
			
		||||
  }
 | 
			
		||||
}*/
 | 
			
		||||
 | 
			
		||||
.video-responsive {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  padding-bottom: 56.25%;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  height: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.video-responsive iframe {
 | 
			
		||||
  left: 0;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  position: absolute;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								data/authors/default.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,8 @@
 | 
			
		||||
name: Red Hat
 | 
			
		||||
avatar: /static/images/redhat.png
 | 
			
		||||
occupation: TSSC Workshop
 | 
			
		||||
company: Open Source
 | 
			
		||||
email: redhat@redhat.com
 | 
			
		||||
twitter: https://twitter.com/RedHat
 | 
			
		||||
github: https://github.com/RedHat
 | 
			
		||||
linkedin: https://www.linkedin.com/in/RedHat
 | 
			
		||||
							
								
								
									
										12
									
								
								data/authors/sparrowhawk.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,12 @@
 | 
			
		||||
---
 | 
			
		||||
name: Sparrow Hawk
 | 
			
		||||
avatar: /static/images/sparrowhawk-avatar.jpg
 | 
			
		||||
occupation: Wizard of Earthsea
 | 
			
		||||
company: Earthsea
 | 
			
		||||
twitter: https://twitter.com/sparrowhawk
 | 
			
		||||
linkedin: https://www.linkedin.com/sparrowhawk
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
At birth Ged was given the child-name Duny by his mother. He was born on the island of Gont, 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 understanding of their meanings, to surprising effect.
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
							
								
								
									
										0
									
								
								data/blog/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										6
									
								
								data/headerNavLinks.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,6 @@
 | 
			
		||||
const headerNavLinks = [
 | 
			
		||||
  { href: '/workshop', title: 'Exercises' },
 | 
			
		||||
  { href: 'https://etherpad.wikimedia.org/p/tssc-workshop-bne-dec-23', title: 'Etherpad'}
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export default headerNavLinks
 | 
			
		||||
							
								
								
									
										19
									
								
								data/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,19 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
 | 
			
		||||
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
 | 
			
		||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
 | 
			
		||||
	 viewBox="0 0 228 228" width="39" height="30" style="enable-background:new 0 0 228 228;" xml:space="preserve">
 | 
			
		||||
<style type="text/css">
 | 
			
		||||
	.st0{fill:#EE0000;}
 | 
			
		||||
</style>
 | 
			
		||||
<g>
 | 
			
		||||
	<g>
 | 
			
		||||
		<path d="M187.9,100.6c0.3,1.3,0.4,2.7,0.4,4.1c0,17.7-21.5,20.7-36.3,20.7c-57.8,0-100.8-35.9-100.8-46.8c0-0.7,0-1.5,0.3-2.3
 | 
			
		||||
			L47,87c-1,2.3-1.8,5.4-1.8,8.7c0,21.5,48.6,54,104.2,54c24.6,0,43.3-9.2,43.3-25.9c0-1.3,0-2.3-2-12L187.9,100.6z"/>
 | 
			
		||||
		<path class="st0" d="M151.9,125.4c14.8,0,36.3-3.1,36.3-20.7c0-1.4,0-2.7-0.4-4.1l-8.8-38.4c-2.1-8.4-3.8-12.3-18.7-19.7
 | 
			
		||||
			c-11.5-5.9-36.6-15.6-44-15.6c-6.9,0-9,9-17.1,9c-7.9,0-13.8-6.7-21.2-6.7c-7.2,0-11.8,4.9-15.4,14.8c0,0-10,28.2-11.3,32.2
 | 
			
		||||
			c-0.3,0.8-0.3,1.6-0.3,2.3C51.1,89.5,94.2,125.4,151.9,125.4 M190.6,111.9c2,9.7,2,10.7,2,12c0,16.6-18.7,25.9-43.3,25.9
 | 
			
		||||
			c-55.5,0-104.2-32.5-104.2-54c0-3.3,0.8-6.4,1.8-8.7c-20,1-45.8,4.6-45.8,27.4c0,37.4,88.6,83.4,158.7,83.4
 | 
			
		||||
			c53.8,0,67.3-24.3,67.3-43.5C227.2,139.3,214.1,122.1,190.6,111.9"/>
 | 
			
		||||
	</g>
 | 
			
		||||
</g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 1.2 KiB  | 
							
								
								
									
										11
									
								
								data/projectsData.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,11 @@
 | 
			
		||||
const projectsData = [
 | 
			
		||||
  {
 | 
			
		||||
    title: 'BBQ and Kubernetes deployments',
 | 
			
		||||
    description: `Who knew that BBQing had so much to do with deploying containers
 | 
			
		||||
    to Kubernetes clusters?`,
 | 
			
		||||
    imgSrc: '/static/images/google.png',
 | 
			
		||||
    href: '/workshop/bbq-and-kubernetes',
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export default projectsData
 | 
			
		||||
							
								
								
									
										69
									
								
								data/siteMetadata.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,69 @@
 | 
			
		||||
const siteMetadata = {
 | 
			
		||||
  title: 'Red Hat OpenShift Application Delivery Workshop',
 | 
			
		||||
  author: 'Red Hat',
 | 
			
		||||
  headerTitle: 'Red Hat',
 | 
			
		||||
  description: 'Red Hat OpenShift Application Delivery Workshop',
 | 
			
		||||
  language: 'en-us',
 | 
			
		||||
  siteUrl: 'https://oadw.rhdemo.win',
 | 
			
		||||
  siteRepo: 'https://github.com/jmhbnz/ocp-app-delivery-workshop',
 | 
			
		||||
  siteLogo: '/static/images/redhat.png',
 | 
			
		||||
  image: '/static/images/avatar.png',
 | 
			
		||||
  socialBanner: '/static/images/twitter-card.png',
 | 
			
		||||
  email: 'jablair@redhat.com',
 | 
			
		||||
  github: 'https://github.com/jmhbnz',
 | 
			
		||||
  twitter: 'https://twitter.com/redhat',
 | 
			
		||||
  facebook: 'https://facebook.com',
 | 
			
		||||
  youtube: 'https://www.youtube.com',
 | 
			
		||||
  linkedin: 'https://www.linkedin.com',
 | 
			
		||||
  locale: 'en-US',
 | 
			
		||||
  analytics: {
 | 
			
		||||
    // supports plausible, simpleAnalytics or googleAnalytics
 | 
			
		||||
    plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
 | 
			
		||||
    simpleAnalytics: false, // true or false
 | 
			
		||||
    googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
 | 
			
		||||
  },
 | 
			
		||||
  comment: {
 | 
			
		||||
    // Select a provider and use the environment variables associated to it
 | 
			
		||||
    // https://vercel.com/docs/environment-variables
 | 
			
		||||
    provider: 'giscus', // supported providers: giscus, utterances, disqus
 | 
			
		||||
    giscusConfig: {
 | 
			
		||||
      // Visit the link below, and follow the steps in the 'configuration' section
 | 
			
		||||
      // https://giscus.app/
 | 
			
		||||
      repo: process.env.NEXT_PUBLIC_GISCUS_REPO,
 | 
			
		||||
      repositoryId: process.env.NEXT_PUBLIC_GISCUS_REPOSITORY_ID,
 | 
			
		||||
      category: process.env.NEXT_PUBLIC_GISCUS_CATEGORY,
 | 
			
		||||
      categoryId: process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID,
 | 
			
		||||
      mapping: 'pathname', // supported options: pathname, url, title
 | 
			
		||||
      reactions: '1', // Emoji reactions: 1 = enable / 0 = disable
 | 
			
		||||
      // Send discussion metadata periodically to the parent window: 1 = enable / 0 = disable
 | 
			
		||||
      metadata: '0',
 | 
			
		||||
      // theme example: light, dark, dark_dimmed, dark_high_contrast
 | 
			
		||||
      // transparent_dark, preferred_color_scheme, custom
 | 
			
		||||
      theme: 'light',
 | 
			
		||||
      // theme when dark mode
 | 
			
		||||
      darkTheme: 'transparent_dark',
 | 
			
		||||
      // If the theme option above is set to 'custom`
 | 
			
		||||
      // please provide a link below to your custom theme css file.
 | 
			
		||||
      // example: https://giscus.app/themes/custom_example.css
 | 
			
		||||
      //themeURL: '',
 | 
			
		||||
    },
 | 
			
		||||
    utterancesConfig: {
 | 
			
		||||
      // Visit the link below, and follow the steps in the 'configuration' section
 | 
			
		||||
      // https://utteranc.es/
 | 
			
		||||
      repo: process.env.NEXT_PUBLIC_UTTERANCES_REPO,
 | 
			
		||||
      issueTerm: '', // supported options: pathname, url, title
 | 
			
		||||
      label: '', // label (optional): Comment 💬
 | 
			
		||||
      // theme example: github-light, github-dark, preferred-color-scheme
 | 
			
		||||
      // github-dark-orange, icy-dark, dark-blue, photon-dark, boxy-light
 | 
			
		||||
      theme: '',
 | 
			
		||||
      // theme when dark mode
 | 
			
		||||
      darkTheme: '',
 | 
			
		||||
    },
 | 
			
		||||
    disqus: {
 | 
			
		||||
      // https://help.disqus.com/en/articles/1717111-what-s-a-shortname
 | 
			
		||||
      //shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = siteMetadata
 | 
			
		||||
							
								
								
									
										189
									
								
								data/workshop/exercise1.mdx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,189 @@
 | 
			
		||||
---
 | 
			
		||||
title: Getting familiar with OpenShift application platform
 | 
			
		||||
exercise: 1
 | 
			
		||||
date: '2023-12-04'
 | 
			
		||||
tags: ['openshift','containers','kubernetes']
 | 
			
		||||
draft: false
 | 
			
		||||
authors: ['default']
 | 
			
		||||
summary: "In this first exercise we'll get familiar with OpenShift."
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
In this first exercise we'll sign a container image using Sigstore. Specifically in this example, we're going to use "keyless" signing, one of the workflows supported by Sigstore.
 | 
			
		||||
 | 
			
		||||
Sigstore keyless signing associates identities, rather than keys, with an artifact signature. The Sigstore 'Fulcio' service issues short-lived certificates binding an ephemeral key to an OpenID Connect identity. Signing events are logged in Rekor, a signature transparency log, providing an auditable record of when a signature was created.
 | 
			
		||||
 | 
			
		||||
Sigstore’s root of trust, which includes Fulcio’s root CA certificate and Rekor’s public key, are distributed by The Update Framework (TUF). TUF is a framework to provide secure software and file updates. TUF defines a set of protocols to protect against various types of attacks.
 | 
			
		||||
 | 
			
		||||
This "keyless" signing workflow provides a number of benefits:
 | 
			
		||||
- There is no need for users to store and protect private keys used during signing. The Fulcio service destroys the private key  shortly after and the short-lived identity certificate expires. Users who wish to verify the software will use the transparency log entry, rather than relying on the signer to store and manage the private key.
 | 
			
		||||
- The signature transparency log provides a complete audit trail of signature actions.
 | 
			
		||||
 | 
			
		||||
Let's get started!
 | 
			
		||||
 | 
			
		||||
## Lab environment
 | 
			
		||||
Navigate to your project and find the `tssc-terminal` container. Create a terminal session within OpenShift:
 | 
			
		||||
 | 
			
		||||
<Zoom>
 | 
			
		||||

 | 
			
		||||
</Zoom>
 | 
			
		||||
 | 
			
		||||
Login to the registry using your lab credentials:
 | 
			
		||||
```bash
 | 
			
		||||
podman login -u tssc -p $(oc whoami -t) registry.blueradish.net
 | 
			
		||||
```
 | 
			
		||||
Set the project for your username:
 | 
			
		||||
```
 | 
			
		||||
oc project userX
 | 
			
		||||
```
 | 
			
		||||
Pull down the test app, and re-tag it using your OpenShift project:
 | 
			
		||||
```bash
 | 
			
		||||
OC_PROJECT=$(oc project -q) 
 | 
			
		||||
 | 
			
		||||
podman pull \
 | 
			
		||||
  quay.io/smileyfritz/demo-app:v0.1.1
 | 
			
		||||
 | 
			
		||||
podman tag \
 | 
			
		||||
  quay.io/smileyfritz/demo-app:v0.1.1 \
 | 
			
		||||
  registry.blueradish.net/$OC_PROJECT/demo-app:v1
 | 
			
		||||
 | 
			
		||||
podman push \
 | 
			
		||||
  registry.blueradish.net/$OC_PROJECT/demo-app:v1
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Sign the container image
 | 
			
		||||
Firstly, grab the digest for your new image:
 | 
			
		||||
```bash
 | 
			
		||||
OC_PROJECT=$(oc project -q)
 | 
			
		||||
 | 
			
		||||
IMAGE_REF=registry.blueradish.net/$OC_PROJECT/demo-app
 | 
			
		||||
 | 
			
		||||
IMAGE_DIGEST=$(crane digest $IMAGE_REF:v1)
 | 
			
		||||
```
 | 
			
		||||
Now sign the container with cosign:
 | 
			
		||||
```bash
 | 
			
		||||
cosign sign $IMAGE_REF@$IMAGE_DIGEST
 | 
			
		||||
```
 | 
			
		||||
You'll be presented with a screen to authenticate Sigstore:
 | 
			
		||||
 | 
			
		||||
<Zoom>
 | 
			
		||||

 | 
			
		||||
</Zoom>
 | 
			
		||||
 | 
			
		||||
Copy the `oauth2.sigstore.dev` link into your browser. Log into Sigstore with GitHub.
 | 
			
		||||
 | 
			
		||||
<Zoom>
 | 
			
		||||

 | 
			
		||||
</Zoom>
 | 
			
		||||
 | 
			
		||||
Copy the code and paste it back into your OpenShift terminal session. The Sigstore signing action will continue, and the transparency log will be updated.
 | 
			
		||||
 | 
			
		||||
<Zoom>
 | 
			
		||||

 | 
			
		||||
</Zoom>
 | 
			
		||||
 | 
			
		||||
Note the tlog entry that is created:
 | 
			
		||||
```
 | 
			
		||||
tlog entry created with index: 35809256
 | 
			
		||||
```
 | 
			
		||||
Use `crane` to list the registry contents, and verify that you have a signature created in the registry:
 | 
			
		||||
```bash
 | 
			
		||||
OC_PROJECT=$(oc project -q)
 | 
			
		||||
 | 
			
		||||
crane ls \
 | 
			
		||||
  registry.blueradish.net/$OC_PROJECT/demo-app
 | 
			
		||||
```
 | 
			
		||||
You should see a tag and the signature file are now created:
 | 
			
		||||
```
 | 
			
		||||
sha256-5e350542cb6501b78549701996fc8d22e86d9a8bc51434092d35698d0073b667.sig
 | 
			
		||||
v1
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Verifying the signature
 | 
			
		||||
We can also use the `cosign` to verify container signatures. You can do this knowing the identity that was used to sign the container as well as the certificate issuer. For example, we can validate our upstream image by running:
 | 
			
		||||
```
 | 
			
		||||
cosign verify --certificate-identity shane.boulden@gmail.com --certificate-oidc-issuer https://github.com/login/oauth quay.io/smileyfritz/demo-app:v0.1.1
 | 
			
		||||
 | 
			
		||||
Verification for quay.io/smileyfritz/demo-app:v0.1.1 --
 | 
			
		||||
The following checks were performed on each of these signatures:
 | 
			
		||||
  - The cosign claims were validated
 | 
			
		||||
  - Existence of the claims in the transparency log was verified offline
 | 
			
		||||
  - The code-signing certificate was verified using trusted certificate authority certificates
 | 
			
		||||
 | 
			
		||||
[{"critical":{"identity":{"docker-reference":"quay.io/smileyfritz/demo-app"},"image":{"docker-manifest-digest":"sha256:63c1117db19d56296150993c4f4eb78d54b386cbf35f4a2116b821e6b34ed53e"},"type":"cosign container image signature"},
 | 
			
		||||
...
 | 
			
		||||
```
 | 
			
		||||
Now lets use the `cosign` to verify the signature you created:
 | 
			
		||||
```bash
 | 
			
		||||
OC_PROJECT=$(oc project -q)
 | 
			
		||||
 | 
			
		||||
 cosign verify \
 | 
			
		||||
   --certificate-oidc-issuer https://github.com/login/oauth \
 | 
			
		||||
   --certificate-identity <your-github-identity> \
 | 
			
		||||
   registry.blueradish.net/$OC_PROJECT/demo-app:v1
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Inspecting the transparency log
 | 
			
		||||
We can also inspect the information that's been published to the Rekor transparency log, using the `tlog` entry number that was recorded initially. If you don't have one, just sign the image again with `cosign`.
 | 
			
		||||
 | 
			
		||||
Let's see what the record looks like:
 | 
			
		||||
```json
 | 
			
		||||
rekor-cli get --log-index 35809256
 | 
			
		||||
 | 
			
		||||
LogID: c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d
 | 
			
		||||
Index: 35809256
 | 
			
		||||
IntegratedTime: 2023-09-12T06:03:46Z
 | 
			
		||||
UUID: 24296fb24b8ad77a8b3ac548fc64ce4da8544381d36c2176cf84975da85aa05ee9e92efea91224a7
 | 
			
		||||
Body: {
 | 
			
		||||
  "HashedRekordObj": {
 | 
			
		||||
    "data": {
 | 
			
		||||
      "hash": {
 | 
			
		||||
        "algorithm": "sha256",
 | 
			
		||||
        "value": "628a902b63f6224376503e7169af80a2f03f522734b42e0dd768440b0359bfd0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "signature": {
 | 
			
		||||
      "content": "MEUCIAEdOaVgD3xzYiX9yibIU2vr2fAa3dpZzZ6fZAEJOwa7AiEA2204D/Pg91BLRVtqR9t0DQpEivbrxwuJ3zhEjdDvJ3U=",
 | 
			
		||||
      "publicKey": {
 | 
			
		||||
        "content": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxekNDQWwyZ0F3SUJBZ0lVVGE0eHh1b1dadjNlcTBVbTQ5Nm9xQmhXeStJd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpNd09URXlNRFl3TXpReldoY05Nak13T1RFeU1EWXhNelF6V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUV1dG5mRmpHNFZ3MnUxNFBOVjJ4OFQrL1pRUGZNVVhWS0tSMWcKU2RhTml3bzllQ2FMdlNrRk10b2RlRGdjM3JHa2NLV09BbGJOUktKdThvOWFnWFN0MXFPQ0FYd3dnZ0Y0TUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVFdmoyClhaOFZQU0R5ZmFtRkIyT0poTlQyMUJvd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0pRWURWUjBSQVFIL0JCc3dHWUVYYzJoaGJtVXVZbTkxYkdSbGJrQm5iV0ZwYkM1amIyMHdMQVlLS3dZQgpCQUdEdnpBQkFRUWVhSFIwY0hNNkx5OW5hWFJvZFdJdVkyOXRMMnh2WjJsdUwyOWhkWFJvTUM0R0Npc0dBUVFCCmc3OHdBUWdFSUF3ZWFIUjBjSE02THk5bmFYUm9kV0l1WTI5dEwyeHZaMmx1TDI5aGRYUm9NSUdMQmdvckJnRUUKQWRaNUFnUUNCSDBFZXdCNUFIY0EzVDB3YXNiSEVUSmpHUjRjbVdjM0FxSktYcmplUEszL2g0cHlnQzhwN280QQpBQUdLaC8wUlFnQUFCQU1BU0RCR0FpRUFreDNqVHJxMXplcFpaQm5wUjlzL1ZDRzcyU2hCVW5nQTVCeitDd21nCnJrUUNJUUNxYnRqaVFkMndxNE5NTmkvOG0ycXVNVlVrQ2tXMDVEVGR3RHIvNDljZTh6QUtCZ2dxaGtqT1BRUUQKQXdOb0FEQmxBakVBay9TZ0pOcmpXc1B3WXl2bTBNNnVMMFQwSVVuVjlPeE1WRDQyTjh0M09wTTNGdUZHR3lyNgpkR2crYVpOQ25zdXRBakEyTGpicjN4UHIrdDloa2VadG9lZFhWM2VJeTJPdmdERnJRbUI1dFFwNEJyajZjZHdOCmRsUXN0b2dHdkdVZ0lqRT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
Since this is a json document we can extract the information with `jq`. For example, we can look at the certificates that were generated by Cosign and signed by the Fulcio CA:
 | 
			
		||||
```bash
 | 
			
		||||
# Fetch the record from Rekor by log index in JSON format
 | 
			
		||||
REKOR_RECORD=$(rekor-cli get --log-index 35809256 --format json)
 | 
			
		||||
 | 
			
		||||
# Extract the public key content from the Rekor record using jq
 | 
			
		||||
PUBLIC_KEY=$(echo "$REKOR_RECORD" |\
 | 
			
		||||
 jq -r '.Body.HashedRekordObj.signature.publicKey.content')
 | 
			
		||||
 | 
			
		||||
# Decode the base64-encoded content of the public key
 | 
			
		||||
DECODED_KEY=$(echo "$PUBLIC_KEY" | base64 -d)
 | 
			
		||||
 | 
			
		||||
# Display the X.509 certificate information of the decoded key
 | 
			
		||||
openssl x509 -noout -text <<< "$DECODED_KEY"
 | 
			
		||||
```
 | 
			
		||||
```yaml
 | 
			
		||||
 | 
			
		||||
Certificate:
 | 
			
		||||
    Data:
 | 
			
		||||
        Version: 3 (0x2)
 | 
			
		||||
        Serial Number:
 | 
			
		||||
            4d:ae:31:c6:ea:16:66:fd:de:ab:45:26:e3:de:a8:a8:18:56:cb:e2
 | 
			
		||||
        Signature Algorithm: ecdsa-with-SHA384
 | 
			
		||||
        Issuer: O = sigstore.dev, CN = sigstore-intermediate
 | 
			
		||||
        Validity
 | 
			
		||||
            Not Before: Sep 12 06:03:43 2023 GMT
 | 
			
		||||
            Not After : Sep 12 06:13:43 2023 GMT
 | 
			
		||||
        Subject: 
 | 
			
		||||
        Subject Public Key Info:
 | 
			
		||||
            Public Key Algorithm: id-ecPublicKey
 | 
			
		||||
                Public-Key: (256 bit)
 | 
			
		||||
```
 | 
			
		||||
## Stretch goals
 | 
			
		||||
Let's investigate data stored in Rekor. See if you can use the `rekor-cli` commands to find out more about the image `cgr.dev/chainguard/jre:latest`:
 | 
			
		||||
- Which version of `openssl-config` is used in this image?
 | 
			
		||||
- How was this image signed? Was it interactively signed, or was a build system used?
 | 
			
		||||
- When was this image last signed?
 | 
			
		||||
							
								
								
									
										12
									
								
								jsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,12 @@
 | 
			
		||||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "baseUrl": ".",
 | 
			
		||||
    "paths": {
 | 
			
		||||
      "@/components/*": ["components/*"],
 | 
			
		||||
      "@/data/*": ["data/*"],
 | 
			
		||||
      "@/layouts/*": ["layouts/*"],
 | 
			
		||||
      "@/lib/*": ["lib/*"],
 | 
			
		||||
      "@/css/*": ["css/*"]
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										41
									
								
								layouts/AuthorLayout.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,41 @@
 | 
			
		||||
import SocialIcon from '@/components/social-icons'
 | 
			
		||||
import Image from '@/components/Image'
 | 
			
		||||
import { PageSEO } from '@/components/SEO'
 | 
			
		||||
 | 
			
		||||
export default function AuthorLayout({ children, frontMatter }) {
 | 
			
		||||
  const { name, avatar, occupation, company, email, twitter, linkedin, github } = frontMatter
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PageSEO title={`About - ${name}`} description={`About me - ${name}`} />
 | 
			
		||||
      <div className="divide-y">
 | 
			
		||||
        <div className="pt-6 pb-8 space-y-2 md:space-y-5">
 | 
			
		||||
          <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
 | 
			
		||||
            About
 | 
			
		||||
          </h1>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="items-start space-y-2 xl:grid xl:grid-cols-3 xl:gap-x-8 xl:space-y-0">
 | 
			
		||||
          <div className="flex flex-col items-center pt-8 space-x-2">
 | 
			
		||||
            <Image
 | 
			
		||||
              src={avatar}
 | 
			
		||||
              alt="avatar"
 | 
			
		||||
              width="192px"
 | 
			
		||||
              height="192px"
 | 
			
		||||
              className="w-48 h-48 rounded-full"
 | 
			
		||||
            />
 | 
			
		||||
            <h3 className="pt-4 pb-2 text-2xl font-bold leading-8 tracking-tight">{name}</h3>
 | 
			
		||||
            <div className="text-gray-500 dark:text-gray-400">{occupation}</div>
 | 
			
		||||
            <div className="text-gray-500 dark:text-gray-400">{company}</div>
 | 
			
		||||
            <div className="flex pt-6 space-x-3">
 | 
			
		||||
              <SocialIcon kind="mail" href={`mailto:${email}`} />
 | 
			
		||||
              <SocialIcon kind="github" href={github} />
 | 
			
		||||
              <SocialIcon kind="linkedin" href={linkedin} />
 | 
			
		||||
              <SocialIcon kind="twitter" href={twitter} />
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="pt-8 pb-8 prose dark:prose-dark max-w-none xl:col-span-2">{children}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										90
									
								
								layouts/ListLayout.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,90 @@
 | 
			
		||||
import Link from '@/components/Link'
 | 
			
		||||
import Tag from '@/components/Tag'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import Pagination from '@/components/Pagination'
 | 
			
		||||
import formatDate from '@/lib/utils/formatDate'
 | 
			
		||||
 | 
			
		||||
export default function ListLayout({ posts, title, initialDisplayPosts = [], pagination }) {
 | 
			
		||||
  const [searchValue, setSearchValue] = useState('')
 | 
			
		||||
  const filteredBlogPosts = posts.filter((frontMatter) => {
 | 
			
		||||
    const searchContent = frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ')
 | 
			
		||||
    return searchContent.toLowerCase().includes(searchValue.toLowerCase())
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // If initialDisplayPosts exist, display it if no searchValue is specified
 | 
			
		||||
  const displayPosts =
 | 
			
		||||
    initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div className="divide-y">
 | 
			
		||||
        <div className="pt-6 pb-8 space-y-2 md:space-y-5">
 | 
			
		||||
          <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
 | 
			
		||||
            {title}
 | 
			
		||||
          </h1>
 | 
			
		||||
          <div className="relative max-w-lg">
 | 
			
		||||
            <input
 | 
			
		||||
              aria-label="Search articles"
 | 
			
		||||
              type="text"
 | 
			
		||||
              onChange={(e) => setSearchValue(e.target.value)}
 | 
			
		||||
              placeholder="Search articles"
 | 
			
		||||
              className="block w-full px-4 py-2 text-gray-900 bg-white border border-gray-300 rounded-md dark:border-gray-900 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-800 dark:text-gray-100"
 | 
			
		||||
            />
 | 
			
		||||
            <svg
 | 
			
		||||
              className="absolute w-5 h-5 text-gray-400 right-3 top-3 dark:text-gray-300"
 | 
			
		||||
              xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
              fill="none"
 | 
			
		||||
              viewBox="0 0 24 24"
 | 
			
		||||
              stroke="currentColor"
 | 
			
		||||
            >
 | 
			
		||||
              <path
 | 
			
		||||
                strokeLinecap="round"
 | 
			
		||||
                strokeLinejoin="round"
 | 
			
		||||
                strokeWidth={2}
 | 
			
		||||
                d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
 | 
			
		||||
              />
 | 
			
		||||
            </svg>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <ul>
 | 
			
		||||
          {!filteredBlogPosts.length && 'No posts found.'}
 | 
			
		||||
          {displayPosts.map((frontMatter) => {
 | 
			
		||||
            const { slug, date, title, summary, tags, exercise } = frontMatter
 | 
			
		||||
            return (
 | 
			
		||||
              <li key={slug} className="py-4">
 | 
			
		||||
                <article className="space-y-2 xl:grid xl:grid-cols-4 xl:space-y-0 xl:items-baseline">
 | 
			
		||||
                  <dl>
 | 
			
		||||
                    <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
 | 
			
		||||
                      <div>Exercise {exercise}</div>
 | 
			
		||||
                    </dd>
 | 
			
		||||
                  </dl>
 | 
			
		||||
                  <div className="space-y-3 xl:col-span-3">
 | 
			
		||||
                    <div>
 | 
			
		||||
                      <h3 className="text-2xl font-bold leading-8 tracking-tight">
 | 
			
		||||
                        <Link href={`/workshop/${slug}`} className="text-gray-900 dark:text-gray-100">
 | 
			
		||||
                          {title}
 | 
			
		||||
                        </Link>
 | 
			
		||||
                      </h3>
 | 
			
		||||
                      <div className="flex flex-wrap">
 | 
			
		||||
                        {tags.map((tag) => (
 | 
			
		||||
                          <Tag key={tag} text={tag} />
 | 
			
		||||
                        ))}
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div className="prose text-gray-500 max-w-none dark:text-gray-400">
 | 
			
		||||
                      {summary}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </article>
 | 
			
		||||
              </li>
 | 
			
		||||
            )
 | 
			
		||||
          })}
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
      {pagination && pagination.totalPages > 1 && !searchValue && (
 | 
			
		||||
        <Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										142
									
								
								layouts/PostLayout.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,142 @@
 | 
			
		||||
import Link from '@/components/Link'
 | 
			
		||||
import PageTitle from '@/components/PageTitle'
 | 
			
		||||
import SectionContainer from '@/components/SectionContainer'
 | 
			
		||||
import { BlogSEO } from '@/components/SEO'
 | 
			
		||||
import Image from '@/components/Image'
 | 
			
		||||
import Tag from '@/components/Tag'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import Comments from '@/components/comments'
 | 
			
		||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
 | 
			
		||||
 | 
			
		||||
const editUrl = (fileName) => `${siteMetadata.siteRepo}/blob/master/data/workshop/${fileName}`
 | 
			
		||||
const discussUrl = (slug) =>
 | 
			
		||||
  `https://mobile.twitter.com/search?q=${encodeURIComponent(
 | 
			
		||||
    `${siteMetadata.siteUrl}/workshop/${slug}`
 | 
			
		||||
  )}`
 | 
			
		||||
 | 
			
		||||
const postDateTemplate = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }
 | 
			
		||||
 | 
			
		||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
 | 
			
		||||
  const { slug, fileName, date, title, images, tags, readingTime, exercise } = frontMatter
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SectionContainer>
 | 
			
		||||
      <BlogSEO
 | 
			
		||||
        url={`${siteMetadata.siteUrl}/workshop/${slug}`}
 | 
			
		||||
        authorDetails={authorDetails}
 | 
			
		||||
        {...frontMatter}
 | 
			
		||||
      />
 | 
			
		||||
      <ScrollTopAndComment />
 | 
			
		||||
      <article>
 | 
			
		||||
        <div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700">
 | 
			
		||||
          <header className="pt-6 xl:pb-6">
 | 
			
		||||
            <div className="space-y-1 text-center">
 | 
			
		||||
              <dl className="space-y-10">
 | 
			
		||||
                <div>
 | 
			
		||||
                  <dt className="sr-only">Published on</dt>
 | 
			
		||||
                  <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
 | 
			
		||||
                    Exercise {exercise}
 | 
			
		||||
                  </dd>
 | 
			
		||||
                </div>
 | 
			
		||||
              </dl>
 | 
			
		||||
              <div>
 | 
			
		||||
                <PageTitle>{title}</PageTitle>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </header>
 | 
			
		||||
          <div
 | 
			
		||||
            className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0"
 | 
			
		||||
            style={{ gridTemplateRows: 'auto 1fr' }}
 | 
			
		||||
          >
 | 
			
		||||
            <dl className="pt-6 pb-10 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700">
 | 
			
		||||
              <dt className="sr-only">Authors</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <ul className="flex justify-center space-x-8 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
 | 
			
		||||
                  {authorDetails.map((author) => (
 | 
			
		||||
                    <li className="flex items-center space-x-2" key={author.name}>
 | 
			
		||||
                      {author.avatar && (
 | 
			
		||||
                        <Image
 | 
			
		||||
                          src={author.avatar}
 | 
			
		||||
                          width="38px"
 | 
			
		||||
                          height="38px"
 | 
			
		||||
                          alt="avatar"
 | 
			
		||||
                          className="h-10 w-10 rounded-full"
 | 
			
		||||
                        />
 | 
			
		||||
                      )}
 | 
			
		||||
                      <dl className="whitespace-nowrap text-sm font-medium leading-5">
 | 
			
		||||
                        <dt className="sr-only">Name</dt>
 | 
			
		||||
                        <dd className="text-gray-900 dark:text-gray-100">{author.name}</dd>
 | 
			
		||||
                        <dt className="sr-only">Twitter</dt>
 | 
			
		||||
                        <dd>
 | 
			
		||||
                          {author.twitter && (
 | 
			
		||||
                            <Link
 | 
			
		||||
                              href={author.twitter}
 | 
			
		||||
                              className="text-primary-800 hover:text-primary-900 dark:hover:text-primary-400"
 | 
			
		||||
                            >
 | 
			
		||||
                              {author.twitter.replace('https://twitter.com/', '@')}
 | 
			
		||||
                            </Link>
 | 
			
		||||
                          )}
 | 
			
		||||
                        </dd>
 | 
			
		||||
                      </dl>
 | 
			
		||||
                    </li>
 | 
			
		||||
                  ))}
 | 
			
		||||
                </ul>
 | 
			
		||||
              </dd>
 | 
			
		||||
            </dl>
 | 
			
		||||
            <div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
 | 
			
		||||
              <div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <footer>
 | 
			
		||||
              <div className="divide-gray-200 text-sm font-medium leading-5 dark:divide-gray-700 xl:col-start-1 xl:row-start-2 xl:divide-y">
 | 
			
		||||
                {tags && (
 | 
			
		||||
                  <div className="py-4 xl:py-8">
 | 
			
		||||
                    <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
 | 
			
		||||
                      Tags
 | 
			
		||||
                    </h2>
 | 
			
		||||
                    <div className="flex flex-wrap">
 | 
			
		||||
                      {tags.map((tag) => (
 | 
			
		||||
                        <Tag key={tag} text={tag} />
 | 
			
		||||
                      ))}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
                {(next || prev) && (
 | 
			
		||||
                  <div className="flex justify-between py-4 xl:block xl:space-y-8 xl:py-8">
 | 
			
		||||
                    {prev && (
 | 
			
		||||
                      <div>
 | 
			
		||||
                        <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
 | 
			
		||||
                          Previous Exercise
 | 
			
		||||
                        </h2>
 | 
			
		||||
                        <div className="text-primary-800 hover:text-primary-900 dark:hover:text-primary-400">
 | 
			
		||||
                          <Link href={`/workshop/${prev.slug}`}>{prev.title}</Link>
 | 
			
		||||
                        </div>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    )}
 | 
			
		||||
                    {next && (
 | 
			
		||||
                      <div>
 | 
			
		||||
                        <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
 | 
			
		||||
                          Next Exercise
 | 
			
		||||
                        </h2>
 | 
			
		||||
                        <div className="text-primary-800 hover:text-primary-900 dark:hover:text-primary-400">
 | 
			
		||||
                          <Link href={`/workshop/${next.slug}`}>{next.title}</Link>
 | 
			
		||||
                        </div>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    )}
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="pt-4 xl:pt-8">
 | 
			
		||||
                <Link
 | 
			
		||||
                  href="/workshop"
 | 
			
		||||
                  className="text-primary-800 hover:text-primary-900 dark:hover:text-primary-400"
 | 
			
		||||
                >
 | 
			
		||||
                  ← Back to the exercise list
 | 
			
		||||
                </Link>
 | 
			
		||||
              </div>
 | 
			
		||||
            </footer>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </article>
 | 
			
		||||
    </SectionContainer>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										71
									
								
								layouts/PostSimple.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,71 @@
 | 
			
		||||
import Link from '@/components/Link'
 | 
			
		||||
import PageTitle from '@/components/PageTitle'
 | 
			
		||||
import SectionContainer from '@/components/SectionContainer'
 | 
			
		||||
import { BlogSEO } from '@/components/SEO'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import formatDate from '@/lib/utils/formatDate'
 | 
			
		||||
import Comments from '@/components/comments'
 | 
			
		||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
 | 
			
		||||
 | 
			
		||||
export default function PostLayout({ frontMatter, authorDetails, next, prev, children }) {
 | 
			
		||||
  const { date, title } = frontMatter
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SectionContainer>
 | 
			
		||||
      <BlogSEO url={`${siteMetadata.siteUrl}/workshop/${frontMatter.slug}`} {...frontMatter} />
 | 
			
		||||
      <ScrollTopAndComment />
 | 
			
		||||
      <article>
 | 
			
		||||
        <div>
 | 
			
		||||
          <header>
 | 
			
		||||
            <div className="space-y-1 border-b border-gray-200 pb-10 text-center dark:border-gray-700">
 | 
			
		||||
              <dl>
 | 
			
		||||
                <div>
 | 
			
		||||
                  <dt className="sr-only">Published on</dt>
 | 
			
		||||
                  <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
 | 
			
		||||
                    <time dateTime={date}>{formatDate(date)}</time>
 | 
			
		||||
                  </dd>
 | 
			
		||||
                </div>
 | 
			
		||||
              </dl>
 | 
			
		||||
              <div>
 | 
			
		||||
                <PageTitle>{title}</PageTitle>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </header>
 | 
			
		||||
          <div
 | 
			
		||||
            className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0 "
 | 
			
		||||
            style={{ gridTemplateRows: 'auto 1fr' }}
 | 
			
		||||
          >
 | 
			
		||||
            <div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
 | 
			
		||||
              <div className="prose max-w-none pt-10 pb-8 dark:prose-dark">{children}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <Comments frontMatter={frontMatter} />
 | 
			
		||||
            <footer>
 | 
			
		||||
              <div className="flex flex-col text-sm font-medium sm:flex-row sm:justify-between sm:text-base">
 | 
			
		||||
                {prev && (
 | 
			
		||||
                  <div className="pt-4 xl:pt-8">
 | 
			
		||||
                    <Link
 | 
			
		||||
                      href={`/workshop/${prev.slug}`}
 | 
			
		||||
                      className="text-primary-800 hover:text-primary-900 dark:hover:text-primary-400"
 | 
			
		||||
                    >
 | 
			
		||||
                      ← {prev.title}
 | 
			
		||||
                    </Link>
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
                {next && (
 | 
			
		||||
                  <div className="pt-4 xl:pt-8">
 | 
			
		||||
                    <Link
 | 
			
		||||
                      href={`/workshop/${next.slug}`}
 | 
			
		||||
                      className="text-primary-800 hover:text-primary-900 dark:hover:text-primary-400"
 | 
			
		||||
                    >
 | 
			
		||||
                      {next.title} →
 | 
			
		||||
                    </Link>
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            </footer>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </article>
 | 
			
		||||
    </SectionContainer>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										32
									
								
								lib/generate-rss.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,32 @@
 | 
			
		||||
import { escape } from '@/lib/utils/htmlEscaper'
 | 
			
		||||
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const generateRssItem = (post) => `
 | 
			
		||||
  <item>
 | 
			
		||||
    <guid>${siteMetadata.siteUrl}/workshop/${post.slug}</guid>
 | 
			
		||||
    <title>${escape(post.title)}</title>
 | 
			
		||||
    <link>${siteMetadata.siteUrl}/workshop/${post.slug}</link>
 | 
			
		||||
    ${post.summary && `<description>${escape(post.summary)}</description>`}
 | 
			
		||||
    <pubDate>${new Date(post.date).toUTCString()}</pubDate>
 | 
			
		||||
    <author>${siteMetadata.email} (${siteMetadata.author})</author>
 | 
			
		||||
    ${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')}
 | 
			
		||||
  </item>
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
const generateRss = (posts, page = 'feed.xml') => `
 | 
			
		||||
  <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
 | 
			
		||||
    <channel>
 | 
			
		||||
      <title>${escape(siteMetadata.title)}</title>
 | 
			
		||||
      <link>${siteMetadata.siteUrl}/workshop</link>
 | 
			
		||||
      <description>${escape(siteMetadata.description)}</description>
 | 
			
		||||
      <language>${siteMetadata.language}</language>
 | 
			
		||||
      <managingEditor>${siteMetadata.email} (${siteMetadata.author})</managingEditor>
 | 
			
		||||
      <webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster>
 | 
			
		||||
      <lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate>
 | 
			
		||||
      <atom:link href="${siteMetadata.siteUrl}/${page}" rel="self" type="application/rss+xml"/>
 | 
			
		||||
      ${posts.map(generateRssItem).join('')}
 | 
			
		||||
    </channel>
 | 
			
		||||
  </rss>
 | 
			
		||||
`
 | 
			
		||||
export default generateRss
 | 
			
		||||
							
								
								
									
										136
									
								
								lib/mdx.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,136 @@
 | 
			
		||||
import { bundleMDX } from 'mdx-bundler'
 | 
			
		||||
import fs from 'fs'
 | 
			
		||||
import matter from 'gray-matter'
 | 
			
		||||
import path from 'path'
 | 
			
		||||
import readingTime from 'reading-time'
 | 
			
		||||
import { visit } from 'unist-util-visit'
 | 
			
		||||
import getAllFilesRecursively from './utils/files'
 | 
			
		||||
// Remark packages
 | 
			
		||||
import remarkGfm from 'remark-gfm'
 | 
			
		||||
import remarkFootnotes from 'remark-footnotes'
 | 
			
		||||
import remarkMath from 'remark-math'
 | 
			
		||||
import remarkExtractFrontmatter from './remark-extract-frontmatter'
 | 
			
		||||
import remarkCodeTitles from './remark-code-title'
 | 
			
		||||
import remarkTocHeadings from './remark-toc-headings'
 | 
			
		||||
import remarkImgToJsx from './remark-img-to-jsx'
 | 
			
		||||
// Rehype packages
 | 
			
		||||
import rehypeSlug from 'rehype-slug'
 | 
			
		||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
 | 
			
		||||
import rehypeKatex from 'rehype-katex'
 | 
			
		||||
import rehypeCitation from 'rehype-citation'
 | 
			
		||||
import rehypePrismPlus from 'rehype-prism-plus'
 | 
			
		||||
import rehypePresetMinify from 'rehype-preset-minify'
 | 
			
		||||
 | 
			
		||||
const root = process.cwd()
 | 
			
		||||
 | 
			
		||||
export function getFiles(type) {
 | 
			
		||||
  const prefixPaths = path.join(root, 'data', type)
 | 
			
		||||
  const files = getAllFilesRecursively(prefixPaths)
 | 
			
		||||
  // Only want to return workshop/path and ignore root, replace is needed to work on Windows
 | 
			
		||||
  return files.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/'))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function formatSlug(slug) {
 | 
			
		||||
  return slug.replace(/\.(mdx|md)/, '')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function dateSortAsc(a, b) {
 | 
			
		||||
  if (a < b) return -1
 | 
			
		||||
  if (a > b) return 1
 | 
			
		||||
  return 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getFileBySlug(type, slug) {
 | 
			
		||||
  const mdxPath = path.join(root, 'data', type, `${slug}.mdx`)
 | 
			
		||||
  const mdPath = path.join(root, 'data', type, `${slug}.md`)
 | 
			
		||||
  const source = fs.existsSync(mdxPath)
 | 
			
		||||
    ? fs.readFileSync(mdxPath, 'utf8')
 | 
			
		||||
    : fs.readFileSync(mdPath, 'utf8')
 | 
			
		||||
 | 
			
		||||
  // https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
 | 
			
		||||
  if (process.platform === 'win32') {
 | 
			
		||||
    process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'esbuild.exe')
 | 
			
		||||
  } else {
 | 
			
		||||
    process.env.ESBUILD_BINARY_PATH = path.join(root, 'node_modules', 'esbuild', 'bin', 'esbuild')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let toc = []
 | 
			
		||||
 | 
			
		||||
  const { code, frontmatter } = await bundleMDX({
 | 
			
		||||
    source,
 | 
			
		||||
    // mdx imports can be automatically source from the components directory
 | 
			
		||||
    cwd: path.join(root, 'components'),
 | 
			
		||||
    xdmOptions(options, frontmatter) {
 | 
			
		||||
      // this is the recommended way to add custom remark/rehype plugins:
 | 
			
		||||
      // The syntax might look weird, but it protects you in case we add/remove
 | 
			
		||||
      // plugins in the future.
 | 
			
		||||
      options.remarkPlugins = [
 | 
			
		||||
        ...(options.remarkPlugins ?? []),
 | 
			
		||||
        remarkExtractFrontmatter,
 | 
			
		||||
        [remarkTocHeadings, { exportRef: toc }],
 | 
			
		||||
        remarkGfm,
 | 
			
		||||
        remarkCodeTitles,
 | 
			
		||||
        [remarkFootnotes, { inlineNotes: true }],
 | 
			
		||||
        remarkMath,
 | 
			
		||||
        remarkImgToJsx,
 | 
			
		||||
      ]
 | 
			
		||||
      options.rehypePlugins = [
 | 
			
		||||
        ...(options.rehypePlugins ?? []),
 | 
			
		||||
        rehypeSlug,
 | 
			
		||||
        rehypeAutolinkHeadings,
 | 
			
		||||
        rehypeKatex,
 | 
			
		||||
        [rehypeCitation, { path: path.join(root, 'data') }],
 | 
			
		||||
        [rehypePrismPlus, { ignoreMissing: true }],
 | 
			
		||||
        rehypePresetMinify,
 | 
			
		||||
      ]
 | 
			
		||||
      return options
 | 
			
		||||
    },
 | 
			
		||||
    esbuildOptions: (options) => {
 | 
			
		||||
      options.loader = {
 | 
			
		||||
        ...options.loader,
 | 
			
		||||
        '.js': 'jsx',
 | 
			
		||||
      }
 | 
			
		||||
      return options
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    mdxSource: code,
 | 
			
		||||
    toc,
 | 
			
		||||
    frontMatter: {
 | 
			
		||||
      readingTime: readingTime(code),
 | 
			
		||||
      slug: slug || null,
 | 
			
		||||
      fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`,
 | 
			
		||||
      ...frontmatter,
 | 
			
		||||
      date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getAllFilesFrontMatter(folder) {
 | 
			
		||||
  const prefixPaths = path.join(root, 'data', folder)
 | 
			
		||||
 | 
			
		||||
  const files = getAllFilesRecursively(prefixPaths)
 | 
			
		||||
 | 
			
		||||
  const allFrontMatter = []
 | 
			
		||||
 | 
			
		||||
  files.forEach((file) => {
 | 
			
		||||
    // Replace is needed to work on Windows
 | 
			
		||||
    const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
 | 
			
		||||
    // Remove Unexpected File
 | 
			
		||||
    if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
    const source = fs.readFileSync(file, 'utf8')
 | 
			
		||||
    const { data: frontmatter } = matter(source)
 | 
			
		||||
    if (frontmatter.draft !== true) {
 | 
			
		||||
      allFrontMatter.push({
 | 
			
		||||
        ...frontmatter,
 | 
			
		||||
        slug: formatSlug(fileName),
 | 
			
		||||
        date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return allFrontMatter.sort((a, b) => dateSortAsc(a.date, b.date))
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										32
									
								
								lib/remark-code-title.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,32 @@
 | 
			
		||||
import { visit } from 'unist-util-visit'
 | 
			
		||||
 | 
			
		||||
export default function remarkCodeTitles() {
 | 
			
		||||
  return (tree) =>
 | 
			
		||||
    visit(tree, 'code', (node, index) => {
 | 
			
		||||
      const nodeLang = node.lang || ''
 | 
			
		||||
      let language = ''
 | 
			
		||||
      let title = ''
 | 
			
		||||
 | 
			
		||||
      if (nodeLang.includes(':')) {
 | 
			
		||||
        language = nodeLang.slice(0, nodeLang.search(':'))
 | 
			
		||||
        title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!title) {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const className = 'remark-code-title'
 | 
			
		||||
 | 
			
		||||
      const titleNode = {
 | 
			
		||||
        type: 'mdxJsxFlowElement',
 | 
			
		||||
        name: 'div',
 | 
			
		||||
        attributes: [{ type: 'mdxJsxAttribute', name: 'className', value: className }],
 | 
			
		||||
        children: [{ type: 'text', value: title }],
 | 
			
		||||
        data: { _xdmExplicitJsx: true },
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      tree.children.splice(index, 0, titleNode)
 | 
			
		||||
      node.lang = language
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								lib/remark-extract-frontmatter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,10 @@
 | 
			
		||||
import { visit } from 'unist-util-visit'
 | 
			
		||||
import { load } from 'js-yaml'
 | 
			
		||||
 | 
			
		||||
export default function extractFrontmatter() {
 | 
			
		||||
  return (tree, file) => {
 | 
			
		||||
    visit(tree, 'yaml', (node, index, parent) => {
 | 
			
		||||
      file.data.frontmatter = load(node.value)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								lib/remark-img-to-jsx.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,35 @@
 | 
			
		||||
import { visit } from 'unist-util-visit'
 | 
			
		||||
import sizeOf from 'image-size'
 | 
			
		||||
import fs from 'fs'
 | 
			
		||||
 | 
			
		||||
export default function remarkImgToJsx() {
 | 
			
		||||
  return (tree) => {
 | 
			
		||||
    visit(
 | 
			
		||||
      tree,
 | 
			
		||||
      // only visit p tags that contain an img element
 | 
			
		||||
      (node) => node.type === 'paragraph' && node.children.some((n) => n.type === 'image'),
 | 
			
		||||
      (node) => {
 | 
			
		||||
        const imageNode = node.children.find((n) => n.type === 'image')
 | 
			
		||||
 | 
			
		||||
        // only local files
 | 
			
		||||
        if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
 | 
			
		||||
          const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`)
 | 
			
		||||
 | 
			
		||||
          // Convert original node to next/image
 | 
			
		||||
          ;(imageNode.type = 'mdxJsxFlowElement'),
 | 
			
		||||
            (imageNode.name = 'Image'),
 | 
			
		||||
            (imageNode.attributes = [
 | 
			
		||||
              { type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
 | 
			
		||||
              { type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
 | 
			
		||||
              { type: 'mdxJsxAttribute', name: 'width', value: dimensions.width },
 | 
			
		||||
              { type: 'mdxJsxAttribute', name: 'height', value: dimensions.height },
 | 
			
		||||
            ])
 | 
			
		||||
 | 
			
		||||
          // Change node type from p to div to avoid nesting error
 | 
			
		||||
          node.type = 'div'
 | 
			
		||||
          node.children = [imageNode]
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								lib/remark-toc-headings.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,15 @@
 | 
			
		||||
import { visit } from 'unist-util-visit'
 | 
			
		||||
import { slug } from 'github-slugger'
 | 
			
		||||
import { toString } from 'mdast-util-to-string'
 | 
			
		||||
 | 
			
		||||
export default function remarkTocHeadings(options) {
 | 
			
		||||
  return (tree) =>
 | 
			
		||||
    visit(tree, 'heading', (node, index, parent) => {
 | 
			
		||||
      const textContent = toString(node)
 | 
			
		||||
      options.exportRef.push({
 | 
			
		||||
        value: textContent,
 | 
			
		||||
        url: '#' + slug(textContent),
 | 
			
		||||
        depth: node.depth,
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								lib/tags.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,30 @@
 | 
			
		||||
import fs from 'fs'
 | 
			
		||||
import matter from 'gray-matter'
 | 
			
		||||
import path from 'path'
 | 
			
		||||
import { getFiles } from './mdx'
 | 
			
		||||
import kebabCase from './utils/kebabCase'
 | 
			
		||||
 | 
			
		||||
const root = process.cwd()
 | 
			
		||||
 | 
			
		||||
export async function getAllTags(type) {
 | 
			
		||||
  const files = await getFiles(type)
 | 
			
		||||
 | 
			
		||||
  let tagCount = {}
 | 
			
		||||
  // Iterate through each post, putting all found tags into `tags`
 | 
			
		||||
  files.forEach((file) => {
 | 
			
		||||
    const source = fs.readFileSync(path.join(root, 'data', type, file), 'utf8')
 | 
			
		||||
    const { data } = matter(source)
 | 
			
		||||
    if (data.tags && data.draft !== true) {
 | 
			
		||||
      data.tags.forEach((tag) => {
 | 
			
		||||
        const formattedTag = kebabCase(tag)
 | 
			
		||||
        if (formattedTag in tagCount) {
 | 
			
		||||
          tagCount[formattedTag] += 1
 | 
			
		||||
        } else {
 | 
			
		||||
          tagCount[formattedTag] = 1
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return tagCount
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								lib/utils/files.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,20 @@
 | 
			
		||||
import fs from 'fs'
 | 
			
		||||
import path from 'path'
 | 
			
		||||
 | 
			
		||||
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x)
 | 
			
		||||
 | 
			
		||||
const flattenArray = (input) =>
 | 
			
		||||
  input.reduce((acc, item) => [...acc, ...(Array.isArray(item) ? item : [item])], [])
 | 
			
		||||
 | 
			
		||||
const map = (fn) => (input) => input.map(fn)
 | 
			
		||||
 | 
			
		||||
const walkDir = (fullPath) => {
 | 
			
		||||
  return fs.statSync(fullPath).isFile() ? fullPath : getAllFilesRecursively(fullPath)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const pathJoinPrefix = (prefix) => (extraPath) => path.join(prefix, extraPath)
 | 
			
		||||
 | 
			
		||||
const getAllFilesRecursively = (folder) =>
 | 
			
		||||
  pipe(fs.readdirSync, map(pipe(pathJoinPrefix(folder), walkDir)), flattenArray)(folder)
 | 
			
		||||
 | 
			
		||||
export default getAllFilesRecursively
 | 
			
		||||
							
								
								
									
										14
									
								
								lib/utils/formatDate.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,14 @@
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
 | 
			
		||||
const formatDate = (date) => {
 | 
			
		||||
  const options = {
 | 
			
		||||
    year: 'numeric',
 | 
			
		||||
    month: 'long',
 | 
			
		||||
    day: 'numeric',
 | 
			
		||||
  }
 | 
			
		||||
  const now = new Date(date).toLocaleDateString(siteMetadata.locale, options)
 | 
			
		||||
 | 
			
		||||
  return now
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default formatDate
 | 
			
		||||
							
								
								
									
										23
									
								
								lib/utils/htmlEscaper.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,23 @@
 | 
			
		||||
const { replace } = ''
 | 
			
		||||
 | 
			
		||||
// escape
 | 
			
		||||
const es = /&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34);/g
 | 
			
		||||
const ca = /[&<>'"]/g
 | 
			
		||||
 | 
			
		||||
const esca = {
 | 
			
		||||
  '&': '&',
 | 
			
		||||
  '<': '<',
 | 
			
		||||
  '>': '>',
 | 
			
		||||
  "'": ''',
 | 
			
		||||
  '"': '"',
 | 
			
		||||
}
 | 
			
		||||
const pe = (m) => esca[m]
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Safely escape HTML entities such as `&`, `<`, `>`, `"`, and `'`.
 | 
			
		||||
 * @param {string} es the input to safely escape
 | 
			
		||||
 * @returns {string} the escaped input, and it **throws** an error if
 | 
			
		||||
 *  the input type is unexpected, except for boolean and numbers,
 | 
			
		||||
 *  converted as string.
 | 
			
		||||
 */
 | 
			
		||||
export const escape = (es) => replace.call(es, ca, pe)
 | 
			
		||||
							
								
								
									
										8
									
								
								lib/utils/kebabCase.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,8 @@
 | 
			
		||||
const kebabCase = (str) =>
 | 
			
		||||
  str &&
 | 
			
		||||
  str
 | 
			
		||||
    .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
 | 
			
		||||
    .map((x) => x.toLowerCase())
 | 
			
		||||
    .join('-')
 | 
			
		||||
 | 
			
		||||
export default kebabCase
 | 
			
		||||
							
								
								
									
										43
									
								
								next.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,43 @@
 | 
			
		||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
 | 
			
		||||
  enabled: process.env.ANALYZE === 'true',
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
module.exports = withBundleAnalyzer({
 | 
			
		||||
  reactStrictMode: true,
 | 
			
		||||
  pageExtensions: ['js', 'jsx', 'md', 'mdx'],
 | 
			
		||||
  eslint: {
 | 
			
		||||
    dirs: ['pages', 'components', 'lib', 'layouts', 'scripts'],
 | 
			
		||||
  },
 | 
			
		||||
  experimental: { esmExternals: true },
 | 
			
		||||
  webpack: (config, { dev, isServer }) => {
 | 
			
		||||
    config.module.rules.push({
 | 
			
		||||
      test: /\.(png|jpe?g|gif|mp4)$/i,
 | 
			
		||||
      use: [
 | 
			
		||||
        {
 | 
			
		||||
          loader: 'file-loader',
 | 
			
		||||
          options: {
 | 
			
		||||
            publicPath: '/_next',
 | 
			
		||||
            name: 'static/media/[name].[hash].[ext]',
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    config.module.rules.push({
 | 
			
		||||
      test: /\.svg$/,
 | 
			
		||||
      use: ['@svgr/webpack'],
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    if (!dev && !isServer) {
 | 
			
		||||
      // Replace React with Preact only in client production build
 | 
			
		||||
      Object.assign(config.resolve.alias, {
 | 
			
		||||
        react: 'preact/compat',
 | 
			
		||||
        'react-dom/test-utils': 'preact/test-utils',
 | 
			
		||||
        'react-dom': 'preact/compat',
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return config
 | 
			
		||||
  },
 | 
			
		||||
  output: "standalone"
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										23859
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										75
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,75 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "tssc-workshop",
 | 
			
		||||
  "version": "1.1.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "start": "next-remote-watch ./data",
 | 
			
		||||
    "dev": "next dev",
 | 
			
		||||
    "build": "next build",
 | 
			
		||||
    "serve": "next start",
 | 
			
		||||
    "analyze": "cross-env ANALYZE=true next build",
 | 
			
		||||
    "lint": "next lint --fix --dir pages --dir components --dir lib --dir layouts --dir scripts",
 | 
			
		||||
    "prepare": "husky install",
 | 
			
		||||
    "spell": "cspell data/workshop/*"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@fontsource/inter": "4.5.2",
 | 
			
		||||
    "@mailchimp/mailchimp_marketing": "^3.0.58",
 | 
			
		||||
    "@next/bundle-analyzer": "^12.1.4",
 | 
			
		||||
    "@tailwindcss/forms": "^0.4.0",
 | 
			
		||||
    "@tailwindcss/typography": "^0.5.0",
 | 
			
		||||
    "autoprefixer": "^10.4.0",
 | 
			
		||||
    "esbuild": "^0.13.13",
 | 
			
		||||
    "github-slugger": "^1.3.0",
 | 
			
		||||
    "gray-matter": "^4.0.2",
 | 
			
		||||
    "image-size": "1.0.0",
 | 
			
		||||
    "mdx-bundler": "^8.0.0",
 | 
			
		||||
    "next": "12.1.4",
 | 
			
		||||
    "next-themes": "^0.0.14",
 | 
			
		||||
    "postcss": "^8.4.5",
 | 
			
		||||
    "preact": "^10.6.2",
 | 
			
		||||
    "react": "17.0.2",
 | 
			
		||||
    "react-dom": "17.0.2",
 | 
			
		||||
    "react-medium-image-zoom": "^4.3.5",
 | 
			
		||||
    "reading-time": "1.3.0",
 | 
			
		||||
    "rehype-autolink-headings": "^6.1.0",
 | 
			
		||||
    "rehype-citation": "^0.4.0",
 | 
			
		||||
    "rehype-katex": "^6.0.2",
 | 
			
		||||
    "rehype-preset-minify": "6.0.0",
 | 
			
		||||
    "rehype-prism-plus": "^1.1.3",
 | 
			
		||||
    "rehype-slug": "^5.0.0",
 | 
			
		||||
    "remark-footnotes": "^4.0.1",
 | 
			
		||||
    "remark-gfm": "^3.0.1",
 | 
			
		||||
    "remark-math": "^5.1.1",
 | 
			
		||||
    "sharp": "^0.28.3",
 | 
			
		||||
    "tailwindcss": "^3.0.23",
 | 
			
		||||
    "unist-util-visit": "^4.0.0" 
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@svgr/webpack": "^6.1.2",
 | 
			
		||||
    "cross-env": "^7.0.3",
 | 
			
		||||
    "dedent": "^0.7.0",
 | 
			
		||||
    "eslint": "^7.29.0",
 | 
			
		||||
    "eslint-config-next": "12.1.4",
 | 
			
		||||
    "eslint-config-prettier": "^8.3.0",
 | 
			
		||||
    "eslint-plugin-prettier": "^3.3.1",
 | 
			
		||||
    "file-loader": "^6.0.0",
 | 
			
		||||
    "globby": "11.0.3",
 | 
			
		||||
    "husky": "^6.0.0",
 | 
			
		||||
    "inquirer": "^8.1.1",
 | 
			
		||||
    "lint-staged": "^11.0.0",
 | 
			
		||||
    "next-remote-watch": "^1.0.0",
 | 
			
		||||
    "prettier": "^2.5.1",
 | 
			
		||||
    "prettier-plugin-tailwindcss": "^0.1.4",
 | 
			
		||||
    "socket.io": "^4.4.0",
 | 
			
		||||
    "socket.io-client": "^4.4.0"
 | 
			
		||||
  },
 | 
			
		||||
  "lint-staged": {
 | 
			
		||||
    "*.+(js|jsx|ts|tsx)": [
 | 
			
		||||
      "eslint --fix"
 | 
			
		||||
    ],
 | 
			
		||||
    "*.+(js|jsx|ts|tsx|json|css|md|mdx)": [
 | 
			
		||||
      "prettier --write"
 | 
			
		||||
    ]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								pages/404.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,24 @@
 | 
			
		||||
import Link from '@/components/Link'
 | 
			
		||||
 | 
			
		||||
export default function FourZeroFour() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col items-start justify-start md:justify-center md:items-center md:flex-row md:space-x-6 md:mt-24">
 | 
			
		||||
      <div className="pt-6 pb-8 space-x-2 md:space-y-5">
 | 
			
		||||
        <h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:text-8xl md:leading-14 md:border-r-2 md:px-6">
 | 
			
		||||
          404
 | 
			
		||||
        </h1>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="max-w-md">
 | 
			
		||||
        <p className="mb-4 text-xl font-bold leading-normal md:text-2xl">
 | 
			
		||||
          Sorry we couldn't find this page.
 | 
			
		||||
        </p>
 | 
			
		||||
        <p className="mb-8">But dont worry, you can find plenty of other things on our homepage.</p>
 | 
			
		||||
        <Link href="/">
 | 
			
		||||
          <button className="inline px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg shadow focus:outline-none focus:shadow-outline-blue hover:bg-blue-700 dark:hover:bg-blue-500">
 | 
			
		||||
            Back to homepage
 | 
			
		||||
          </button>
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										31
									
								
								pages/_app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,31 @@
 | 
			
		||||
import '@/css/tailwind.css'
 | 
			
		||||
import '@/css/prism.css'
 | 
			
		||||
import 'katex/dist/katex.css'
 | 
			
		||||
 | 
			
		||||
import '@fontsource/inter/variable-full.css'
 | 
			
		||||
 | 
			
		||||
import { ThemeProvider } from 'next-themes'
 | 
			
		||||
import Head from 'next/head'
 | 
			
		||||
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import Analytics from '@/components/analytics'
 | 
			
		||||
import LayoutWrapper from '@/components/LayoutWrapper'
 | 
			
		||||
import { ClientReload } from '@/components/ClientReload'
 | 
			
		||||
 | 
			
		||||
const isDevelopment = process.env.NODE_ENV === 'development'
 | 
			
		||||
const isSocket = process.env.SOCKET
 | 
			
		||||
 | 
			
		||||
export default function App({ Component, pageProps }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <ThemeProvider attribute="class" defaultTheme={siteMetadata.theme}>
 | 
			
		||||
      <Head>
 | 
			
		||||
        <meta content="width=device-width, initial-scale=1" name="viewport" />
 | 
			
		||||
      </Head>
 | 
			
		||||
      {isDevelopment && isSocket && <ClientReload />}
 | 
			
		||||
      <Analytics />
 | 
			
		||||
      <LayoutWrapper>
 | 
			
		||||
        <Component {...pageProps} />
 | 
			
		||||
      </LayoutWrapper>
 | 
			
		||||
    </ThemeProvider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										38
									
								
								pages/_document.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,38 @@
 | 
			
		||||
import Document, { Html, Head, Main, NextScript } from 'next/document'
 | 
			
		||||
class MyDocument extends Document {
 | 
			
		||||
  render() {
 | 
			
		||||
    return (
 | 
			
		||||
      <Html lang="en">
 | 
			
		||||
        <Head>
 | 
			
		||||
          <link rel="apple-touch-icon" sizes="180x180" href="/static/favicons/apple-touch-icon.png?v=2" />
 | 
			
		||||
          <link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png?v=2" />
 | 
			
		||||
          <link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png?v=2" />
 | 
			
		||||
          <link rel="manifest" href="/static/favicons/site.webmanifest?v=2" />
 | 
			
		||||
          <link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg?v=2" color="#5bbad5" />
 | 
			
		||||
          <link rel="shortcut icon" href="/static/favicons/favicon.ico?v=2" />
 | 
			
		||||
          <meta name="msapplication-TileColor" content="#2b5797" />
 | 
			
		||||
          <meta name="msapplication-config" content="/static/favicons/browserconfig.xml?v=2" />
 | 
			
		||||
          <meta name="theme-color" content="#ffffff" />
 | 
			
		||||
          <link rel="alternate" type="application/rss+xml" href="/feed.xml" />
 | 
			
		||||
          <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
 | 
			
		||||
          <link
 | 
			
		||||
            href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
 | 
			
		||||
            rel="stylesheet"
 | 
			
		||||
          />
 | 
			
		||||
          <link
 | 
			
		||||
            rel="stylesheet"
 | 
			
		||||
            href="https://cdn.jsdelivr.net/npm/katex@0.13.11/dist/katex.min.css"
 | 
			
		||||
            integrity="sha384-Um5gpz1odJg5Z4HAmzPtgZKdTBHZdw8S29IecapCSB31ligYPhHQZMIlWLYQGVoc"
 | 
			
		||||
            crossOrigin="anonymous"
 | 
			
		||||
          />
 | 
			
		||||
        </Head>
 | 
			
		||||
        <body className="antialiased text-black bg-white dark:bg-gray-900 dark:text-white">
 | 
			
		||||
          <Main />
 | 
			
		||||
          <NextScript />
 | 
			
		||||
        </body>
 | 
			
		||||
      </Html>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default MyDocument
 | 
			
		||||
							
								
								
									
										21
									
								
								pages/about.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,21 @@
 | 
			
		||||
import { MDXLayoutRenderer } from '@/components/MDXComponents'
 | 
			
		||||
import { getFileBySlug } from '@/lib/mdx'
 | 
			
		||||
 | 
			
		||||
const DEFAULT_LAYOUT = 'AuthorLayout'
 | 
			
		||||
 | 
			
		||||
export async function getStaticProps() {
 | 
			
		||||
  const authorDetails = await getFileBySlug('authors', ['default'])
 | 
			
		||||
  return { props: { authorDetails } }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function About({ authorDetails }) {
 | 
			
		||||
  const { mdxSource, frontMatter } = authorDetails
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <MDXLayoutRenderer
 | 
			
		||||
      layout={frontMatter.layout || DEFAULT_LAYOUT}
 | 
			
		||||
      mdxSource={mdxSource}
 | 
			
		||||
      frontMatter={frontMatter}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								pages/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,91 @@
 | 
			
		||||
import Link from '@/components/Link'
 | 
			
		||||
import { PageSEO } from '@/components/SEO'
 | 
			
		||||
import Tag from '@/components/Tag'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
 | 
			
		||||
import formatDate from '@/lib/utils/formatDate'
 | 
			
		||||
 | 
			
		||||
const MAX_DISPLAY = 5
 | 
			
		||||
 | 
			
		||||
export async function getStaticProps() {
 | 
			
		||||
  const posts = await getAllFilesFrontMatter('workshop')
 | 
			
		||||
 | 
			
		||||
  return { props: { posts } }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Home({ posts }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PageSEO title={siteMetadata.title} description={siteMetadata.description} />
 | 
			
		||||
      <div className="divide-y divide-gray-200 dark:divide-gray-700">
 | 
			
		||||
        <div className="pt-6 pb-8 space-y-2 md:space-y-5">
 | 
			
		||||
          <p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
 | 
			
		||||
            {siteMetadata.description}
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <ul className="divide-y divide-gray-200 dark:divide-gray-700">
 | 
			
		||||
          {!posts.length && 'No posts found.'}
 | 
			
		||||
          {posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
 | 
			
		||||
            const { slug, date, title, summary, tags, exercise } = frontMatter
 | 
			
		||||
            return (
 | 
			
		||||
              <li key={slug} className="py-12">
 | 
			
		||||
                <article>
 | 
			
		||||
                  <div className="space-y-2 xl:grid xl:grid-cols-4 xl:space-y-0 xl:items-baseline">
 | 
			
		||||
                    <dl>
 | 
			
		||||
                      <dt className="sr-only">Published on</dt>
 | 
			
		||||
                      <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
 | 
			
		||||
                        <div>Exercise {exercise}</div>
 | 
			
		||||
                      </dd>
 | 
			
		||||
                    </dl>
 | 
			
		||||
                    <div className="space-y-5 xl:col-span-3">
 | 
			
		||||
                      <div className="space-y-6">
 | 
			
		||||
                        <div>
 | 
			
		||||
                          <h2 className="text-2xl font-bold leading-8 tracking-tight">
 | 
			
		||||
                            <Link
 | 
			
		||||
                              href={`/workshop/${slug}`}
 | 
			
		||||
                              className="text-gray-900 dark:text-gray-100"
 | 
			
		||||
                            >
 | 
			
		||||
                              {title}
 | 
			
		||||
                            </Link>
 | 
			
		||||
                          </h2>
 | 
			
		||||
                          <div className="flex flex-wrap">
 | 
			
		||||
                            {tags.map((tag) => (
 | 
			
		||||
                              <Tag key={tag} text={tag} />
 | 
			
		||||
                            ))}
 | 
			
		||||
                          </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div className="prose text-gray-500 max-w-none dark:text-gray-400">
 | 
			
		||||
                          {summary}
 | 
			
		||||
                        </div>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <div className="text-base font-medium leading-6">
 | 
			
		||||
                        <Link
 | 
			
		||||
                          href={`/workshop/${slug}`}
 | 
			
		||||
                          className="text-primary-800 dark:text-primary-700 hover:text-primary-900 dark:hover:text-primary-400"
 | 
			
		||||
                          aria-label={`Read "${title}"`}
 | 
			
		||||
                        >
 | 
			
		||||
                          Read more →
 | 
			
		||||
                        </Link>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </article>
 | 
			
		||||
              </li>
 | 
			
		||||
            )
 | 
			
		||||
          })}
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
      {posts.length > MAX_DISPLAY && (
 | 
			
		||||
        <div className="flex justify-end text-base font-medium leading-6">
 | 
			
		||||
          <Link
 | 
			
		||||
            href="/workshop"
 | 
			
		||||
            className="text-primary-800 dark:text-primary-700 hover:text-primary-900 dark:hover:text-primary-400"
 | 
			
		||||
            aria-label="all posts"
 | 
			
		||||
          >
 | 
			
		||||
            All Posts →
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								pages/projects.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,35 @@
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import projectsData from '@/data/projectsData'
 | 
			
		||||
import Card from '@/components/Card'
 | 
			
		||||
import { PageSEO } from '@/components/SEO'
 | 
			
		||||
 | 
			
		||||
export default function Projects() {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PageSEO title={`Projects - ${siteMetadata.author}`} description={siteMetadata.description} />
 | 
			
		||||
      <div className="divide-y divide-gray-200 dark:divide-gray-700">
 | 
			
		||||
        <div className="pt-6 pb-8 space-y-2 md:space-y-5">
 | 
			
		||||
          <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
 | 
			
		||||
            Projects
 | 
			
		||||
          </h1>
 | 
			
		||||
          <p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
 | 
			
		||||
            Showcase your projects with a hero image (16 x 9)
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="container py-12">
 | 
			
		||||
          <div className="flex flex-wrap -m-4">
 | 
			
		||||
            {projectsData.map((d) => (
 | 
			
		||||
              <Card
 | 
			
		||||
                key={d.title}
 | 
			
		||||
                title={d.title}
 | 
			
		||||
                description={d.description}
 | 
			
		||||
                imgSrc={d.imgSrc}
 | 
			
		||||
                href={d.href}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										44
									
								
								pages/tags.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,44 @@
 | 
			
		||||
import Link from '@/components/Link'
 | 
			
		||||
import { PageSEO } from '@/components/SEO'
 | 
			
		||||
import Tag from '@/components/Tag'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import { getAllTags } from '@/lib/tags'
 | 
			
		||||
import kebabCase from '@/lib/utils/kebabCase'
 | 
			
		||||
 | 
			
		||||
export async function getStaticProps() {
 | 
			
		||||
  const tags = await getAllTags('workshop')
 | 
			
		||||
 | 
			
		||||
  return { props: { tags } }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Tags({ tags }) {
 | 
			
		||||
  const sortedTags = Object.keys(tags).sort((a, b) => tags[b] - tags[a])
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PageSEO title={`Tags - ${siteMetadata.author}`} description="Things I write about" />
 | 
			
		||||
      <div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:justify-center md:items-center md:divide-y-0 md:flex-row md:space-x-6 md:mt-24">
 | 
			
		||||
        <div className="pt-6 pb-8 space-x-2 md:space-y-5">
 | 
			
		||||
          <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14 md:border-r-2 md:px-6">
 | 
			
		||||
            Tags
 | 
			
		||||
          </h1>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="flex flex-wrap max-w-lg">
 | 
			
		||||
          {Object.keys(tags).length === 0 && 'No tags found.'}
 | 
			
		||||
          {sortedTags.map((t) => {
 | 
			
		||||
            return (
 | 
			
		||||
              <div key={t} className="mt-2 mb-2 mr-5">
 | 
			
		||||
                <Tag text={t} />
 | 
			
		||||
                <Link
 | 
			
		||||
                  href={`/tags/${kebabCase(t)}`}
 | 
			
		||||
                  className="-ml-2 text-sm font-semibold text-gray-600 uppercase dark:text-gray-300"
 | 
			
		||||
                >
 | 
			
		||||
                  {` (${tags[t]})`}
 | 
			
		||||
                </Link>
 | 
			
		||||
              </div>
 | 
			
		||||
            )
 | 
			
		||||
          })}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										53
									
								
								pages/tags/[tag].js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,53 @@
 | 
			
		||||
import { TagSEO } from '@/components/SEO'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import ListLayout from '@/layouts/ListLayout'
 | 
			
		||||
import generateRss from '@/lib/generate-rss'
 | 
			
		||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
 | 
			
		||||
import { getAllTags } from '@/lib/tags'
 | 
			
		||||
import kebabCase from '@/lib/utils/kebabCase'
 | 
			
		||||
import fs from 'fs'
 | 
			
		||||
import path from 'path'
 | 
			
		||||
 | 
			
		||||
const root = process.cwd()
 | 
			
		||||
 | 
			
		||||
export async function getStaticPaths() {
 | 
			
		||||
  const tags = await getAllTags('workshop')
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    paths: Object.keys(tags).map((tag) => ({
 | 
			
		||||
      params: {
 | 
			
		||||
        tag,
 | 
			
		||||
      },
 | 
			
		||||
    })),
 | 
			
		||||
    fallback: false,
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getStaticProps({ params }) {
 | 
			
		||||
  const allPosts = await getAllFilesFrontMatter('workshop')
 | 
			
		||||
  const filteredPosts = allPosts.filter(
 | 
			
		||||
    (post) => post.draft !== true && post.tags.map((t) => kebabCase(t)).includes(params.tag)
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  // rss
 | 
			
		||||
  const rss = generateRss(filteredPosts, `tags/${params.tag}/feed.xml`)
 | 
			
		||||
  const rssPath = path.join(root, 'public', 'tags', params.tag)
 | 
			
		||||
  fs.mkdirSync(rssPath, { recursive: true })
 | 
			
		||||
  fs.writeFileSync(path.join(rssPath, 'feed.xml'), rss)
 | 
			
		||||
 | 
			
		||||
  return { props: { posts: filteredPosts, tag: params.tag } }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Tag({ posts, tag }) {
 | 
			
		||||
  // Capitalize first letter and convert space to dash
 | 
			
		||||
  const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1)
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <TagSEO
 | 
			
		||||
        title={`${tag} - ${siteMetadata.author}`}
 | 
			
		||||
        description={`${tag} tags - ${siteMetadata.author}`}
 | 
			
		||||
      />
 | 
			
		||||
      <ListLayout posts={posts} title={title} />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										31
									
								
								pages/workshop.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,31 @@
 | 
			
		||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import ListLayout from '@/layouts/ListLayout'
 | 
			
		||||
import { PageSEO } from '@/components/SEO'
 | 
			
		||||
 | 
			
		||||
export const POSTS_PER_PAGE = 5
 | 
			
		||||
 | 
			
		||||
export async function getStaticProps() {
 | 
			
		||||
  const posts = await getAllFilesFrontMatter('workshop')
 | 
			
		||||
  const initialDisplayPosts = posts.slice(0, POSTS_PER_PAGE)
 | 
			
		||||
  const pagination = {
 | 
			
		||||
    currentPage: 1,
 | 
			
		||||
    totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { props: { initialDisplayPosts, posts, pagination } }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Workshop({ posts, initialDisplayPosts, pagination }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PageSEO title={`${siteMetadata.description}`}/>
 | 
			
		||||
      <ListLayout
 | 
			
		||||
        posts={posts}
 | 
			
		||||
        initialDisplayPosts={initialDisplayPosts}
 | 
			
		||||
        pagination={pagination}
 | 
			
		||||
        title="All exercises"
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										70
									
								
								pages/workshop/[...slug].js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,70 @@
 | 
			
		||||
import fs from 'fs'
 | 
			
		||||
import PageTitle from '@/components/PageTitle'
 | 
			
		||||
import generateRss from '@/lib/generate-rss'
 | 
			
		||||
import { MDXLayoutRenderer } from '@/components/MDXComponents'
 | 
			
		||||
import { formatSlug, getAllFilesFrontMatter, getFileBySlug, getFiles } from '@/lib/mdx'
 | 
			
		||||
 | 
			
		||||
const DEFAULT_LAYOUT = 'PostLayout'
 | 
			
		||||
 | 
			
		||||
export async function getStaticPaths() {
 | 
			
		||||
  const posts = getFiles('workshop')
 | 
			
		||||
  return {
 | 
			
		||||
    paths: posts.map((p) => ({
 | 
			
		||||
      params: {
 | 
			
		||||
        slug: formatSlug(p).split('/'),
 | 
			
		||||
      },
 | 
			
		||||
    })),
 | 
			
		||||
    fallback: false,
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getStaticProps({ params }) {
 | 
			
		||||
  const allPosts = await getAllFilesFrontMatter('workshop')
 | 
			
		||||
  const postIndex = allPosts.findIndex((post) => formatSlug(post.slug) === params.slug.join('/'))
 | 
			
		||||
  const prev = allPosts[postIndex - 1] || null
 | 
			
		||||
  const next = allPosts[postIndex + 1] || null
 | 
			
		||||
  const post = await getFileBySlug('workshop', params.slug.join('/'))
 | 
			
		||||
  const authorList = post.frontMatter.authors || ['default']
 | 
			
		||||
  const authorPromise = authorList.map(async (author) => {
 | 
			
		||||
    const authorResults = await getFileBySlug('authors', [author])
 | 
			
		||||
    return authorResults.frontMatter
 | 
			
		||||
  })
 | 
			
		||||
  const authorDetails = await Promise.all(authorPromise)
 | 
			
		||||
 | 
			
		||||
  // rss
 | 
			
		||||
  if (allPosts.length > 0) {
 | 
			
		||||
    const rss = generateRss(allPosts)
 | 
			
		||||
    fs.writeFileSync('./public/feed.xml', rss)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { props: { post, authorDetails, prev, next } }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Workshop({ post, authorDetails, prev, next }) {
 | 
			
		||||
  const { mdxSource, toc, frontMatter } = post
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {frontMatter.draft !== true ? (
 | 
			
		||||
        <MDXLayoutRenderer
 | 
			
		||||
          layout={frontMatter.layout || DEFAULT_LAYOUT}
 | 
			
		||||
          toc={toc}
 | 
			
		||||
          mdxSource={mdxSource}
 | 
			
		||||
          frontMatter={frontMatter}
 | 
			
		||||
          authorDetails={authorDetails}
 | 
			
		||||
          prev={prev}
 | 
			
		||||
          next={next}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div className="mt-24 text-center">
 | 
			
		||||
          <PageTitle>
 | 
			
		||||
            Under Construction{' '}
 | 
			
		||||
            <span role="img" aria-label="roadwork sign">
 | 
			
		||||
              🚧
 | 
			
		||||
            </span>
 | 
			
		||||
          </PageTitle>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										56
									
								
								pages/workshop/page/[page].js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,56 @@
 | 
			
		||||
import { PageSEO } from '@/components/SEO'
 | 
			
		||||
import siteMetadata from '@/data/siteMetadata'
 | 
			
		||||
import { getAllFilesFrontMatter } from '@/lib/mdx'
 | 
			
		||||
import ListLayout from '@/layouts/ListLayout'
 | 
			
		||||
import { POSTS_PER_PAGE } from '..'
 | 
			
		||||
 | 
			
		||||
export async function getStaticPaths() {
 | 
			
		||||
  const totalPosts = await getAllFilesFrontMatter('workshop')
 | 
			
		||||
  const totalPages = Math.ceil(totalPosts.length / POSTS_PER_PAGE)
 | 
			
		||||
  const paths = Array.from({ length: totalPages }, (_, i) => ({
 | 
			
		||||
    params: { page: (i + 1).toString() },
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    paths,
 | 
			
		||||
    fallback: false,
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function getStaticProps(context) {
 | 
			
		||||
  const {
 | 
			
		||||
    params: { page },
 | 
			
		||||
  } = context
 | 
			
		||||
  const posts = await getAllFilesFrontMatter('workshop')
 | 
			
		||||
  const pageNumber = parseInt(page)
 | 
			
		||||
  const initialDisplayPosts = posts.slice(
 | 
			
		||||
    POSTS_PER_PAGE * (pageNumber - 1),
 | 
			
		||||
    POSTS_PER_PAGE * pageNumber
 | 
			
		||||
  )
 | 
			
		||||
  const pagination = {
 | 
			
		||||
    currentPage: pageNumber,
 | 
			
		||||
    totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    props: {
 | 
			
		||||
      posts,
 | 
			
		||||
      initialDisplayPosts,
 | 
			
		||||
      pagination,
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function PostPage({ posts, initialDisplayPosts, pagination }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PageSEO title={siteMetadata.title} description={siteMetadata.description} />
 | 
			
		||||
      <ListLayout
 | 
			
		||||
        posts={posts}
 | 
			
		||||
        initialDisplayPosts={initialDisplayPosts}
 | 
			
		||||
        pagination={pagination}
 | 
			
		||||
        title="All workshops"
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								postcss.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,6 @@
 | 
			
		||||
module.exports = {
 | 
			
		||||
  plugins: {
 | 
			
		||||
    tailwindcss: {},
 | 
			
		||||
    autoprefixer: {},
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								prettier.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,9 @@
 | 
			
		||||
module.exports = {
 | 
			
		||||
  semi: false,
 | 
			
		||||
  singleQuote: true,
 | 
			
		||||
  printWidth: 100,
 | 
			
		||||
  tabWidth: 2,
 | 
			
		||||
  useTabs: false,
 | 
			
		||||
  trailingComma: 'es5',
 | 
			
		||||
  bracketSpacing: true,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										121
									
								
								scripts/compose.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,121 @@
 | 
			
		||||
const fs = require('fs')
 | 
			
		||||
const path = require('path')
 | 
			
		||||
const inquirer = require('inquirer')
 | 
			
		||||
const dedent = require('dedent')
 | 
			
		||||
 | 
			
		||||
const root = process.cwd()
 | 
			
		||||
 | 
			
		||||
const getAuthors = () => {
 | 
			
		||||
  const authorPath = path.join(root, 'data', 'authors')
 | 
			
		||||
  const authorList = fs.readdirSync(authorPath).map((filename) => path.parse(filename).name)
 | 
			
		||||
  return authorList
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getLayouts = () => {
 | 
			
		||||
  const layoutPath = path.join(root, 'layouts')
 | 
			
		||||
  const layoutList = fs
 | 
			
		||||
    .readdirSync(layoutPath)
 | 
			
		||||
    .map((filename) => path.parse(filename).name)
 | 
			
		||||
    .filter((file) => file.toLowerCase().includes('post'))
 | 
			
		||||
  return layoutList
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const genFrontMatter = (answers) => {
 | 
			
		||||
  let d = new Date()
 | 
			
		||||
  const date = [
 | 
			
		||||
    d.getFullYear(),
 | 
			
		||||
    ('0' + (d.getMonth() + 1)).slice(-2),
 | 
			
		||||
    ('0' + d.getDate()).slice(-2),
 | 
			
		||||
  ].join('-')
 | 
			
		||||
  const tagArray = answers.tags.split(',')
 | 
			
		||||
  tagArray.forEach((tag, index) => (tagArray[index] = tag.trim()))
 | 
			
		||||
  const tags = "'" + tagArray.join("','") + "'"
 | 
			
		||||
  const authorArray = answers.authors.length > 0 ? "'" + answers.authors.join("','") + "'" : ''
 | 
			
		||||
 | 
			
		||||
  let frontMatter = dedent`---
 | 
			
		||||
  title: ${answers.title ? answers.title : 'Untitled'}
 | 
			
		||||
  date: '${date}'
 | 
			
		||||
  tags: [${answers.tags ? tags : ''}]
 | 
			
		||||
  draft: ${answers.draft === 'yes' ? true : false}
 | 
			
		||||
  summary: ${answers.summary ? answers.summary : ' '}
 | 
			
		||||
  images: []
 | 
			
		||||
  layout: ${answers.layout}
 | 
			
		||||
  `
 | 
			
		||||
 | 
			
		||||
  if (answers.authors.length > 0) {
 | 
			
		||||
    frontMatter = frontMatter + '\n' + `authors: [${authorArray}]`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  frontMatter = frontMatter + '\n---'
 | 
			
		||||
 | 
			
		||||
  return frontMatter
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
inquirer
 | 
			
		||||
  .prompt([
 | 
			
		||||
    {
 | 
			
		||||
      name: 'title',
 | 
			
		||||
      message: 'Enter post title:',
 | 
			
		||||
      type: 'input',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'extension',
 | 
			
		||||
      message: 'Choose post extension:',
 | 
			
		||||
      type: 'list',
 | 
			
		||||
      choices: ['mdx', 'md'],
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'authors',
 | 
			
		||||
      message: 'Choose authors:',
 | 
			
		||||
      type: 'checkbox',
 | 
			
		||||
      choices: getAuthors,
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'summary',
 | 
			
		||||
      message: 'Enter post summary:',
 | 
			
		||||
      type: 'input',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'draft',
 | 
			
		||||
      message: 'Set post as draft?',
 | 
			
		||||
      type: 'list',
 | 
			
		||||
      choices: ['yes', 'no'],
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'tags',
 | 
			
		||||
      message: 'Any Tags? Separate them with , or leave empty if no tags.',
 | 
			
		||||
      type: 'input',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'layout',
 | 
			
		||||
      message: 'Select layout',
 | 
			
		||||
      type: 'list',
 | 
			
		||||
      choices: getLayouts,
 | 
			
		||||
    },
 | 
			
		||||
  ])
 | 
			
		||||
  .then((answers) => {
 | 
			
		||||
    // Remove special characters and replace space with -
 | 
			
		||||
    const fileName = answers.title
 | 
			
		||||
      .toLowerCase()
 | 
			
		||||
      .replace(/[^a-zA-Z0-9 ]/g, '')
 | 
			
		||||
      .replace(/ /g, '-')
 | 
			
		||||
      .replace(/-+/g, '-')
 | 
			
		||||
    const frontMatter = genFrontMatter(answers)
 | 
			
		||||
    const filePath = `data/workshop/${fileName ? fileName : 'untitled'}.${
 | 
			
		||||
      answers.extension ? answers.extension : 'md'
 | 
			
		||||
    }`
 | 
			
		||||
    fs.writeFile(filePath, frontMatter, { flag: 'wx' }, (err) => {
 | 
			
		||||
      if (err) {
 | 
			
		||||
        throw err
 | 
			
		||||
      } else {
 | 
			
		||||
        console.log(`Blog post generated successfully at ${filePath}`)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
  .catch((error) => {
 | 
			
		||||
    if (error.isTtyError) {
 | 
			
		||||
      console.log("Prompt couldn't be rendered in the current environment")
 | 
			
		||||
    } else {
 | 
			
		||||
      console.log('Something went wrong, sorry!')
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
							
								
								
									
										51
									
								
								scripts/generate-sitemap.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,51 @@
 | 
			
		||||
const fs = require('fs')
 | 
			
		||||
const globby = require('globby')
 | 
			
		||||
const prettier = require('prettier')
 | 
			
		||||
const siteMetadata = require('../data/siteMetadata')
 | 
			
		||||
 | 
			
		||||
;(async () => {
 | 
			
		||||
  const prettierConfig = await prettier.resolveConfig('./.prettierrc.js')
 | 
			
		||||
  const pages = await globby([
 | 
			
		||||
    'pages/*.js',
 | 
			
		||||
    'data/workshop/**/*.mdx',
 | 
			
		||||
    'data/workshop/**/*.md',
 | 
			
		||||
    'public/tags/**/*.xml',
 | 
			
		||||
    '!pages/_*.js',
 | 
			
		||||
    '!pages/api',
 | 
			
		||||
  ])
 | 
			
		||||
 | 
			
		||||
  const sitemap = `
 | 
			
		||||
        <?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
        <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
 | 
			
		||||
            ${pages
 | 
			
		||||
              .map((page) => {
 | 
			
		||||
                const path = page
 | 
			
		||||
                  .replace('pages/', '/')
 | 
			
		||||
                  .replace('data/workshop', '/workshop')
 | 
			
		||||
                  .replace('public/', '/')
 | 
			
		||||
                  .replace('.js', '')
 | 
			
		||||
                  .replace('.mdx', '')
 | 
			
		||||
                  .replace('.md', '')
 | 
			
		||||
                  .replace('/feed.xml', '')
 | 
			
		||||
                const route = path === '/index' ? '' : path
 | 
			
		||||
                if (page === `pages/404.js` || page === `pages/workshop/[...slug].js`) {
 | 
			
		||||
                  return
 | 
			
		||||
                }
 | 
			
		||||
                return `
 | 
			
		||||
                        <url>
 | 
			
		||||
                            <loc>${siteMetadata.siteUrl}${route}</loc>
 | 
			
		||||
                        </url>
 | 
			
		||||
                    `
 | 
			
		||||
              })
 | 
			
		||||
              .join('')}
 | 
			
		||||
        </urlset>
 | 
			
		||||
    `
 | 
			
		||||
 | 
			
		||||
  const formatted = prettier.format(sitemap, {
 | 
			
		||||
    ...prettierConfig,
 | 
			
		||||
    parser: 'html',
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line no-sync
 | 
			
		||||
  fs.writeFileSync('public/sitemap.xml', formatted)
 | 
			
		||||
})()
 | 
			
		||||
							
								
								
									
										163
									
								
								tailwind.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,163 @@
 | 
			
		||||
const defaultTheme = require('tailwindcss/defaultTheme')
 | 
			
		||||
const colors = require('tailwindcss/colors')
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  mode: 'jit',
 | 
			
		||||
  purge: ['./pages/**/*.js', './components/**/*.js', './layouts/**/*.js', './lib/**/*.js'],
 | 
			
		||||
  darkMode: 'class',
 | 
			
		||||
  theme: {
 | 
			
		||||
    extend: {
 | 
			
		||||
      spacing: {
 | 
			
		||||
        '9/16': '56.25%',
 | 
			
		||||
      },
 | 
			
		||||
      lineHeight: {
 | 
			
		||||
        11: '2.75rem',
 | 
			
		||||
        12: '3rem',
 | 
			
		||||
        13: '3.25rem',
 | 
			
		||||
        14: '3.5rem',
 | 
			
		||||
      },
 | 
			
		||||
      fontFamily: {
 | 
			
		||||
        sans: ['Inter', ...defaultTheme.fontFamily.sans],
 | 
			
		||||
      },
 | 
			
		||||
      colors: {
 | 
			
		||||
        primary: colors.red,
 | 
			
		||||
        gray: colors.gray,
 | 
			
		||||
        code: {
 | 
			
		||||
          green: '#b5f4a5',
 | 
			
		||||
          yellow: '#ffe484',
 | 
			
		||||
          purple: '#d9a9ff',
 | 
			
		||||
          red: '#ff8383',
 | 
			
		||||
          blue: '#93ddfd',
 | 
			
		||||
          white: '#fff',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      typography: (theme) => ({
 | 
			
		||||
        DEFAULT: {
 | 
			
		||||
          css: {
 | 
			
		||||
            color: theme('colors.gray.800'),
 | 
			
		||||
            a: {
 | 
			
		||||
              color: theme('colors.primary.800'),
 | 
			
		||||
              '&:hover': {
 | 
			
		||||
                color: theme('colors.primary.900'),
 | 
			
		||||
              },
 | 
			
		||||
              code: { color: theme('colors.primary.400') },
 | 
			
		||||
            },
 | 
			
		||||
            h1: {
 | 
			
		||||
              fontWeight: '700',
 | 
			
		||||
              letterSpacing: theme('letterSpacing.tight'),
 | 
			
		||||
              color: theme('colors.gray.900'),
 | 
			
		||||
            },
 | 
			
		||||
            h2: {
 | 
			
		||||
              fontWeight: '700',
 | 
			
		||||
              letterSpacing: theme('letterSpacing.tight'),
 | 
			
		||||
              color: theme('colors.gray.900'),
 | 
			
		||||
            },
 | 
			
		||||
            h3: {
 | 
			
		||||
              fontWeight: '600',
 | 
			
		||||
              color: theme('colors.gray.900'),
 | 
			
		||||
            },
 | 
			
		||||
            'h4,h5,h6': {
 | 
			
		||||
              color: theme('colors.gray.900'),
 | 
			
		||||
            },
 | 
			
		||||
            code: {
 | 
			
		||||
              color: theme('colors.pink.500'),
 | 
			
		||||
              backgroundColor: theme('colors.gray.100'),
 | 
			
		||||
              paddingLeft: '4px',
 | 
			
		||||
              paddingRight: '4px',
 | 
			
		||||
              paddingTop: '2px',
 | 
			
		||||
              paddingBottom: '2px',
 | 
			
		||||
              borderRadius: '0.25rem',
 | 
			
		||||
            },
 | 
			
		||||
            'code:before': {
 | 
			
		||||
              content: 'none',
 | 
			
		||||
            },
 | 
			
		||||
            'code:after': {
 | 
			
		||||
              content: 'none',
 | 
			
		||||
            },
 | 
			
		||||
            details: {
 | 
			
		||||
              backgroundColor: theme('colors.gray.100'),
 | 
			
		||||
              paddingLeft: '4px',
 | 
			
		||||
              paddingRight: '4px',
 | 
			
		||||
              paddingTop: '2px',
 | 
			
		||||
              paddingBottom: '2px',
 | 
			
		||||
              borderRadius: '0.25rem',
 | 
			
		||||
            },
 | 
			
		||||
            hr: { borderColor: theme('colors.gray.200') },
 | 
			
		||||
            'ol li:before': {
 | 
			
		||||
              fontWeight: '600',
 | 
			
		||||
              color: theme('colors.gray.500'),
 | 
			
		||||
            },
 | 
			
		||||
            'ul li:before': {
 | 
			
		||||
              backgroundColor: theme('colors.gray.500'),
 | 
			
		||||
            },
 | 
			
		||||
            strong: { color: theme('colors.gray.600') },
 | 
			
		||||
            blockquote: {
 | 
			
		||||
              color: theme('colors.gray.900'),
 | 
			
		||||
              borderLeftColor: theme('colors.gray.200'),
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        dark: {
 | 
			
		||||
          css: {
 | 
			
		||||
            color: theme('colors.gray.300'),
 | 
			
		||||
            a: {
 | 
			
		||||
              color: theme('colors.primary.800'),
 | 
			
		||||
              '&:hover': {
 | 
			
		||||
                color: theme('colors.primary.400'),
 | 
			
		||||
              },
 | 
			
		||||
              code: { color: theme('colors.primary.400') },
 | 
			
		||||
            },
 | 
			
		||||
            h1: {
 | 
			
		||||
              fontWeight: '700',
 | 
			
		||||
              letterSpacing: theme('letterSpacing.tight'),
 | 
			
		||||
              color: theme('colors.gray.100'),
 | 
			
		||||
            },
 | 
			
		||||
            h2: {
 | 
			
		||||
              fontWeight: '700',
 | 
			
		||||
              letterSpacing: theme('letterSpacing.tight'),
 | 
			
		||||
              color: theme('colors.gray.100'),
 | 
			
		||||
            },
 | 
			
		||||
            h3: {
 | 
			
		||||
              fontWeight: '600',
 | 
			
		||||
              color: theme('colors.gray.100'),
 | 
			
		||||
            },
 | 
			
		||||
            'h4,h5,h6': {
 | 
			
		||||
              color: theme('colors.gray.100'),
 | 
			
		||||
            },
 | 
			
		||||
            code: {
 | 
			
		||||
              backgroundColor: theme('colors.gray.800'),
 | 
			
		||||
            },
 | 
			
		||||
            details: {
 | 
			
		||||
              backgroundColor: theme('colors.gray.800'),
 | 
			
		||||
            },
 | 
			
		||||
            hr: { borderColor: theme('colors.gray.700') },
 | 
			
		||||
            'ol li:before': {
 | 
			
		||||
              fontWeight: '600',
 | 
			
		||||
              color: theme('colors.gray.400'),
 | 
			
		||||
            },
 | 
			
		||||
            'ul li:before': {
 | 
			
		||||
              backgroundColor: theme('colors.gray.400'),
 | 
			
		||||
            },
 | 
			
		||||
            strong: { color: theme('colors.gray.100') },
 | 
			
		||||
            thead: {
 | 
			
		||||
              color: theme('colors.gray.100'),
 | 
			
		||||
            },
 | 
			
		||||
            tbody: {
 | 
			
		||||
              tr: {
 | 
			
		||||
                borderBottomColor: theme('colors.gray.700'),
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
            blockquote: {
 | 
			
		||||
              color: theme('colors.gray.100'),
 | 
			
		||||
              borderLeftColor: theme('colors.gray.700'),
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  variants: {
 | 
			
		||||
    typography: ['dark'],
 | 
			
		||||
  },
 | 
			
		||||
  plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
 | 
			
		||||
}
 | 
			
		||||