mirror of
https://github.com/kremalicious/portfolio.git
synced 2024-12-12 04:27:11 +01:00
refactor: handle content generation as prebuild step
This commit is contained in:
parent
5a2f2478c9
commit
a5cd4aebda
3
.gitignore
vendored
3
.gitignore
vendored
@ -39,4 +39,5 @@ coverage
|
||||
public/matomo.js
|
||||
# public/favicon*
|
||||
# public/apple-touch-icon*
|
||||
# public/manifest*
|
||||
# public/manifest*
|
||||
generated
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -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",
|
||||
|
12
package.json
12
package.json
@ -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",
|
||||
|
48
scripts/content/content.test-disabled.ts
Normal file
48
scripts/content/content.test-disabled.ts
Normal 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()
|
||||
// })
|
||||
// })
|
32
scripts/content/generateProjects.ts
Normal file
32
scripts/content/generateProjects.ts
Normal 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
36
scripts/content/images.ts
Normal 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
|
||||
}
|
11
scripts/content/rgbDataURL.ts
Normal file
11
scripts/content/rgbDataURL.ts
Normal 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==`
|
20
scripts/content/transformProject.ts
Normal file
20
scripts/content/transformProject.ts
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
3
scripts/prebuild.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { generateProjects } from './content/generateProjects.js'
|
||||
|
||||
generateProjects()
|
6
src/app/[slug]/getAllSlugs.ts
Normal file
6
src/app/[slug]/getAllSlugs.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import projects from '@/generated/projects.json'
|
||||
|
||||
export function getAllSlugs() {
|
||||
const slugs = projects.map(({ slug }: { slug: string }) => slug)
|
||||
return slugs
|
||||
}
|
5
src/app/[slug]/getProjectBySlug.ts
Normal file
5
src/app/[slug]/getProjectBySlug.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import projects from '@/generated/projects.json'
|
||||
|
||||
export function getProjectBySlug(slug: string) {
|
||||
return projects.find((item) => item.slug === slug)
|
||||
}
|
@ -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 }))
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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', () => {
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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}`}>
|
||||
|
@ -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', () => {
|
||||
|
@ -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}>
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
@ -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" />
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 &&
|
||||
|
@ -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}>
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
@ -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
|
||||
)
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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'}`,
|
||||
|
@ -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 }) => {
|
||||
|
19
src/components/Vcard/types.ts
Normal file
19
src/components/Vcard/types.ts
Normal 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
|
||||
}
|
@ -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()
|
||||
})
|
||||
})
|
@ -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
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
declare type ImageType = {
|
||||
src: string
|
||||
width: number
|
||||
height: number
|
||||
format: string
|
||||
width?: number
|
||||
height?: number
|
||||
format?: string
|
||||
blurDataURL?: string
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user