Merge pull request #1285 from kremalicious/images

content generation via build script
This commit is contained in:
Matthias Kretschmann 2024-02-06 01:47:14 +00:00 committed by GitHub
commit 9d3f7e4d2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 2644 additions and 2012 deletions

View File

@ -19,18 +19,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm test
- uses: actions/upload-artifact@v2
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
@ -40,12 +40,12 @@ jobs:
needs: [test]
if: ${{ success() && github.actor != 'dependabot[bot]' }}
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v2
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: coverage
- uses: paambaati/codeclimate-action@v2.7.5
- uses: paambaati/codeclimate-action@v5.0.0
env:
CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
@ -53,16 +53,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
cache: 'npm'
- name: Cache Next.js build output
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}

View File

@ -38,11 +38,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@ -53,7 +53,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@ -67,4 +67,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

3
.gitignore vendored
View File

@ -39,4 +39,5 @@ coverage
public/matomo.js
# public/favicon*
# public/apple-touch-icon*
# public/manifest*
# public/manifest*
generated

2
.nvmrc
View File

@ -1 +1 @@
18
20

View File

@ -9,6 +9,9 @@
"^(react/(.*)$)|^(react$)",
"^(next/(.*)$)|^(next$)",
"<THIRD_PARTY_MODULES>",
"^(@/(.*)$)|^(@$)",
"^(@content/(.*)$)|^(@content$)",
"^(@generated/(.*)$)|^(@generated$)",
"^[./]"
],
"importOrderSeparation": false,

View File

@ -44,9 +44,10 @@ If you are looking for the former Gatsby-based app, it is archived in the [`gats
All displayed project content is powered by one YAML file where all the portfolio's projects are defined. The project description itself is transformed from Markdown written inside the YAML file into HTML on build time.
Next.js automatically creates pages from each item in that file utilizing the [`[slug]/page.tsx`](src/app/[slug]/page.tsx) template.
Next.js automatically creates pages from each item in that file utilizing the [`scripts/prebuild.ts`](scripts/prebuild.ts) script and the [`[slug]/page.tsx`](src/app/[slug]/page.tsx) template.
- [`_content/projects.yml`](_content/projects.yml)
- [`scripts/prebuild.ts`](scripts/prebuild.ts)
- [`src/app/[slug]/page.tsx`](src/app/[slug]/page.tsx)
### 🖼 Project images
@ -56,7 +57,7 @@ All project images live under `public/images` and are automatically attached to
Next.js with `next/image` generates all required image sizes for delivering responsible, responsive images to visitors, including lazy loading of all images. For this to work, images are analyzed on build time and various image metadata is passed down as props.
- [`src/components/ProjectImage/index.tsx`](src/components/ProjectImage/index.tsx)
- [`src/lib/content.ts`](src/lib/content.ts)
- [`script/content/images.ts`](script/content/images.ts)
### 🐱 GitHub repositories

View File

@ -5,8 +5,7 @@
"author": {
"name": "Matthias Kretschmann",
"label": "Designer & Developer",
"email": "m@kretschmann.io",
"picture": "../src/images/avatar.jpg"
"email": "m@kretschmann.io"
},
"availability": {
"status": false,

3418
package-lock.json generated

File diff suppressed because it is too large Load Diff

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",
"new": "ts-node-esm ./scripts/new.ts",
"favicon": "ts-node-esm ./scripts/favicon.ts"
"jest": "jest --coverage -c tests/jest.config.js",
"test": "NODE_ENV=test npm run prebuild && npm run lint && npm run typecheck && npm run jest",
"new": "node --import tsx/esm ./scripts/new.ts",
"favicon": "node --import tsx/esm ./scripts/favicon.ts",
"prebuild": "node --import tsx/esm ./scripts/prebuild.ts"
},
"dependencies": {
"@giphy/js-fetch-api": "^5.3.0",
@ -35,36 +36,35 @@
"react-dom": "^18.2.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.0",
"remark-html": "^16.0.1",
"vcf": "github:jhermsmeier/node-vcf"
"remark-html": "^16.0.1"
},
"devDependencies": {
"@svgr/webpack": "^8.1.0",
"@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^14.2.0",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/file-saver": "^2.0.7",
"@types/jest": "^29.5.12",
"@types/js-yaml": "^4.0.9",
"chalk": "^5.3.0",
"eslint": "^8.56.0",
"eslint-config-next": "^14.1.0",
"jest": "^29.7.0",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"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",
"slugify": "^1.6.6",
"stylelint": "^16.2.1",
"stylelint-prettier": "^5.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},
"engines": {
"node": "18"
"node": "^20.6.0"
},
"browserslist": [
"> 0.2%",

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,38 @@
import fs from 'fs'
import yaml from 'js-yaml'
import ora from 'ora'
import path from 'path'
import type ProjectType from '@/types/project'
import { transformProject } from './transformProject'
const contentDirectory = path.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)
}
const dirPath = path.dirname(projectsOutput)
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
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 fs from 'fs'
import { join } from 'path'
import sharp from 'sharp'
import type ImageType from '@/types/image'
import { rgbDataURL } from './rgbDataURL'
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 '@/types/project'
import { getProjectImages } from './images'
import { markdownToHtml } from './markdown'
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'
generateProjects()

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 Header from '@/components/Header/Header'
import Project from '@/components/Project'
import ProjectNav from '@/components/ProjectNav'
import { getAllSlugs } from '@/lib/getAllSlugs'
import { getProjectBySlug } from '@/lib/getProjectBySlug'
import meta from '@content/meta.json'
import projects from '@generated/projects.json'
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 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('../../lib/getProjectBySlug', () => ({
getProjectBySlug: jest.fn().mockImplementation(() => projectMock)
}))
jest.mock('../../lib/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,14 +1,9 @@
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)
jest.mock('../../lib/getRepos', () => ({
getRepos: jest.fn().mockImplementation(() => reposMock)
}))
describe('app: /page', () => {

View File

@ -1,13 +1,13 @@
import { ReactNode } from 'react'
import { Metadata, Viewport } from 'next'
import Script from 'next/script'
import meta from '../../_content/meta.json'
import Footer from '../components/Footer'
import HostnameCheck from '../components/HostnameCheck'
import ThemeSwitch from '../components/ThemeSwitch'
import { UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID } from '../lib/umami'
import '../styles/global.css'
import styles from '../styles/layout.module.css'
import Footer from '@/components/Footer'
import HostnameCheck from '@/components/HostnameCheck'
import ThemeSwitch from '@/components/ThemeSwitch'
import { UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID } from '@/lib/umami'
import '@/styles/global.css'
import styles from '@/styles/layout.module.css'
import meta from '@content/meta.json'
import { Providers } from './providers'
const isProduction = process.env.NODE_ENV === 'production'

View File

@ -1,5 +1,5 @@
import { Metadata } from 'next'
import NotFound from '../components/404'
import NotFound from '@/components/404'
export const metadata: Metadata = {
title: `Shenanigans`,

View File

@ -1,13 +1,13 @@
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 { Suspense } from 'react'
import Hero from '@/components/Hero'
import Projects from '@/components/Projects'
import Repositories from '@/components/Repositories/Repositories'
import { preloadLocation } from '@/lib/getLocation'
import { getRepos } from '@/lib/getRepos'
import projects from '@generated/projects.json'
export default async function IndexPage() {
const projects = await getAllProjects(['title', 'images', 'slug'])
const repos = await getGithubRepos()
const repos = await getRepos()
preloadLocation()
@ -15,7 +15,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,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import NotFoundPage from '../../../src/components/404'
import NotFoundPage from '@/components/404'
import mockData from '../../../tests/__fixtures__/giphy.json'
jest.setTimeout(30000)
describe('NotFoundPage', () => {
it('renders correctly', async () => {
render(<NotFoundPage />)

View File

@ -3,7 +3,7 @@
import { MouseEvent, useEffect, useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { getRandomGif } from '../../app/actions'
import { getRandomGif } from '@/lib/getRandomGif'
import Button from '../Button'
import styles from './index.module.css'

View File

@ -1,4 +1,4 @@
import meta from '../../../_content/meta.json'
import meta from '@content/meta.json'
import styles from './index.module.css'
export default function Availability() {

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

@ -1,4 +1,4 @@
import meta from '../../../_content/meta.json'
import meta from '@content/meta.json'
import LogoUnit from '../LogoUnit'
import Networks from '../Networks'
import Vcard from '../Vcard'

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import Header from './Header'
import Header from '.'
describe('Header', () => {
it('renders correctly', async () => {

View File

@ -1,5 +1,5 @@
import Availability from '../Availability'
import Location from '../Location/Location'
import Location from '../Location'
import LogoUnit from '../LogoUnit'
import Networks from '../Networks'
import styles from './Hero.module.css'

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

@ -16,7 +16,7 @@ import {
Star,
Sun
} from 'lucide-react'
import Mastodon from '../../images/mastodon.svg'
import Mastodon from '@/images/mastodon.svg'
import styles from './index.module.css'
export default function Icon({ name, ...props }: { name: string }) {
@ -44,7 +44,7 @@ export default function Icon({ name, ...props }: { name: string }) {
Contrast
}
const IconMapped = components[name]
const IconMapped = components[name as keyof typeof components]
return IconMapped ? (
<IconMapped className={`${styles.icon} ${styles[name]}`} {...props} />

View File

@ -1,34 +1,49 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useTransition } from 'react'
import RelativeTime from '@yaireo/relative-time'
import { getLocation } from '../../app/actions'
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
import { getLocation } from '@/lib/getLocation'
import { fadeIn, getAnimationProps } from '../Transitions'
import { Flag } from './Flag'
import styles from './Location.module.css'
import { UseLocation } from './types'
function Animation({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion()
return (
<LazyMotion features={domAnimation}>
<m.section
aria-label="Location"
variants={fadeIn}
className={styles.location}
{...getAnimationProps(shouldReduceMotion || false)}
>
{children}
</m.section>
</LazyMotion>
)
}
export default function Location() {
const [isPending, startTransition] = useTransition()
const [location, setLocation] = useState<UseLocation | null>(null)
const isDifferentCountry = location?.now?.country !== location?.next?.country
const relativeTime = new RelativeTime({ locale: 'en' })
useEffect(() => {
const updateLocation = async () => {
startTransition(async () => {
const location = await getLocation()
setLocation(location)
}
updateLocation()
})
}, [])
return (
<div className={styles.wrapper}>
{location?.now?.city ? (
<section
aria-label="Location"
className={styles.location}
style={{ opacity: 1 }}
>
{!isPending && location?.now?.city ? (
<Animation>
<Flag
country={{
code: location.now.country_code,
@ -54,7 +69,7 @@ export default function Location() {
</>
)}
</div>
</section>
</Animation>
) : null}
</div>
)

View File

@ -1 +1 @@
export * from './Location'
export { default } from './Location'

View File

@ -1,6 +1,6 @@
import Link from 'next/link'
import meta from '../../../_content/meta.json'
import Logo from '../../images/logo.svg'
import Logo from '@/images/logo.svg'
import meta from '@content/meta.json'
import styles from './index.module.css'
type Props = {
@ -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

@ -1,7 +1,7 @@
'use client'
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
import meta from '../../../_content/meta.json'
import meta from '@content/meta.json'
import { getAnimationProps } from '../Transitions'
import { NetworkLink } from './NetworkLink'
import styles from './index.module.css'
@ -14,7 +14,6 @@ type Props = {
const containerVariants = {
enter: {
transition: {
delay: 0.2,
staggerChildren: 0.1
}
}
@ -22,7 +21,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

@ -1,5 +1,5 @@
import Button from '../../Button'
import Icon from '../../Icon'
import Button from '@/components/Button'
import Icon from '@/components/Icon'
import styles from './index.module.css'
export default function ProjectLinks({

View File

@ -1,8 +1,8 @@
'use client'
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'
import type ImageType from '../../types/image'
import type ProjectType from '../../types/project'
import type ImageType from '@/types/image'
import type ProjectType from '@/types/project'
import ProjectImage from '../ProjectImage'
import { getAnimationProps, moveInBottom } from '../Transitions'
import ProjectLinks from './Links'
@ -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 ImageType from '@/types/image'
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

@ -1,12 +1,12 @@
'use client'
import { createRef, useEffect } from 'react'
import ProjectType from '../../types/project'
import ProjectType from '@/types/project'
import { Project } from './Project'
import styles from './index.module.css'
type Props = {
projects: Partial<ProjectType>[]
projects: ProjectType[]
currentSlug: string
}
@ -18,9 +18,13 @@ export default function ProjectNav({ projects, currentSlug }: Props) {
useEffect(() => {
function scrollToCurrent() {
if (!scrollContainer.current || !currentItem.current) return
const activeItem = currentItem.current
const scrollRect = scrollContainer.current.getBoundingClientRect()
const activeRect = activeItem && activeItem.getBoundingClientRect()
if (!activeItem || !scrollRect || !activeRect) return
const newScrollLeftPosition =
activeRect &&
activeRect.left -

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

@ -1,9 +1,9 @@
import ProjectType from '../../types/project'
import ProjectType from '@/types/project'
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

@ -1,16 +1,11 @@
import { render } from '@testing-library/react'
import Repo from '@/types/repo'
import Repositories from '.'
import repos from '../../../tests/__fixtures__/repos.json'
import Repo from '../../types/repo'
describe('Repositories', () => {
it('renders correctly', () => {
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

@ -0,0 +1,14 @@
import Repo from '@/types/repo'
import Repository from '../Repository'
import styles from './Repositories.module.css'
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} />)}
</div>
</>
)
}

View File

@ -1,18 +1 @@
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
return (
<>
<h2 className={styles.sectionTitle}>Open Source Projects</h2>
<div className={styles.repos}>
{repos.map((repo) => (
<Repository key={repo.name} repo={repo} />
))}
</div>
</>
)
}
export { default } from './Repositories'

View File

@ -1,7 +1,7 @@
import { render } from '@testing-library/react'
import Repo from '@/types/repo'
import Repository from '.'
import repos from '../../../tests/__fixtures__/repos.json'
import Repo from '../../types/repo'
import Repository from '../Repository'
describe('Repository', () => {
it('renders correctly', () => {
@ -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

@ -1,4 +1,4 @@
import Repo from '../../types/repo'
import Repo from '@/types/repo'
import Icon from '../Icon'
import styles from './index.module.css'

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,13 +1,9 @@
import meta from '../../../_content/meta.json'
import { constructVcard, init, toDataURL } from './_utils'
import { constructVcard, init } from './_utils'
const metaMock = {
...meta,
name: meta.author.name,
label: meta.author.label,
email: meta.author.email,
profiles: [...meta.profiles]
}
jest.mock('./imageToDataUrl', () => ({
__esModule: true,
imageToDataUrl: jest.fn().mockResolvedValue('data:image/png;base64,')
}))
describe('Vcard/_utils', () => {
beforeEach(() => {
@ -15,17 +11,12 @@ describe('Vcard/_utils', () => {
})
it('combined vCard download process finishes', async () => {
await init(metaMock)
await init()
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1)
})
it('vCard can be constructed', async () => {
const vcard = await constructVcard(metaMock, 'data:image/jpeg;base64,00')
it('vCard can be constructed', () => {
const vcard = constructVcard('data:image/jpeg;base64,00')
expect(vcard).toBeDefined()
})
it('Base64 from image can be constructed', async () => {
const dataUrl = await toDataURL('hello', 'image/jpeg')
expect(dataUrl).toBeDefined()
})
})

View File

@ -1,67 +1,43 @@
import saveAs from 'file-saver'
import vCard from 'vcf'
import avatar from '@/images/avatar.jpg'
import meta from '@content/meta.json'
import { imageToDataUrl } from './imageToDataUrl'
export async function toDataURL(photoSrc: string, outputFormat) {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.src = photoSrc
img.onload = () => {}
// yeah, we're gonna create a fake canvas to render the image
// and then create a base64 string from the rendered result
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
let dataURL
canvas.height = img.naturalHeight
canvas.width = img.naturalWidth
ctx.drawImage(img, 0, 0)
dataURL = canvas.toDataURL(outputFormat)
// img.src = photoSrc
// if (img.complete || img.complete === undefined) {
// img.src =
// 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='
// img.src = photoSrc
// }
return dataURL
}
export async function constructVcard(meta, dataUrl: string) {
const contact = new vCard()
export function constructVcard(dataUrl: string) {
const blog = meta.profiles.filter(({ network }) => network === 'Blog')[0].url
const github = meta.profiles.filter(({ network }) => network === 'GitHub')[0]
.url
// stripping this data out of base64 string is required
// for vcard to actually display the image for whatever reason
// const dataUrlCleaned = dataUrl.split('data:image/jpeg;base64,').join('')
// contact.set('photo', dataUrlCleaned, { encoding: 'b', type: 'JPEG' })
contact.set('fn', meta.name)
contact.set('title', meta.label)
contact.set('email', meta.email)
contact.set('nickname', 'kremalicious')
contact.set('url', meta.url, { type: 'Portfolio' })
contact.add('url', blog, { type: 'Blog' })
contact.add('x-socialprofile', github, { type: 'GitHub' })
const dataUrlCleaned = dataUrl.replace(
/^data:image\/(png|jpg|jpeg);base64,/,
''
)
const vCard = `BEGIN:VCARD
VERSION:3.0
PHOTO;ENCODING=B;TYPE=JPEG:${dataUrlCleaned},
FN:${meta.author.name}
TITLE:${meta.author.label}
EMAIL:${meta.author.email}
NICKNAME:kremalicious
URL;TYPE=portfolio:${meta.url}
URL;TYPE=blog:${blog}
X-SOCIALPROFILE;TYPE=github:${github}
END:VCARD`
const vcard = contact.toString('3.0')
return vcard
return vCard
}
export async function init(meta) {
// first, convert the avatar to base64, then construct all vCard elements
const dataUrl = await toDataURL(meta.photoSrc, 'image/jpeg')
const vcard = await constructVcard(meta, dataUrl)
export async function init() {
const dataUrl = await imageToDataUrl(avatar.src)
const vcard = constructVcard(dataUrl)
// Construct the download from a blob of the just constructed vCard,
const { addressbook } = meta
const name = addressbook.split('/').join('')
const blob = new Blob([vcard], {
type: 'text/x-vcard'
})
const blob = new Blob([vcard], { type: 'text/x-vcard' })
// save it to user's file system
saveAs(blob, name)
}

View File

@ -0,0 +1,52 @@
import fetch, { FetchMock } from 'jest-fetch-mock'
import { imageToDataUrl } from './imageToDataUrl'
const dummyPath = 'http://example.com/image.png'
const pixel = [
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44,
0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d,
0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42,
0x60, 0x82
]
describe('imageToDataUrl', () => {
beforeEach(() => {
fetch.resetMocks()
const mockBlob = new Blob([new Uint8Array(pixel)], { type: 'image/png' })
const mockResponse = new Response(mockBlob)
;(fetch as FetchMock).mockResponseOnce(async () => {
const text = await mockResponse.text()
return text
})
})
it('should convert image to data URL', async () => {
function MockFileReader(this: any) {
this.readAsDataURL = function () {
this.result = 'data:image/png;base64,...'
setTimeout(() => this.onload(), 0)
}
}
window.FileReader = MockFileReader as any
const dataUrl = await imageToDataUrl(dummyPath)
expect(dataUrl).toBe('data:image/png;base64,...')
})
it('should handle errors in readAsDataURL', async () => {
function MockFileReader(this: FileReader) {
this.readAsDataURL = function () {
throw new Error('Mock error')
}
}
window.FileReader = MockFileReader as any
// Expect imageToDataUrl to reject with the mock error
await expect(imageToDataUrl(dummyPath)).rejects.toThrow('Mock error')
})
})

View File

@ -0,0 +1,16 @@
export async function imageToDataUrl(path: string): Promise<string> {
const response = await fetch(path)
const blob = await response.blob()
return new Promise((onSuccess, onError) => {
try {
const reader = new FileReader()
reader.onload = function () {
onSuccess(this.result as string)
}
reader.readAsDataURL(blob)
} catch (e) {
onError(e)
}
})
}

View File

@ -1,6 +1,11 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Vcard from '.'
jest.mock('./imageToDataUrl', () => ({
__esModule: true,
imageToDataUrl: jest.fn().mockResolvedValue('data:image/png;base64,')
}))
describe('Vcard', () => {
beforeEach(() => {
global.URL.createObjectURL = jest.fn()

View File

@ -1,25 +1,13 @@
'use client'
import meta from '../../../_content/meta.json'
import { MouseEvent } from 'react'
import meta from '@content/meta.json'
export default function Vcard() {
const { name, label, email } = meta.author
const vCardMeta = {
...meta,
/// photoSrc,
name,
label,
email,
profiles: meta.profiles
}
const handleAddressbookClick = (e) => {
const handleAddressbookClick = (e: MouseEvent) => {
e.preventDefault()
import('./_utils').then(({ init }) => {
init(vCardMeta)
})
import('./_utils').then(({ init }) => init())
}
return (

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
}

6
src/lib/getAllSlugs.ts Normal file
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
}

20
src/lib/getLocation.ts Normal file
View File

@ -0,0 +1,20 @@
'use server'
import { cache } from 'react'
export const preloadLocation = () => {
void getLocation()
}
export const getLocation = cache(async () => {
try {
const response = await fetch('https://location.kretschmann.io')
if (!response.ok)
throw new Error('Network response for location was not ok.')
const data = await response.json()
return data
} catch (error: unknown) {
console.error((error as Error).message)
}
})

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,8 @@
'use server'
import { cache } from 'react'
import { revalidatePath } from 'next/cache'
import { GiphyFetch } from '@giphy/js-fetch-api'
export const preloadLocation = () => {
void getLocation()
}
export const getLocation = cache(async () => {
try {
const response = await fetch('https://location.kretschmann.io')
if (!response.ok)
throw new Error('Network response for location was not ok.')
const data = await response.json()
return data
} catch (error) {
console.error(error.message)
}
})
export async function getRandomGif(tag: string, pathname?: string) {
try {
// Famous last words:
@ -29,8 +11,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)

57
src/lib/getRepos.test.ts Normal file
View File

@ -0,0 +1,57 @@
import * as React from 'react'
import fetch, { FetchMock } from 'jest-fetch-mock'
import repoFilter from '@content/repos.json'
import { getRepos } from './getRepos'
jest.mock('react', () => ({
...jest.requireActual('react'),
cache: (fn: any) => fn
}))
describe('getRepos', () => {
beforeEach(() => {
fetch.resetMocks()
})
it('should fetch repos data', async () => {
const mockData = {
name: 'test',
full_name: 'test/test',
description: 'test repo',
html_url: 'https://github.com/test/test',
homepage: 'https://test.com',
stargazers_count: 100,
pushed_at: '2022-01-01T00:00:00Z'
}
;(fetch as FetchMock).mockResponse(JSON.stringify(mockData))
const data = await getRepos()
const count = repoFilter.length
expect(data).toEqual(Array.from({ length: count }, () => mockData))
expect(fetch).toHaveBeenCalledTimes(count)
})
it('should handle network errors', async () => {
let consoleErrorMock: jest.SpyInstance
consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {})
;(fetch as FetchMock).mockRejectOnce(new Error('Network error'))
const data = await getRepos()
expect(data).toBeUndefined()
expect(fetch).toHaveBeenCalledTimes(1)
consoleErrorMock.mockRestore()
})
it('should handle invalid repo data', async () => {
const mockData = { name: null }
;(fetch as FetchMock).mockResponseOnce(JSON.stringify(mockData))
const data = await getRepos()
expect(data).toBeUndefined()
expect(fetch).toHaveBeenCalledTimes(1)
})
})

64
src/lib/getRepos.ts Normal file
View File

@ -0,0 +1,64 @@
'use server'
import { cache } from 'react'
import type Repo from '@/types/repo'
import filter from '@content/repos.json'
//
// Get GitHub repos
//
if (!process.env.GITHUB_TOKEN) {
throw new Error('Missing GitHub environment variable')
}
const gitHubConfig = {
headers: {
'User-Agent': 'kremalicious/portfolio',
Authorization: `token ${process.env.GITHUB_TOKEN}`
}
}
export const getRepos = cache(async () => {
try {
let repos: Repo[] = []
for (let item of filter) {
const user = item.split('/')[0]
const repoName = item.split('/')[1]
const response = await fetch(
`https://api.github.com/repos/${user}/${repoName}`,
gitHubConfig
)
const json: Repo = await response.json()
if (!json?.name) return
const {
name,
full_name,
description,
html_url,
homepage,
stargazers_count,
pushed_at
} = json
const repo: Repo = {
name,
full_name,
description,
html_url,
homepage,
stargazers_count,
pushed_at
}
repos.push(repo)
}
// sort by pushed to, newest first
repos = repos.sort((a, b) => b.pushed_at.localeCompare(a.pushed_at))
return repos
} catch (error: unknown) {
console.error((error as Error).message)
}
})

View File

@ -1,57 +0,0 @@
import data from '../../_content/repos.json'
import Repo from '../types/repo'
//
// Get GitHub repos
//
if (!process.env.GITHUB_TOKEN) {
throw new Error('Missing GitHub environment variable')
}
const gitHubConfig = {
headers: {
'User-Agent': 'kremalicious/portfolio',
Authorization: `token ${process.env.GITHUB_TOKEN}`
}
}
export async function getGithubRepos() {
let repos: Repo[] = []
for (let item of data) {
const user = item.split('/')[0]
const repoName = item.split('/')[1]
const data = await fetch(
`https://api.github.com/repos/${user}/${repoName}`,
gitHubConfig
)
const json: Repo = await data.json()
if (!json?.name) return
const {
name,
full_name,
description,
html_url,
homepage,
stargazers_count,
pushed_at
} = json
const repo: Repo = {
name,
full_name,
description,
html_url,
homepage,
stargazers_count,
pushed_at
}
repos.push(repo)
}
// sort by pushed to, newest first
repos = repos.sort((a, b) => b.pushed_at.localeCompare(a.pushed_at))
return repos
}

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

@ -1,20 +1,19 @@
import nextJest from 'next/jest'
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './'
})
// Add any custom config to be passed to Jest
const customJestConfig: Config = {
rootDir: '../', // = /
// Add more setup options before each test is run
setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.tsx'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
/** @type {import('jest').Config} */
const customJestConfig = {
rootDir: '../',
setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.ts'],
moduleDirectories: ['node_modules', '<rootDir>/src'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
'^@content/(.*)$': '<rootDir>/_content/$1',
'^@generated/(.*)$': '<rootDir>/generated/$1',
'^.+\\.(svg)$': '<rootDir>/tests/__mocks__/svgr-mock.tsx'
},
collectCoverage: true,
@ -38,7 +37,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

@ -1,20 +1,25 @@
import { jest } from '@jest/globals'
import '@testing-library/jest-dom'
import 'jest-canvas-mock'
import giphy from './__fixtures__/giphy.json'
import fetchMock from 'jest-fetch-mock'
import giphyMock from './__fixtures__/giphy.json'
import { dataLocation } from './__fixtures__/location'
import './__mocks__/matchMedia'
fetchMock.enableMocks()
jest.mock('next/navigation', () => ({
usePathname: jest.fn().mockImplementationOnce(() => '/')
}))
jest.mock('../src/app/actions', () => ({
jest.mock('../src/lib/getLocation', () => ({
getLocation: jest.fn().mockImplementation(() => dataLocation),
preloadLocation: jest.fn()
}))
jest.mock('../src/lib/getRandomGif', () => ({
getRandomGif: jest
.fn()
.mockImplementation(() => giphy.data.images.original.mp4),
preloadLocation: jest.fn()
.mockImplementation(() => giphyMock.data.images.original.mp4)
}))
const unmockedFetch = global.fetch

View File

@ -1,21 +1,26 @@
{
"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": 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": {
"@/*": ["./src/*"],
"@content/*": ["./_content/*"],
"@generated/*": ["./generated/*"]
}
},
"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"]
}