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

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

View File

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

1
.gitignore vendored
View File

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

2
.nvmrc
View File

@ -1 +1 @@
18 20

View File

@ -9,6 +9,9 @@
"^(react/(.*)$)|^(react$)", "^(react/(.*)$)|^(react$)",
"^(next/(.*)$)|^(next$)", "^(next/(.*)$)|^(next$)",
"<THIRD_PARTY_MODULES>", "<THIRD_PARTY_MODULES>",
"^(@/(.*)$)|^(@$)",
"^(@content/(.*)$)|^(@content$)",
"^(@generated/(.*)$)|^(@generated$)",
"^[./]" "^[./]"
], ],
"importOrderSeparation": false, "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. 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) - [`_content/projects.yml`](_content/projects.yml)
- [`scripts/prebuild.ts`](scripts/prebuild.ts)
- [`src/app/[slug]/page.tsx`](src/app/[slug]/page.tsx) - [`src/app/[slug]/page.tsx`](src/app/[slug]/page.tsx)
### 🖼 Project images ### 🖼 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. 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/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 ### 🐱 GitHub repositories

View File

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

3420
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>", "author": "Matthias Kretschmann <m@kretschmann.io>",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "next", "start": "npm run prebuild && next",
"build": "next build", "build": "next build",
"preview": "npm run build && next start", "preview": "next start",
"export": "next export", "export": "npm run prebuild && next export",
"typecheck": "tsc", "typecheck": "tsc",
"lint:js": "next lint", "lint:js": "next lint",
"lint:css": "stylelint ./src/**/*.css", "lint:css": "stylelint ./src/**/*.css",
"lint": "npm run lint:js && npm run lint:css", "lint": "npm run lint:js && npm run lint:css",
"format": "prettier --write 'src/**/*.{ts,tsx,css}'", "format": "prettier --write 'src/**/*.{ts,tsx,css}'",
"jest": "jest --coverage -c tests/jest.config.ts", "jest": "jest --coverage -c tests/jest.config.js",
"test": "NODE_ENV=test npm run lint && npm run typecheck && npm run jest", "test": "NODE_ENV=test npm run prebuild && npm run lint && npm run typecheck && npm run jest",
"new": "ts-node-esm ./scripts/new.ts", "new": "node --import tsx/esm ./scripts/new.ts",
"favicon": "ts-node-esm ./scripts/favicon.ts" "favicon": "node --import tsx/esm ./scripts/favicon.ts",
"prebuild": "node --import tsx/esm ./scripts/prebuild.ts"
}, },
"dependencies": { "dependencies": {
"@giphy/js-fetch-api": "^5.3.0", "@giphy/js-fetch-api": "^5.3.0",
@ -35,36 +36,35 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-html": "^16.0.1", "remark-html": "^16.0.1"
"vcf": "github:jhermsmeier/node-vcf"
}, },
"devDependencies": { "devDependencies": {
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@testing-library/jest-dom": "^6.4.1", "@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^14.2.0", "@testing-library/react": "^14.2.0",
"@trivago/prettier-plugin-sort-imports": "^4.3.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/file-saver": "^2.0.7",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-next": "^14.1.0", "eslint-config-next": "^14.1.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"ora": "^8.0.1", "ora": "^8.0.1",
"prepend": "^1.0.2",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"sharp-ico": "^0.1.5", "sharp-ico": "^0.1.5",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"stylelint": "^16.2.1", "stylelint": "^16.2.1",
"stylelint-prettier": "^5.0.0", "stylelint-prettier": "^5.0.0",
"ts-node": "^10.9.2", "tsx": "^4.7.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"engines": { "engines": {
"node": "18" "node": "^20.6.0"
}, },
"browserslist": [ "browserslist": [
"> 0.2%", "> 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: Add this to src/components/Meta/Favicon.tsx:
${outputMeta} ${outputMeta}
`) `)
} catch (error) { } catch (error: unknown) {
console.error(error.message) console.error((error as Error).message)
} }
} }

View File

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

3
scripts/prebuild.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,9 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import projectsMock from '../../../tests/__fixtures__/projects.json'
import reposMock from '../../../tests/__fixtures__/repos.json' import reposMock from '../../../tests/__fixtures__/repos.json'
import Page from '../page' import Page from '../page'
jest.mock('../../lib/content', () => ({ jest.mock('../../lib/getRepos', () => ({
getAllProjects: jest.fn().mockImplementationOnce(() => projectsMock) getRepos: jest.fn().mockImplementation(() => reposMock)
}))
jest.mock('../../lib/github', () => ({
getGithubRepos: jest.fn().mockImplementationOnce(() => reposMock)
})) }))
describe('app: /page', () => { describe('app: /page', () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react' 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' import mockData from '../../../tests/__fixtures__/giphy.json'
jest.setTimeout(30000)
describe('NotFoundPage', () => { describe('NotFoundPage', () => {
it('renders correctly', async () => { it('renders correctly', async () => {
render(<NotFoundPage />) render(<NotFoundPage />)

View File

@ -3,7 +3,7 @@
import { MouseEvent, useEffect, useState } from 'react' import { MouseEvent, useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { getRandomGif } from '../../app/actions' import { getRandomGif } from '@/lib/getRandomGif'
import Button from '../Button' import Button from '../Button'
import styles from './index.module.css' 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' import styles from './index.module.css'
export default function Availability() { export default function Availability() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import Link from 'next/link' 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' import styles from './index.module.css'
type Props = { type Props = {
@ -14,7 +14,6 @@ export default function LogoUnit({ small }: Props) {
<Link <Link
className={`${styles.logounit} ${small ? styles.small : null}`} className={`${styles.logounit} ${small ? styles.small : null}`}
href="/" href="/"
aria-current={!small ? 'page' : null}
> >
<Logo className={styles.logo} /> <Logo className={styles.logo} />
<H className={`p-name ${styles.title}`}> <H className={`p-name ${styles.title}`}>

View File

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

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion' 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 { getAnimationProps } from '../Transitions'
import { NetworkLink } from './NetworkLink' import { NetworkLink } from './NetworkLink'
import styles from './index.module.css' import styles from './index.module.css'
@ -14,7 +14,6 @@ type Props = {
const containerVariants = { const containerVariants = {
enter: { enter: {
transition: { transition: {
delay: 0.2,
staggerChildren: 0.1 staggerChildren: 0.1
} }
} }
@ -22,7 +21,7 @@ const containerVariants = {
export default function Networks({ label, small }: Props) { export default function Networks({ label, small }: Props) {
const shouldReduceMotion = useReducedMotion() const shouldReduceMotion = useReducedMotion()
const animationProps = getAnimationProps(shouldReduceMotion) const animationProps = getAnimationProps(shouldReduceMotion || false)
return ( return (
<LazyMotion features={domAnimation}> <LazyMotion features={domAnimation}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,11 @@
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import Repo from '@/types/repo'
import Repositories from '.' import Repositories from '.'
import repos from '../../../tests/__fixtures__/repos.json' import repos from '../../../tests/__fixtures__/repos.json'
import Repo from '../../types/repo'
describe('Repositories', () => { describe('Repositories', () => {
it('renders correctly', () => { it('renders correctly', () => {
const { container } = render(<Repositories repos={repos as Repo[]} />) const { container } = render(<Repositories repos={repos as Repo[]} />)
expect(container.firstChild).toBeInTheDocument() expect(container.firstChild).toBeInTheDocument()
}) })
it('return nothing when no repos are passed', () => {
const { container } = render(<Repositories repos={null} />)
expect(container.firstChild).not.toBeInTheDocument()
})
}) })

View File

@ -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' export { default } from './Repositories'
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>
</>
)
}

View File

@ -1,7 +1,7 @@
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import Repo from '@/types/repo'
import Repository from '.'
import repos from '../../../tests/__fixtures__/repos.json' import repos from '../../../tests/__fixtures__/repos.json'
import Repo from '../../types/repo'
import Repository from '../Repository'
describe('Repository', () => { describe('Repository', () => {
it('renders correctly', () => { it('renders correctly', () => {
@ -17,7 +17,7 @@ describe('Repository', () => {
} }
const { container } = render(<Repository repo={repo1 as Repo} />) const { container } = render(<Repository repo={repo1 as Repo} />)
expect(container.querySelector('h3 > a').getAttribute('href')).toBe( expect(container.querySelector('h3 > a')?.getAttribute('href')).toBe(
repo1.html_url repo1.html_url
) )
}) })

View File

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

View File

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

View File

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

View File

@ -1,13 +1,9 @@
import meta from '../../../_content/meta.json' import { constructVcard, init } from './_utils'
import { constructVcard, init, toDataURL } from './_utils'
const metaMock = { jest.mock('./imageToDataUrl', () => ({
...meta, __esModule: true,
name: meta.author.name, imageToDataUrl: jest.fn().mockResolvedValue('data:image/png;base64,')
label: meta.author.label, }))
email: meta.author.email,
profiles: [...meta.profiles]
}
describe('Vcard/_utils', () => { describe('Vcard/_utils', () => {
beforeEach(() => { beforeEach(() => {
@ -15,17 +11,12 @@ describe('Vcard/_utils', () => {
}) })
it('combined vCard download process finishes', async () => { it('combined vCard download process finishes', async () => {
await init(metaMock) await init()
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1) expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1)
}) })
it('vCard can be constructed', async () => { it('vCard can be constructed', () => {
const vcard = await constructVcard(metaMock, 'data:image/jpeg;base64,00') const vcard = constructVcard('data:image/jpeg;base64,00')
expect(vcard).toBeDefined() 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 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) { export function constructVcard(dataUrl: string) {
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()
const blog = meta.profiles.filter(({ network }) => network === 'Blog')[0].url const blog = meta.profiles.filter(({ network }) => network === 'Blog')[0].url
const github = meta.profiles.filter(({ network }) => network === 'GitHub')[0] const github = meta.profiles.filter(({ network }) => network === 'GitHub')[0]
.url .url
// stripping this data out of base64 string is required // stripping this data out of base64 string is required
// for vcard to actually display the image for whatever reason // for vcard to actually display the image for whatever reason
// const dataUrlCleaned = dataUrl.split('data:image/jpeg;base64,').join('') const dataUrlCleaned = dataUrl.replace(
// contact.set('photo', dataUrlCleaned, { encoding: 'b', type: 'JPEG' }) /^data:image\/(png|jpg|jpeg);base64,/,
contact.set('fn', meta.name) ''
contact.set('title', meta.label) )
contact.set('email', meta.email) const vCard = `BEGIN:VCARD
contact.set('nickname', 'kremalicious') VERSION:3.0
contact.set('url', meta.url, { type: 'Portfolio' }) PHOTO;ENCODING=B;TYPE=JPEG:${dataUrlCleaned},
contact.add('url', blog, { type: 'Blog' }) FN:${meta.author.name}
contact.add('x-socialprofile', github, { type: 'GitHub' }) 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) { export async function init() {
// first, convert the avatar to base64, then construct all vCard elements const dataUrl = await imageToDataUrl(avatar.src)
const dataUrl = await toDataURL(meta.photoSrc, 'image/jpeg') const vcard = constructVcard(dataUrl)
const vcard = await constructVcard(meta, dataUrl)
// Construct the download from a blob of the just constructed vCard, // Construct the download from a blob of the just constructed vCard,
const { addressbook } = meta const { addressbook } = meta
const name = addressbook.split('/').join('') const name = addressbook.split('/').join('')
const blob = new Blob([vcard], { const blob = new Blob([vcard], { type: 'text/x-vcard' })
type: 'text/x-vcard'
})
// save it to user's file system // save it to user's file system
saveAs(blob, name) 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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Vcard from '.' import Vcard from '.'
jest.mock('./imageToDataUrl', () => ({
__esModule: true,
imageToDataUrl: jest.fn().mockResolvedValue('data:image/png;base64,')
}))
describe('Vcard', () => { describe('Vcard', () => {
beforeEach(() => { beforeEach(() => {
global.URL.createObjectURL = jest.fn() global.URL.createObjectURL = jest.fn()

View File

@ -1,25 +1,13 @@
'use client' 'use client'
import meta from '../../../_content/meta.json' import { MouseEvent } from 'react'
import meta from '@content/meta.json'
export default function Vcard() { export default function Vcard() {
const { name, label, email } = meta.author const handleAddressbookClick = (e: MouseEvent) => {
const vCardMeta = {
...meta,
/// photoSrc,
name,
label,
email,
profiles: meta.profiles
}
const handleAddressbookClick = (e) => {
e.preventDefault() e.preventDefault()
import('./_utils').then(({ init }) => { import('./_utils').then(({ init }) => init())
init(vCardMeta)
})
} }
return ( 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' 'use server'
import { cache } from 'react'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { GiphyFetch } from '@giphy/js-fetch-api' import { GiphyFetch } from '@giphy/js-fetch-api'
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) { export async function getRandomGif(tag: string, pathname?: string) {
try { try {
// Famous last words: // Famous last words:
@ -29,8 +11,8 @@ export async function getRandomGif(tag: string, pathname?: string) {
const { data } = await giphyClient.random({ tag }) const { data } = await giphyClient.random({ tag })
const gif = data.images.original.mp4 const gif = data.images.original.mp4
return gif return gif
} catch (error) { } catch (error: unknown) {
console.error(error.message) console.error((error as Error).message)
} }
if (pathname) revalidatePath(pathname) if (pathname) revalidatePath(pathname)

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 = { declare type ImageType = {
src: string src: string
width: number width?: number
height: number height?: number
format: string format?: string
blurDataURL?: string blurDataURL?: string
} }

View File

@ -1,20 +1,19 @@
import nextJest from 'next/jest' import nextJest from 'next/jest.js'
import type { Config } from 'jest'
const createJestConfig = nextJest({ const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './' dir: './'
}) })
// Add any custom config to be passed to Jest /** @type {import('jest').Config} */
const customJestConfig: Config = { const customJestConfig = {
rootDir: '../', // = / rootDir: '../',
// Add more setup options before each test is run setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.ts'],
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
moduleDirectories: ['node_modules', '<rootDir>/src'], moduleDirectories: ['node_modules', '<rootDir>/src'],
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
moduleNameMapper: { moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
'^@content/(.*)$': '<rootDir>/_content/$1',
'^@generated/(.*)$': '<rootDir>/generated/$1',
'^.+\\.(svg)$': '<rootDir>/tests/__mocks__/svgr-mock.tsx' '^.+\\.(svg)$': '<rootDir>/tests/__mocks__/svgr-mock.tsx'
}, },
collectCoverage: true, collectCoverage: true,
@ -38,7 +37,6 @@ const customJestConfig: Config = {
// https://github.com/vercel/next.js/issues/35634#issuecomment-1115250297 // https://github.com/vercel/next.js/issues/35634#issuecomment-1115250297
async function jestConfig() { async function jestConfig() {
const nextJestConfig = await createJestConfig(customJestConfig)() const nextJestConfig = await createJestConfig(customJestConfig)()
// /node_modules/ is the first pattern
nextJestConfig.transformIgnorePatterns[0] = '/node_modules/(?!uuid|remark)/' nextJestConfig.transformIgnorePatterns[0] = '/node_modules/(?!uuid|remark)/'
return nextJestConfig return nextJestConfig
} }

View File

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

View File

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