1
0
mirror of https://github.com/kremalicious/portfolio.git synced 2024-12-22 09:13:19 +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/favicon*
# public/apple-touch-icon* # public/apple-touch-icon*
# public/manifest* # public/manifest*
generated

7
package-lock.json generated
View File

@ -39,7 +39,6 @@
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"ora": "^8.0.1", "ora": "^8.0.1",
"prepend": "^1.0.2",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"sharp-ico": "^0.1.5", "sharp-ico": "^0.1.5",
@ -12159,12 +12158,6 @@
"node": ">= 0.8.0" "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": { "node_modules/prettier": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz",

View File

@ -8,19 +8,20 @@
"author": "Matthias Kretschmann <m@kretschmann.io>", "author": "Matthias Kretschmann <m@kretschmann.io>",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "next", "start": "npm run prebuild && next",
"build": "next build", "build": "next build",
"preview": "npm run build && next start", "preview": "next start",
"export": "next export", "export": "npm run prebuild && next export",
"typecheck": "tsc", "typecheck": "tsc",
"lint:js": "next lint", "lint:js": "next lint",
"lint:css": "stylelint ./src/**/*.css", "lint:css": "stylelint ./src/**/*.css",
"lint": "npm run lint:js && npm run lint:css", "lint": "npm run lint:js && npm run lint:css",
"format": "prettier --write 'src/**/*.{ts,tsx,css}'", "format": "prettier --write 'src/**/*.{ts,tsx,css}'",
"jest": "jest --coverage -c tests/jest.config.ts", "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", "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": { "dependencies": {
"@giphy/js-fetch-api": "^5.3.0", "@giphy/js-fetch-api": "^5.3.0",
@ -53,7 +54,6 @@
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"ora": "^8.0.1", "ora": "^8.0.1",
"prepend": "^1.0.2",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"sharp-ico": "^0.1.5", "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: Add this to src/components/Meta/Favicon.tsx:
${outputMeta} ${outputMeta}
`) `)
} catch (error) { } catch (error: unknown) {
console.error(error.message) console.error((error as Error).message)
} }
} }

View File

@ -1,10 +1,8 @@
#!/usr/bin/env ts-node #!/usr/bin/env ts-node
import fs from 'fs' import fs from 'fs'
import path from 'path'
import prepend from 'prepend'
import slugify from 'slugify'
import ora from 'ora' import ora from 'ora'
import path from 'path'
import slugify from 'slugify'
const templatePath = path.join(process.cwd(), 'scripts', 'new.yml') const templatePath = path.join(process.cwd(), 'scripts', 'new.yml')
const template = fs.readFileSync(templatePath).toString() const template = fs.readFileSync(templatePath).toString()
@ -26,7 +24,13 @@ const newContents = template
.split('SLUG') .split('SLUG')
.join(titleSlug) .join(titleSlug)
prepend(projects, newContents, (error) => { // prepend newContents to projects.yml file
if (error) spinner.fail(error) 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.`) 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 { notFound } from 'next/navigation'
import meta from '../../../_content/meta.json' import meta from '@/_content/meta.json'
import Header from '../../components/Header/Header' import projects from '@/generated/projects.json'
import Project from '../../components/Project' import Header from '@/src/components/Header/Header'
import ProjectNav from '../../components/ProjectNav' import Project from '@/src/components/Project'
import { import ProjectNav from '@/src/components/ProjectNav'
getAllProjects, import { getAllSlugs } from './getAllSlugs'
getProjectBySlug, import { getProjectBySlug } from './getProjectBySlug'
getProjectSlugs
} from '../../lib/content'
type Props = { type Props = {
params: { slug: string } params: { slug: string }
// searchParams: { [key: string]: string | string[] | undefined }
} }
export async function generateMetadata( export async function generateMetadata({ params }: Props): Promise<Metadata> {
{ params }: Props const project = getProjectBySlug(params.slug)
// parent: ResolvingMetadata if (!project) return {}
): Promise<Metadata> {
const project = await getProjectBySlug(params.slug)
if (!project) return
return { return {
title: project.title, title: project.title,
@ -37,12 +31,10 @@ export async function generateMetadata(
} }
export default async function ProjectPage({ params }: Props) { export default async function ProjectPage({ params }: Props) {
const project = await getProjectBySlug(params.slug) const project = getProjectBySlug(params.slug)
if (!project) notFound() if (!project) notFound()
const projects = await getAllProjects(['slug', 'title', 'images'])
return ( return (
<> <>
<Header /> <Header />
@ -53,7 +45,6 @@ export default async function ProjectPage({ params }: Props) {
} }
export async function generateStaticParams() { export async function generateStaticParams() {
const slugs = getProjectSlugs() const slugs = getAllSlugs()
return slugs.map((slug) => ({ slug })) return slugs.map((slug) => ({ slug }))
} }

View File

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

View File

@ -1,11 +1,15 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { dataLocation } from '../../../tests/__fixtures__/location'
import Layout from '../layout' import Layout from '../layout'
describe('app: /layout', () => { describe('app: /layout', () => {
// suppress error "Warning: validateDOMNesting(...): <html> cannot appear as a child of <div>" // suppress error "Warning: validateDOMNesting(...): <html> cannot appear as a child of <div>"
// https://github.com/testing-library/react-testing-library/issues/1250 // 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(() => { beforeAll(() => {
originalError = console.error originalError = console.error

View File

@ -1,16 +1,6 @@
import { render, screen } from '@testing-library/react' 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' 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', () => { describe('app: /page', () => {
it('renders correctly', async () => { it('renders correctly', async () => {
render(await Page()) render(await Page())

View File

@ -3,6 +3,7 @@
import { cache } from 'react' import { cache } from 'react'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { GiphyFetch } from '@giphy/js-fetch-api' import { GiphyFetch } from '@giphy/js-fetch-api'
import { getGithubRepos } from '../lib/github'
export const preloadLocation = () => { export const preloadLocation = () => {
void getLocation() void getLocation()
@ -16,8 +17,17 @@ export const getLocation = cache(async () => {
const data = await response.json() const data = await response.json()
return data return data
} catch (error) { } catch (error: unknown) {
console.error(error.message) 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 { data } = await giphyClient.random({ tag })
const gif = data.images.original.mp4 const gif = data.images.original.mp4
return gif return gif
} catch (error) { } catch (error: unknown) {
console.error(error.message) console.error((error as Error).message)
} }
if (pathname) revalidatePath(pathname) 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 Hero from '../components/Hero'
import Projects from '../components/Projects' import Projects from '../components/Projects'
import Repositories from '../components/Repositories' import Repositories from '../components/Repositories'
import { getAllProjects } from '../lib/content' import { getRepos, preloadLocation } from './actions'
import { getGithubRepos } from '../lib/github'
import { preloadLocation } from './actions'
export default async function IndexPage() { export default async function IndexPage() {
const projects = await getAllProjects(['title', 'images', 'slug']) const repos = await getRepos()
const repos = await getGithubRepos()
preloadLocation() preloadLocation()
@ -15,7 +14,9 @@ export default async function IndexPage() {
<> <>
<Hero /> <Hero />
<Projects projects={projects} /> <Projects projects={projects} />
<Suspense fallback={<p>Loading open source projects...</p>}>
<Repositories repos={repos} /> <Repositories repos={repos} />
</Suspense>
</> </>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,16 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { getLocation } from '@/src/app/actions'
import RelativeTime from '@yaireo/relative-time' 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 { Flag } from './Flag'
import styles from './Location.module.css' import styles from './Location.module.css'
import { UseLocation } from './types' import { UseLocation } from './types'
export default function Location() { export default function Location() {
const shouldReduceMotion = useReducedMotion()
const [location, setLocation] = useState<UseLocation | null>(null) const [location, setLocation] = useState<UseLocation | null>(null)
const isDifferentCountry = location?.now?.country !== location?.next?.country const isDifferentCountry = location?.now?.country !== location?.next?.country
@ -24,10 +27,12 @@ export default function Location() {
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
{location?.now?.city ? ( {location?.now?.city ? (
<section <LazyMotion features={domAnimation}>
<m.section
aria-label="Location" aria-label="Location"
variants={fadeIn}
className={styles.location} className={styles.location}
style={{ opacity: 1 }} {...getAnimationProps(shouldReduceMotion)}
> >
<Flag <Flag
country={{ country={{
@ -54,7 +59,8 @@ export default function Location() {
</> </>
)} )}
</div> </div>
</section> </m.section>
</LazyMotion>
) : null} ) : null}
</div> </div>
) )

View File

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

View File

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

View File

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

View File

@ -12,7 +12,6 @@ import styles from './index.module.css'
const containerVariants = { const containerVariants = {
enter: { enter: {
transition: { transition: {
delay: 0.3,
staggerChildren: 0.2 staggerChildren: 0.2
} }
} }
@ -25,7 +24,7 @@ export default function Project({
}) { }) {
const { title, descriptionHtml, images, links, techstack } = project const { title, descriptionHtml, images, links, techstack } = project
const shouldReduceMotion = useReducedMotion() const shouldReduceMotion = useReducedMotion()
const animationProps = getAnimationProps(shouldReduceMotion) const animationProps = getAnimationProps(shouldReduceMotion || false)
return ( return (
<article className={styles.project}> <article className={styles.project}>
@ -42,7 +41,7 @@ export default function Project({
<m.div <m.div
variants={moveInBottom} variants={moveInBottom}
className={styles.description} className={styles.description}
dangerouslySetInnerHTML={{ __html: descriptionHtml }} dangerouslySetInnerHTML={{ __html: descriptionHtml ?? '' }}
/> />
</m.header> </m.header>
</LazyMotion> </LazyMotion>
@ -54,8 +53,6 @@ export default function Project({
alt={`Showcase image no. ${i + 1} for ${title}`} alt={`Showcase image no. ${i + 1} for ${title}`}
key={i} key={i}
sizes="100vw" 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 () => { 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 Image from 'next/image'
import {
LazyMotion,
domAnimation,
m,
useAnimation,
useReducedMotion
} from 'framer-motion'
import ImageType from '../../types/image' import ImageType from '../../types/image'
import { getAnimationProps } from '../Transitions'
import styles from './index.module.css' import styles from './index.module.css'
const animationVariants = {
initial: { opacity: 0 },
enter: { opacity: 1 },
exit: { opacity: 0 }
}
export default function ProjectImage({ export default function ProjectImage({
image, image,
alt, alt,
@ -32,25 +15,8 @@ export default function ProjectImage({
className?: string className?: string
priority?: boolean 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 ? ( return image ? (
<LazyMotion features={domAnimation}> <figure className={`${styles.imageWrap} ${className || null}`}>
<m.figure
variants={animationVariants}
{...animationProps}
transition={{ ease: 'easeOut', duration: 1 }}
className={`${styles.imageWrap} ${className || null}`}
>
<Image <Image
className={styles.image} className={styles.image}
src={image.src} src={image.src}
@ -60,11 +26,9 @@ export default function ProjectImage({
sizes={sizes} sizes={sizes}
quality={85} quality={85}
priority={priority} priority={priority}
placeholder="empty" placeholder="blur"
// blurDataURL={image.blurDataURL} blurDataURL={image.blurDataURL}
onLoad={() => setLoaded(true)}
/> />
</m.figure> </figure>
</LazyMotion>
) : null ) : null
} }

View File

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

View File

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

View File

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

View File

@ -8,9 +8,4 @@ describe('Repositories', () => {
const { container } = render(<Repositories repos={repos as Repo[]} />) const { container } = render(<Repositories repos={repos as Repo[]} />)
expect(container.firstChild).toBeInTheDocument() 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 Repository from '../Repository'
import styles from './index.module.css' import styles from './index.module.css'
export default function Repositories({ repos }: { repos: Repo[] }) { export default function Repositories({ repos }: { repos: Repo[] | undefined }) {
if (!repos) return null
return ( return (
<> <>
<h2 className={styles.sectionTitle}>Open Source Projects</h2> <h2 className={styles.sectionTitle}>Open Source Projects</h2>
<div className={styles.repos}> <div className={styles.repos}>
{repos.map((repo) => ( {repos?.map((repo) => <Repository key={repo.name} repo={repo} />)}
<Repository key={repo.name} repo={repo} />
))}
</div> </div>
</> </>
) )

View File

@ -17,7 +17,7 @@ describe('Repository', () => {
} }
const { container } = render(<Repository repo={repo1 as Repo} />) 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 repo1.html_url
) )
}) })

View File

@ -13,7 +13,7 @@ export function getIconName(theme: string) {
export default function ThemeSwitch() { export default function ThemeSwitch() {
const { theme, themes, resolvedTheme, setTheme } = useTheme() const { theme, themes, resolvedTheme, setTheme } = useTheme()
const iconName = getIconName(resolvedTheme) const iconName = getIconName(resolvedTheme || '')
// hydration errors workaround // hydration errors workaround
const [mounted, setMounted] = useState(false) 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) { export function getAnimationProps(shouldReduceMotion: boolean) {
return { return {
initial: `${shouldReduceMotion ? 'enter' : 'initial'}`, initial: `${shouldReduceMotion ? 'enter' : 'initial'}`,

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import { MouseEvent } from 'react'
import meta from '../../../_content/meta.json' import meta from '../../../_content/meta.json'
export default function Vcard() { export default function Vcard() {
@ -14,7 +15,7 @@ export default function Vcard() {
profiles: meta.profiles profiles: meta.profiles
} }
const handleAddressbookClick = (e) => { const handleAddressbookClick = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
import('./_utils').then(({ init }) => { 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 = { declare type ImageType = {
src: string src: string
width: number width?: number
height: number height?: number
format: string format?: string
blurDataURL?: string blurDataURL?: string
} }

View File

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

View File

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

View File

@ -1,21 +1,22 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"module": "esnext",
"jsx": "preserve",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true,
"strict": false, // TODO: Change to strict: true
"forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve",
"incremental": true, "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"]
} }