1
0
mirror of https://github.com/kremalicious/portfolio.git synced 2024-12-22 01:03:20 +01:00

refactor: handle content generation as prebuild step

This commit is contained in:
Matthias Kretschmann 2024-02-04 22:09:00 +00:00
parent 5a2f2478c9
commit a5cd4aebda
Signed by: m
GPG Key ID: 606EEEF3C479A91F
48 changed files with 356 additions and 354 deletions

1
.gitignore vendored
View File

@ -40,3 +40,4 @@ public/matomo.js
# public/favicon*
# public/apple-touch-icon*
# public/manifest*
generated

7
package-lock.json generated
View File

@ -39,7 +39,6 @@
"jest-environment-jsdom": "^29.7.0",
"js-yaml": "^4.1.0",
"ora": "^8.0.1",
"prepend": "^1.0.2",
"prettier": "^3.2.4",
"sharp": "^0.33.2",
"sharp-ico": "^0.1.5",
@ -12159,12 +12158,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/prepend": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/prepend/-/prepend-1.0.2.tgz",
"integrity": "sha512-ImzjnkCUZRATBOHwlpmxz0IY21r+3/bcK3OTcrrU0njZlTWh58tgD7L5dH3TiZcc8ShDU7JEa5wk/UufWF6p7w==",
"dev": true
},
"node_modules/prettier": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz",

View File

@ -8,19 +8,20 @@
"author": "Matthias Kretschmann <m@kretschmann.io>",
"type": "module",
"scripts": {
"start": "next",
"start": "npm run prebuild && next",
"build": "next build",
"preview": "npm run build && next start",
"export": "next export",
"preview": "next start",
"export": "npm run prebuild && next export",
"typecheck": "tsc",
"lint:js": "next lint",
"lint:css": "stylelint ./src/**/*.css",
"lint": "npm run lint:js && npm run lint:css",
"format": "prettier --write 'src/**/*.{ts,tsx,css}'",
"jest": "jest --coverage -c tests/jest.config.ts",
"test": "NODE_ENV=test npm run lint && npm run typecheck && npm run jest",
"test": "NODE_ENV=test npm run prebuild && npm run lint && npm run typecheck && npm run jest",
"new": "ts-node-esm ./scripts/new.ts",
"favicon": "ts-node-esm ./scripts/favicon.ts"
"favicon": "ts-node-esm ./scripts/favicon.ts",
"prebuild": "ts-node-esm ./scripts/prebuild.ts"
},
"dependencies": {
"@giphy/js-fetch-api": "^5.3.0",
@ -53,7 +54,6 @@
"jest-environment-jsdom": "^29.7.0",
"js-yaml": "^4.1.0",
"ora": "^8.0.1",
"prepend": "^1.0.2",
"prettier": "^3.2.4",
"sharp": "^0.33.2",
"sharp-ico": "^0.1.5",

View File

@ -0,0 +1,48 @@
// import { getProjectBySlug, getProjectImages } from '.'
// jest.setTimeout(20000)
// describe('lib/content', () => {
// test('getProjectSlugs', async () => {
// const slugs: string[] = getProjectSlugs()
// expect(slugs).toContain('ipixelpad')
// })
// test('getProjectBySlug', async () => {
// const project = await getProjectBySlug('ipixelpad')
// expect(project).toBeDefined()
// expect(project.images[0].src).toContain('ipixelpad')
// })
// test('getProjectBySlug returns early', async () => {
// const project = await getProjectBySlug('gibberish')
// expect(project).not.toBeDefined()
// })
// test('getProjectImages', async () => {
// const images = await getProjectImages('ipixelpad')
// expect(images).toBeDefined()
// expect(images[0].src).toContain('ipixelpad')
// // expect(images[0].blurDataURL).toBeDefined()
// expect(images[0].width).toBeDefined()
// expect(images[0].height).toBeDefined()
// expect(images[0].format).toBeDefined()
// })
// test('getAllProjects', async () => {
// const projects = await getAllProjects([
// 'title',
// 'description',
// 'slug',
// 'images',
// 'techstack',
// 'links'
// ])
// expect(projects).toBeDefined()
// })
// test('getAllProjects without fields', async () => {
// const projects = await getAllProjects()
// expect(projects).toBeDefined()
// })
// })

View File

@ -0,0 +1,32 @@
import fs from 'fs'
import yaml from 'js-yaml'
import ora from 'ora'
import { join } from 'path'
import type ProjectType from '../../src/types/project.js'
import { transformProject } from './transformProject.js'
const contentDirectory = join(process.cwd(), '_content')
const projectsOriginal = yaml.load(
fs.readFileSync(`${contentDirectory}/projects.yml`, 'utf8')
) as ProjectType[]
const projectsOutput = 'generated/projects.json'
export async function generateProjects(): Promise<void> {
const spinner = ora('Generating projects content...\n').start()
const slugs = projectsOriginal.map(({ slug }: { slug: string }) => slug)
try {
const projects: ProjectType[] = []
for (const slug of slugs) {
spinner.text = `Generating content for ${slug}...\n`
const project = await transformProject(projectsOriginal, slug)
if (project) projects.push(project)
}
fs.writeFileSync(projectsOutput, JSON.stringify(projects, null, 2))
spinner.succeed(`Projects content written to ${projectsOutput}\n`)
} catch (error: unknown) {
spinner.fail((error as Error).message)
}
}

36
scripts/content/images.ts Normal file
View File

@ -0,0 +1,36 @@
import type ImageType from '@/src/types/image'
import fs from 'fs'
import { join } from 'path'
import sharp from 'sharp'
import { rgbDataURL } from './rgbDataURL.js'
const imagesDirectory = join(process.cwd(), 'public', 'images')
export async function getProjectImages(slug: string) {
const allImages = fs.readdirSync(imagesDirectory, 'utf8')
const projectImages = allImages.filter((image) => image.includes(slug))
let images: ImageType[] = []
await Promise.all(
projectImages.map(async (image) => {
const file = `${imagesDirectory}/${image}`
const transformer = sharp(file)
const { width, height, format } = await transformer.metadata()
const { dominant } = await transformer.stats()
const blurDataURL = rgbDataURL(dominant.r, dominant.g, dominant.b)
const imageType: ImageType = {
width,
height,
format,
blurDataURL,
src: `/images/${image}`
}
images.push(imageType)
})
)
// Sort images by sequentially numbered name to be sure
images = images.sort((a, b) => a.src.localeCompare(b.src))
return images
}

View File

@ -0,0 +1,11 @@
// Pixel GIF code adapted from https://stackoverflow.com/a/33919020/266535
const keyStr =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
const triplet = (e1: number, e2: number, e3: number) =>
keyStr.charAt(e1 >> 2) +
keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
keyStr.charAt(e3 & 63)
export const rgbDataURL = (r: number, g: number, b: number) =>
`data:image/gif;base64,R0lGODlhAQABAPAA${triplet(0, r, g) + triplet(b, 255, 255)}/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`

View File

@ -0,0 +1,20 @@
import ProjectType from '../../src/types/project'
import { getProjectImages } from './images.js'
import { markdownToHtml } from './markdown.js'
export async function transformProject(
projectsOriginal: ProjectType[],
slug: string
) {
const project = projectsOriginal.find((item) => item.slug === slug)
if (!project) return
// enhance data with additional fields
const descriptionHtml = await markdownToHtml(project.description)
project.descriptionHtml = descriptionHtml
const images = await getProjectImages(slug)
project.images = images
return project
}

View File

@ -86,8 +86,8 @@ async function buildFavicons() {
Add this to src/components/Meta/Favicon.tsx:
${outputMeta}
`)
} catch (error) {
console.error(error.message)
} catch (error: unknown) {
console.error((error as Error).message)
}
}

View File

@ -1,10 +1,8 @@
#!/usr/bin/env ts-node
import fs from 'fs'
import path from 'path'
import prepend from 'prepend'
import slugify from 'slugify'
import ora from 'ora'
import path from 'path'
import slugify from 'slugify'
const templatePath = path.join(process.cwd(), 'scripts', 'new.yml')
const template = fs.readFileSync(templatePath).toString()
@ -26,7 +24,13 @@ const newContents = template
.split('SLUG')
.join(titleSlug)
prepend(projects, newContents, (error) => {
if (error) spinner.fail(error)
spinner.succeed(`Added '${title}' to top of projects.yml file.`)
// prepend newContents to projects.yml file
fs.readFile(projects, 'utf8', (error, data) => {
if (error) spinner.fail(error.message)
fs.writeFile(projects, newContents + data, (error) => {
if (error) spinner.fail(error.message)
spinner.succeed(`Added '${title}' to top of projects.yml file.`)
})
})

3
scripts/prebuild.ts Normal file
View File

@ -0,0 +1,3 @@
import { generateProjects } from './content/generateProjects.js'
generateProjects()

View File

@ -0,0 +1,6 @@
import projects from '@/generated/projects.json'
export function getAllSlugs() {
const slugs = projects.map(({ slug }: { slug: string }) => slug)
return slugs
}

View File

@ -0,0 +1,5 @@
import projects from '@/generated/projects.json'
export function getProjectBySlug(slug: string) {
return projects.find((item) => item.slug === slug)
}

View File

@ -1,26 +1,20 @@
import { Metadata, ResolvingMetadata } from 'next'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import meta from '../../../_content/meta.json'
import Header from '../../components/Header/Header'
import Project from '../../components/Project'
import ProjectNav from '../../components/ProjectNav'
import {
getAllProjects,
getProjectBySlug,
getProjectSlugs
} from '../../lib/content'
import meta from '@/_content/meta.json'
import projects from '@/generated/projects.json'
import Header from '@/src/components/Header/Header'
import Project from '@/src/components/Project'
import ProjectNav from '@/src/components/ProjectNav'
import { getAllSlugs } from './getAllSlugs'
import { getProjectBySlug } from './getProjectBySlug'
type Props = {
params: { slug: string }
// searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata(
{ params }: Props
// parent: ResolvingMetadata
): Promise<Metadata> {
const project = await getProjectBySlug(params.slug)
if (!project) return
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const project = getProjectBySlug(params.slug)
if (!project) return {}
return {
title: project.title,
@ -37,12 +31,10 @@ export async function generateMetadata(
}
export default async function ProjectPage({ params }: Props) {
const project = await getProjectBySlug(params.slug)
const project = getProjectBySlug(params.slug)
if (!project) notFound()
const projects = await getAllProjects(['slug', 'title', 'images'])
return (
<>
<Header />
@ -53,7 +45,6 @@ export default async function ProjectPage({ params }: Props) {
}
export async function generateStaticParams() {
const slugs = getProjectSlugs()
const slugs = getAllSlugs()
return slugs.map((slug) => ({ slug }))
}

View File

@ -1,13 +1,14 @@
import { render } from '@testing-library/react'
import meta from '../../../_content/meta.json'
import projectMock from '../../../tests/__fixtures__/project.json'
import projectsMock from '../../../tests/__fixtures__/projects.json'
import Page, { generateMetadata, generateStaticParams } from '../[slug]/page'
jest.mock('../../lib/content', () => ({
getAllProjects: jest.fn().mockImplementation(() => projectsMock),
getProjectBySlug: jest.fn().mockImplementation(() => projectMock),
getProjectSlugs: jest.fn().mockImplementation(() => ['slug1', 'slug2'])
jest.mock('../[slug]/getProjectBySlug', () => ({
getProjectBySlug: jest.fn().mockImplementation(() => projectMock)
}))
jest.mock('../[slug]/getAllSlugs', () => ({
getAllSlugs: jest.fn().mockImplementationOnce(() => ['slug1', 'slug2'])
}))
describe('app: [slug]/page', () => {

View File

@ -1,11 +1,15 @@
import { render, screen } from '@testing-library/react'
import { dataLocation } from '../../../tests/__fixtures__/location'
import Layout from '../layout'
describe('app: /layout', () => {
// suppress error "Warning: validateDOMNesting(...): <html> cannot appear as a child of <div>"
// https://github.com/testing-library/react-testing-library/issues/1250
let originalError
let originalError: {
(...data: any[]): void
(message?: any, ...optionalParams: any[]): void
(...data: any[]): void
(message?: any, ...optionalParams: any[]): void
}
beforeAll(() => {
originalError = console.error

View File

@ -1,16 +1,6 @@
import { render, screen } from '@testing-library/react'
import projectsMock from '../../../tests/__fixtures__/projects.json'
import reposMock from '../../../tests/__fixtures__/repos.json'
import Page from '../page'
jest.mock('../../lib/content', () => ({
getAllProjects: jest.fn().mockImplementationOnce(() => projectsMock)
}))
jest.mock('../../lib/github', () => ({
getGithubRepos: jest.fn().mockImplementationOnce(() => reposMock)
}))
describe('app: /page', () => {
it('renders correctly', async () => {
render(await Page())

View File

@ -3,6 +3,7 @@
import { cache } from 'react'
import { revalidatePath } from 'next/cache'
import { GiphyFetch } from '@giphy/js-fetch-api'
import { getGithubRepos } from '../lib/github'
export const preloadLocation = () => {
void getLocation()
@ -16,8 +17,17 @@ export const getLocation = cache(async () => {
const data = await response.json()
return data
} catch (error) {
console.error(error.message)
} catch (error: unknown) {
console.error((error as Error).message)
}
})
export const getRepos = cache(async () => {
try {
const repos = await getGithubRepos()
return repos
} catch (error: unknown) {
console.error((error as Error).message)
}
})
@ -29,8 +39,8 @@ export async function getRandomGif(tag: string, pathname?: string) {
const { data } = await giphyClient.random({ tag })
const gif = data.images.original.mp4
return gif
} catch (error) {
console.error(error.message)
} catch (error: unknown) {
console.error((error as Error).message)
}
if (pathname) revalidatePath(pathname)

View File

@ -1,13 +1,12 @@
import { Suspense } from 'react'
import projects from '../../generated/projects.json'
import Hero from '../components/Hero'
import Projects from '../components/Projects'
import Repositories from '../components/Repositories'
import { getAllProjects } from '../lib/content'
import { getGithubRepos } from '../lib/github'
import { preloadLocation } from './actions'
import { getRepos, preloadLocation } from './actions'
export default async function IndexPage() {
const projects = await getAllProjects(['title', 'images', 'slug'])
const repos = await getGithubRepos()
const repos = await getRepos()
preloadLocation()
@ -15,7 +14,9 @@ export default async function IndexPage() {
<>
<Hero />
<Projects projects={projects} />
<Repositories repos={repos} />
<Suspense fallback={<p>Loading open source projects...</p>}>
<Repositories repos={repos} />
</Suspense>
</>
)
}

View File

@ -2,6 +2,6 @@
import { ThemeProvider } from 'next-themes'
export function Providers({ children }) {
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider attribute="class">{children}</ThemeProvider>
}

View File

@ -1,9 +1,14 @@
import styles from './index.module.css'
const Button = ({ children, ...props }) => (
<a className={styles.button} {...props}>
{children}
</a>
)
declare type ButtonProps = React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>
export default Button
export default function Button({ children, ...props }: ButtonProps) {
return (
<a {...props} className={styles.button}>
{children}
</a>
)
}

View File

@ -7,7 +7,7 @@ type Props = {
allowedHosts: string[]
}
export async function generateMetadata({ params }) {
export async function generateMetadata({ params }: { params: Props }) {
const isAllowedHost = params.allowedHosts.includes(window.location.hostname)
if (!isAllowedHost) {

View File

@ -4,16 +4,16 @@ import Icon from '.'
describe('Icon', () => {
it('renders correctly', () => {
const { container, rerender } = render(<Icon name={'Compass'} />)
expect(container.firstChild.nodeName).toBe('svg')
expect(container.firstChild?.nodeName).toBe('svg')
rerender(<Icon name={'Download'} />)
expect(container.firstChild.nodeName).toBe('svg')
expect(container.firstChild?.nodeName).toBe('svg')
rerender(<Icon name={'Styleguide'} />)
expect(container.firstChild.nodeName).toBe('svg')
expect(container.firstChild?.nodeName).toBe('svg')
rerender(<Icon name={'Blog'} />)
expect(container.firstChild.nodeName).toBe('svg')
expect(container.firstChild?.nodeName).toBe('svg')
})
it('does not render with unknown name', () => {

View File

@ -1,13 +1,16 @@
'use client'
import { useEffect, useState } from 'react'
import { getLocation } from '@/src/app/actions'
import RelativeTime from '@yaireo/relative-time'
import { getLocation } from '../../app/actions'
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
import { fadeIn, getAnimationProps } from '../Transitions'
import { Flag } from './Flag'
import styles from './Location.module.css'
import { UseLocation } from './types'
export default function Location() {
const shouldReduceMotion = useReducedMotion()
const [location, setLocation] = useState<UseLocation | null>(null)
const isDifferentCountry = location?.now?.country !== location?.next?.country
@ -24,37 +27,40 @@ export default function Location() {
return (
<div className={styles.wrapper}>
{location?.now?.city ? (
<section
aria-label="Location"
className={styles.location}
style={{ opacity: 1 }}
>
<Flag
country={{
code: location.now.country_code,
name: location.now.country
}}
/>
{location.now.city} <span>Now</span>
<div className={styles.next}>
{location?.next?.city && (
<>
{isDifferentCountry && (
<Flag
country={{
code: location.next.country_code,
name: location.next.country
}}
/>
)}
{location.next.city}{' '}
<span>
{relativeTime.from(new Date(location.next.date_start))}
</span>
</>
)}
</div>
</section>
<LazyMotion features={domAnimation}>
<m.section
aria-label="Location"
variants={fadeIn}
className={styles.location}
{...getAnimationProps(shouldReduceMotion)}
>
<Flag
country={{
code: location.now.country_code,
name: location.now.country
}}
/>
{location.now.city} <span>Now</span>
<div className={styles.next}>
{location?.next?.city && (
<>
{isDifferentCountry && (
<Flag
country={{
code: location.next.country_code,
name: location.next.country
}}
/>
)}
{location.next.city}{' '}
<span>
{relativeTime.from(new Date(location.next.date_start))}
</span>
</>
)}
</div>
</m.section>
</LazyMotion>
) : null}
</div>
)

View File

@ -14,7 +14,6 @@ export default function LogoUnit({ small }: Props) {
<Link
className={`${styles.logounit} ${small ? styles.small : null}`}
href="/"
aria-current={!small ? 'page' : null}
>
<Logo className={styles.logo} />
<H className={`p-name ${styles.title}`}>

View File

@ -5,7 +5,7 @@ describe('Networks', () => {
it('renders correctly from data file values', () => {
const { container } = render(<Networks label="Networks" />)
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild.nodeName).toBe('SECTION')
expect(container.firstChild?.nodeName).toBe('SECTION')
})
it('renders correctly in small variant', () => {

View File

@ -22,7 +22,7 @@ const containerVariants = {
export default function Networks({ label, small }: Props) {
const shouldReduceMotion = useReducedMotion()
const animationProps = getAnimationProps(shouldReduceMotion)
const animationProps = getAnimationProps(shouldReduceMotion || false)
return (
<LazyMotion features={domAnimation}>

View File

@ -12,7 +12,6 @@ import styles from './index.module.css'
const containerVariants = {
enter: {
transition: {
delay: 0.3,
staggerChildren: 0.2
}
}
@ -25,7 +24,7 @@ export default function Project({
}) {
const { title, descriptionHtml, images, links, techstack } = project
const shouldReduceMotion = useReducedMotion()
const animationProps = getAnimationProps(shouldReduceMotion)
const animationProps = getAnimationProps(shouldReduceMotion || false)
return (
<article className={styles.project}>
@ -42,7 +41,7 @@ export default function Project({
<m.div
variants={moveInBottom}
className={styles.description}
dangerouslySetInnerHTML={{ __html: descriptionHtml }}
dangerouslySetInnerHTML={{ __html: descriptionHtml ?? '' }}
/>
</m.header>
</LazyMotion>
@ -54,8 +53,6 @@ export default function Project({
alt={`Showcase image no. ${i + 1} for ${title}`}
key={i}
sizes="100vw"
// give priority to the first image
priority={i === 0}
/>
))}

View File

@ -16,6 +16,8 @@ describe('ProjectImage', () => {
})
it('returns without errors without image', async () => {
render(<ProjectImage image={null} alt={project.title} sizes="100vw" />)
render(
<ProjectImage image={null as any} alt={project.title} sizes="100vw" />
)
})
})

View File

@ -1,24 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import Image from 'next/image'
import {
LazyMotion,
domAnimation,
m,
useAnimation,
useReducedMotion
} from 'framer-motion'
import ImageType from '../../types/image'
import { getAnimationProps } from '../Transitions'
import styles from './index.module.css'
const animationVariants = {
initial: { opacity: 0 },
enter: { opacity: 1 },
exit: { opacity: 0 }
}
export default function ProjectImage({
image,
alt,
@ -32,39 +15,20 @@ export default function ProjectImage({
className?: string
priority?: boolean
}) {
const [loaded, setLoaded] = useState(false)
const animationControls = useAnimation()
const shouldReduceMotion = useReducedMotion()
const animationProps = getAnimationProps(shouldReduceMotion)
useEffect(() => {
if (loaded && animationControls) {
animationControls.start('enter')
}
}, [loaded, animationControls])
return image ? (
<LazyMotion features={domAnimation}>
<m.figure
variants={animationVariants}
{...animationProps}
transition={{ ease: 'easeOut', duration: 1 }}
className={`${styles.imageWrap} ${className || null}`}
>
<Image
className={styles.image}
src={image.src}
alt={alt}
width={image.width}
height={image.height}
sizes={sizes}
quality={85}
priority={priority}
placeholder="empty"
// blurDataURL={image.blurDataURL}
onLoad={() => setLoaded(true)}
/>
</m.figure>
</LazyMotion>
<figure className={`${styles.imageWrap} ${className || null}`}>
<Image
className={styles.image}
src={image.src}
alt={alt}
width={image.width}
height={image.height}
sizes={sizes}
quality={85}
priority={priority}
placeholder="blur"
blurDataURL={image.blurDataURL}
/>
</figure>
) : null
}

View File

@ -6,7 +6,7 @@ import { Project } from './Project'
import styles from './index.module.css'
type Props = {
projects: Partial<ProjectType>[]
projects: ProjectType[]
currentSlug: string
}
@ -19,7 +19,7 @@ export default function ProjectNav({ projects, currentSlug }: Props) {
useEffect(() => {
function scrollToCurrent() {
const activeItem = currentItem.current
const scrollRect = scrollContainer.current.getBoundingClientRect()
const scrollRect = scrollContainer.current?.getBoundingClientRect()
const activeRect = activeItem && activeItem.getBoundingClientRect()
const newScrollLeftPosition =
activeRect &&

View File

@ -7,22 +7,15 @@ type Props = {
title: string
slug: string
image: ImageType
imagePriority: boolean
}
export default function ProjectPreview({
title,
slug,
image,
imagePriority
}: Props) {
export default function ProjectPreview({ title, slug, image }: Props) {
return (
<Link href={`/${slug}`} className={styles.project} key={slug}>
<ProjectImage
image={image}
alt={`Showcase image for ${title}`}
sizes="(max-width: 1090px) 100vw, 40vw"
priority={imagePriority}
/>
<footer className={styles.meta}>

View File

@ -3,7 +3,7 @@ import ProjectPreview from '../ProjectPreview'
import styles from './index.module.css'
type Props = {
projects: Partial<ProjectType>[]
projects: ProjectType[]
}
export default function Projects({ projects }: Props) {
@ -15,8 +15,6 @@ export default function Projects({ projects }: Props) {
key={project.slug}
title={project.title}
image={project.images[0]}
// give priority for the first 2 images
imagePriority={i == 0 || i === 1}
slug={project.slug}
/>
))}

View File

@ -8,9 +8,4 @@ describe('Repositories', () => {
const { container } = render(<Repositories repos={repos as Repo[]} />)
expect(container.firstChild).toBeInTheDocument()
})
it('return nothing when no repos are passed', () => {
const { container } = render(<Repositories repos={null} />)
expect(container.firstChild).not.toBeInTheDocument()
})
})

View File

@ -2,16 +2,12 @@ import Repo from '../../types/repo'
import Repository from '../Repository'
import styles from './index.module.css'
export default function Repositories({ repos }: { repos: Repo[] }) {
if (!repos) return null
export default function Repositories({ repos }: { repos: Repo[] | undefined }) {
return (
<>
<h2 className={styles.sectionTitle}>Open Source Projects</h2>
<div className={styles.repos}>
{repos.map((repo) => (
<Repository key={repo.name} repo={repo} />
))}
{repos?.map((repo) => <Repository key={repo.name} repo={repo} />)}
</div>
</>
)

View File

@ -17,7 +17,7 @@ describe('Repository', () => {
}
const { container } = render(<Repository repo={repo1 as Repo} />)
expect(container.querySelector('h3 > a').getAttribute('href')).toBe(
expect(container.querySelector('h3 > a')?.getAttribute('href')).toBe(
repo1.html_url
)
})

View File

@ -13,7 +13,7 @@ export function getIconName(theme: string) {
export default function ThemeSwitch() {
const { theme, themes, resolvedTheme, setTheme } = useTheme()
const iconName = getIconName(resolvedTheme)
const iconName = getIconName(resolvedTheme || '')
// hydration errors workaround
const [mounted, setMounted] = useState(false)

View File

@ -47,6 +47,12 @@ export const moveInBottom = {
}
}
export const fadeIn = {
initial: { opacity: 0 },
enter: { opacity: 1 },
exit: { opacity: 0 }
}
export function getAnimationProps(shouldReduceMotion: boolean) {
return {
initial: `${shouldReduceMotion ? 'enter' : 'initial'}`,

View File

@ -1,5 +1,6 @@
'use client'
import { MouseEvent } from 'react'
import meta from '../../../_content/meta.json'
export default function Vcard() {
@ -14,7 +15,7 @@ export default function Vcard() {
profiles: meta.profiles
}
const handleAddressbookClick = (e) => {
const handleAddressbookClick = (e: MouseEvent) => {
e.preventDefault()
import('./_utils').then(({ init }) => {

View File

@ -0,0 +1,19 @@
export declare type Meta = {
name?: string
label?: string
email?: string
profiles?: (
| { network: string; url: string; username?: undefined }
| { network: string; username: string; url: string }
)[]
description?: string
img?: string
url?: string
author?: { name: string; label: string; email: string; picture: string }
availability?: { status: boolean; available: string; unavailable: string }
gpg?: string
addressbook?: any
bugs?: string
allowedHosts?: string[]
photoSrc?: any
}

View File

@ -1,53 +0,0 @@
import {
getAllProjects,
getProjectBySlug,
getProjectImages,
getProjectSlugs
} from './content'
jest.setTimeout(20000)
describe('lib/content', () => {
test('getProjectSlugs', async () => {
const slugs: string[] = getProjectSlugs()
expect(slugs).toContain('ipixelpad')
})
test('getProjectBySlug', async () => {
const project = await getProjectBySlug('ipixelpad')
expect(project).toBeDefined()
expect(project.images[0].src).toContain('ipixelpad')
})
test('getProjectBySlug returns early', async () => {
const project = await getProjectBySlug('gibberish')
expect(project).not.toBeDefined()
})
test('getProjectImages', async () => {
const images = await getProjectImages('ipixelpad')
expect(images).toBeDefined()
expect(images[0].src).toContain('ipixelpad')
// expect(images[0].blurDataURL).toBeDefined()
expect(images[0].width).toBeDefined()
expect(images[0].height).toBeDefined()
expect(images[0].format).toBeDefined()
})
test('getAllProjects', async () => {
const projects = await getAllProjects([
'title',
'description',
'slug',
'images',
'techstack',
'links'
])
expect(projects).toBeDefined()
})
test('getAllProjects without fields', async () => {
const projects = await getAllProjects()
expect(projects).toBeDefined()
})
})

View File

@ -1,85 +0,0 @@
import fs from 'fs'
import yaml from 'js-yaml'
import { join } from 'path'
import sharp from 'sharp'
import type ImageType from '../types/image'
import type ProjectType from '../types/project'
import { markdownToHtml } from './markdown'
const contentDirectory = join(process.cwd(), '_content')
const imagesDirectory = join(process.cwd(), 'public', 'images')
const projects = yaml.load(
fs.readFileSync(`${contentDirectory}/projects.yml`, 'utf8')
) as Partial<ProjectType>[]
export function getProjectSlugs() {
return projects.map(({ slug }: { slug: string }) => slug)
}
// Pixel GIF code adapted from https://stackoverflow.com/a/33919020/266535
// const keyStr =
// 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
// const triplet = (e1: number, e2: number, e3: number) =>
// keyStr.charAt(e1 >> 2) +
// keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
// keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
// keyStr.charAt(e3 & 63)
// export const rgbDataURL = ({ r, g, b }: { r: number; g: number; b: number }) =>
// `data:image/gif;base64,R0lGODlhAQABAPAA${
// triplet(0, r, g) + triplet(b, 255, 255)
// }/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`
export async function getProjectImages(slug: string) {
const allImages = fs.readdirSync(imagesDirectory, 'utf8')
const projectImages = allImages.filter((image) => image.includes(slug))
let images: ImageType[] = []
await Promise.all(
projectImages.map(async (image) => {
const file = `${imagesDirectory}/${image}`
const transformer = sharp(file)
const { width, height, format } = await transformer.metadata()
// const { dominant } = await transformer.stats()
// const blurDataURL = rgbDataURL(dominant)
const imageType: ImageType = {
width,
height,
format,
// blurDataURL,
src: `/images/${image}`
}
images.push(imageType)
})
)
// Sort images by sequentially numbered name to be sure
images = images.sort((a, b) => a.src.localeCompare(b.src))
return images
}
export async function getProjectBySlug(slug: string, fields: string[] = []) {
const project = projects.find((item) => item.slug === slug)
if (!project) return
// enhance data with additional fields
const descriptionHtml = await markdownToHtml(project.description)
project.descriptionHtml = descriptionHtml
const images = await getProjectImages(slug)
project.images = images
return project
}
export async function getAllProjects(
fields: string[] = []
): Promise<Partial<ProjectType>[]> {
const slugs = getProjectSlugs()
const projects = await Promise.all(
slugs.map(async (slug: string) => await getProjectBySlug(slug, fields))
)
return projects
}

View File

@ -1,8 +1,8 @@
declare type ImageType = {
src: string
width: number
height: number
format: string
width?: number
height?: number
format?: string
blurDataURL?: string
}

View File

@ -15,6 +15,7 @@ const customJestConfig: Config = {
moduleDirectories: ['node_modules', '<rootDir>/src'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
'^.+\\.(svg)$': '<rootDir>/tests/__mocks__/svgr-mock.tsx'
},
collectCoverage: true,
@ -38,7 +39,6 @@ const customJestConfig: Config = {
// https://github.com/vercel/next.js/issues/35634#issuecomment-1115250297
async function jestConfig() {
const nextJestConfig = await createJestConfig(customJestConfig)()
// /node_modules/ is the first pattern
nextJestConfig.transformIgnorePatterns[0] = '/node_modules/(?!uuid|remark)/'
return nextJestConfig
}

View File

@ -3,6 +3,7 @@ import '@testing-library/jest-dom'
import 'jest-canvas-mock'
import giphy from './__fixtures__/giphy.json'
import { dataLocation } from './__fixtures__/location'
import reposMock from './__fixtures__/repos.json'
import './__mocks__/matchMedia'
jest.mock('next/navigation', () => ({
@ -14,7 +15,8 @@ jest.mock('../src/app/actions', () => ({
getRandomGif: jest
.fn()
.mockImplementation(() => giphy.data.images.original.mp4),
preloadLocation: jest.fn()
preloadLocation: jest.fn(),
getRepos: jest.fn().mockImplementationOnce(() => reposMock)
}))
const unmockedFetch = global.fetch

View File

@ -1,21 +1,22 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"jsx": "preserve",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false, // TODO: Change to strict: true
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"exclude": ["node_modules"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}