mirror of
https://github.com/kremalicious/portfolio.git
synced 2024-12-22 01:03:20 +01:00
Merge pull request #1285 from kremalicious/images
content generation via build script
This commit is contained in:
commit
9d3f7e4d2c
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@ -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') }}
|
||||
|
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@ -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
3
.gitignore
vendored
@ -39,4 +39,5 @@ coverage
|
||||
public/matomo.js
|
||||
# public/favicon*
|
||||
# public/apple-touch-icon*
|
||||
# public/manifest*
|
||||
# public/manifest*
|
||||
generated
|
@ -9,6 +9,9 @@
|
||||
"^(react/(.*)$)|^(react$)",
|
||||
"^(next/(.*)$)|^(next$)",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^(@/(.*)$)|^(@$)",
|
||||
"^(@content/(.*)$)|^(@content$)",
|
||||
"^(@generated/(.*)$)|^(@generated$)",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": false,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
3418
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@ -8,19 +8,20 @@
|
||||
"author": "Matthias Kretschmann <m@kretschmann.io>",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "next",
|
||||
"start": "npm run prebuild && next",
|
||||
"build": "next build",
|
||||
"preview": "npm run build && next start",
|
||||
"export": "next export",
|
||||
"preview": "next start",
|
||||
"export": "npm run prebuild && next export",
|
||||
"typecheck": "tsc",
|
||||
"lint:js": "next lint",
|
||||
"lint:css": "stylelint ./src/**/*.css",
|
||||
"lint": "npm run lint:js && npm run lint:css",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx,css}'",
|
||||
"jest": "jest --coverage -c tests/jest.config.ts",
|
||||
"test": "NODE_ENV=test npm run lint && npm run typecheck && npm run jest",
|
||||
"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%",
|
||||
|
48
scripts/content/content.test-disabled.ts
Normal file
48
scripts/content/content.test-disabled.ts
Normal file
@ -0,0 +1,48 @@
|
||||
// import { getProjectBySlug, getProjectImages } from '.'
|
||||
|
||||
// jest.setTimeout(20000)
|
||||
|
||||
// describe('lib/content', () => {
|
||||
// test('getProjectSlugs', async () => {
|
||||
// const slugs: string[] = getProjectSlugs()
|
||||
// expect(slugs).toContain('ipixelpad')
|
||||
// })
|
||||
|
||||
// test('getProjectBySlug', async () => {
|
||||
// const project = await getProjectBySlug('ipixelpad')
|
||||
// expect(project).toBeDefined()
|
||||
// expect(project.images[0].src).toContain('ipixelpad')
|
||||
// })
|
||||
|
||||
// test('getProjectBySlug returns early', async () => {
|
||||
// const project = await getProjectBySlug('gibberish')
|
||||
// expect(project).not.toBeDefined()
|
||||
// })
|
||||
|
||||
// test('getProjectImages', async () => {
|
||||
// const images = await getProjectImages('ipixelpad')
|
||||
// expect(images).toBeDefined()
|
||||
// expect(images[0].src).toContain('ipixelpad')
|
||||
// // expect(images[0].blurDataURL).toBeDefined()
|
||||
// expect(images[0].width).toBeDefined()
|
||||
// expect(images[0].height).toBeDefined()
|
||||
// expect(images[0].format).toBeDefined()
|
||||
// })
|
||||
|
||||
// test('getAllProjects', async () => {
|
||||
// const projects = await getAllProjects([
|
||||
// 'title',
|
||||
// 'description',
|
||||
// 'slug',
|
||||
// 'images',
|
||||
// 'techstack',
|
||||
// 'links'
|
||||
// ])
|
||||
// expect(projects).toBeDefined()
|
||||
// })
|
||||
|
||||
// test('getAllProjects without fields', async () => {
|
||||
// const projects = await getAllProjects()
|
||||
// expect(projects).toBeDefined()
|
||||
// })
|
||||
// })
|
38
scripts/content/generateProjects.ts
Normal file
38
scripts/content/generateProjects.ts
Normal 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
36
scripts/content/images.ts
Normal 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
|
||||
}
|
11
scripts/content/rgbDataURL.ts
Normal file
11
scripts/content/rgbDataURL.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Pixel GIF code adapted from https://stackoverflow.com/a/33919020/266535
|
||||
const keyStr =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
|
||||
const triplet = (e1: number, e2: number, e3: number) =>
|
||||
keyStr.charAt(e1 >> 2) +
|
||||
keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
|
||||
keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
|
||||
keyStr.charAt(e3 & 63)
|
||||
|
||||
export const rgbDataURL = (r: number, g: number, b: number) =>
|
||||
`data:image/gif;base64,R0lGODlhAQABAPAA${triplet(0, r, g) + triplet(b, 255, 255)}/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`
|
20
scripts/content/transformProject.ts
Normal file
20
scripts/content/transformProject.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import ProjectType from '@/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
|
||||
}
|
@ -86,8 +86,8 @@ async function buildFavicons() {
|
||||
Add this to src/components/Meta/Favicon.tsx:
|
||||
${outputMeta}
|
||||
`)
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
} catch (error: unknown) {
|
||||
console.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,8 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import prepend from 'prepend'
|
||||
import slugify from 'slugify'
|
||||
import ora from 'ora'
|
||||
import path from 'path'
|
||||
import slugify from 'slugify'
|
||||
|
||||
const templatePath = path.join(process.cwd(), 'scripts', 'new.yml')
|
||||
const template = fs.readFileSync(templatePath).toString()
|
||||
@ -26,7 +24,13 @@ const newContents = template
|
||||
.split('SLUG')
|
||||
.join(titleSlug)
|
||||
|
||||
prepend(projects, newContents, (error) => {
|
||||
if (error) spinner.fail(error)
|
||||
spinner.succeed(`Added '${title}' to top of projects.yml file.`)
|
||||
// prepend newContents to projects.yml file
|
||||
fs.readFile(projects, 'utf8', (error, data) => {
|
||||
if (error) spinner.fail(error.message)
|
||||
|
||||
fs.writeFile(projects, newContents + data, (error) => {
|
||||
if (error) spinner.fail(error.message)
|
||||
|
||||
spinner.succeed(`Added '${title}' to top of projects.yml file.`)
|
||||
})
|
||||
})
|
||||
|
3
scripts/prebuild.ts
Normal file
3
scripts/prebuild.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { generateProjects } from './content/generateProjects'
|
||||
|
||||
generateProjects()
|
@ -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 }))
|
||||
}
|
||||
|
@ -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', () => {
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { dataLocation } from '../../../tests/__fixtures__/location'
|
||||
import Layout from '../layout'
|
||||
|
||||
describe('app: /layout', () => {
|
||||
// suppress error "Warning: validateDOMNesting(...): <html> cannot appear as a child of <div>"
|
||||
// https://github.com/testing-library/react-testing-library/issues/1250
|
||||
let originalError
|
||||
let originalError: {
|
||||
(...data: any[]): void
|
||||
(message?: any, ...optionalParams: any[]): void
|
||||
(...data: any[]): void
|
||||
(message?: any, ...optionalParams: any[]): void
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
originalError = console.error
|
||||
|
@ -1,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', () => {
|
||||
|
@ -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'
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Metadata } from 'next'
|
||||
import NotFound from '../components/404'
|
||||
import NotFound from '@/components/404'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Shenanigans`,
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -2,6 +2,6 @@
|
||||
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
|
||||
export function Providers({ children }) {
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <ThemeProvider attribute="class">{children}</ThemeProvider>
|
||||
}
|
||||
|
@ -1,9 +1,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 />)
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Header from './Header'
|
||||
import Header from '.'
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders correctly', async () => {
|
||||
|
@ -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'
|
||||
|
@ -7,7 +7,7 @@ type Props = {
|
||||
allowedHosts: string[]
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
export async function generateMetadata({ params }: { params: Props }) {
|
||||
const isAllowedHost = params.allowedHosts.includes(window.location.hostname)
|
||||
|
||||
if (!isAllowedHost) {
|
||||
|
@ -4,16 +4,16 @@ import Icon from '.'
|
||||
describe('Icon', () => {
|
||||
it('renders correctly', () => {
|
||||
const { container, rerender } = render(<Icon name={'Compass'} />)
|
||||
expect(container.firstChild.nodeName).toBe('svg')
|
||||
expect(container.firstChild?.nodeName).toBe('svg')
|
||||
|
||||
rerender(<Icon name={'Download'} />)
|
||||
expect(container.firstChild.nodeName).toBe('svg')
|
||||
expect(container.firstChild?.nodeName).toBe('svg')
|
||||
|
||||
rerender(<Icon name={'Styleguide'} />)
|
||||
expect(container.firstChild.nodeName).toBe('svg')
|
||||
expect(container.firstChild?.nodeName).toBe('svg')
|
||||
|
||||
rerender(<Icon name={'Blog'} />)
|
||||
expect(container.firstChild.nodeName).toBe('svg')
|
||||
expect(container.firstChild?.nodeName).toBe('svg')
|
||||
})
|
||||
|
||||
it('does not render with unknown name', () => {
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -1 +1 @@
|
||||
export * from './Location'
|
||||
export { default } from './Location'
|
||||
|
@ -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}`}>
|
||||
|
@ -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', () => {
|
||||
|
@ -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}>
|
||||
|
@ -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({
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
@ -16,6 +16,8 @@ describe('ProjectImage', () => {
|
||||
})
|
||||
|
||||
it('returns without errors without image', async () => {
|
||||
render(<ProjectImage image={null} alt={project.title} sizes="100vw" />)
|
||||
render(
|
||||
<ProjectImage image={null as any} alt={project.title} sizes="100vw" />
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -1,24 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
LazyMotion,
|
||||
domAnimation,
|
||||
m,
|
||||
useAnimation,
|
||||
useReducedMotion
|
||||
} from 'framer-motion'
|
||||
import ImageType from '../../types/image'
|
||||
import { getAnimationProps } from '../Transitions'
|
||||
import 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
|
||||
}
|
||||
|
@ -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 -
|
||||
|
@ -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}>
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
@ -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()
|
||||
})
|
||||
})
|
14
src/components/Repositories/Repositories.tsx
Normal file
14
src/components/Repositories/Repositories.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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'
|
||||
|
@ -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
|
||||
)
|
||||
})
|
||||
|
@ -1,4 +1,4 @@
|
||||
import Repo from '../../types/repo'
|
||||
import Repo from '@/types/repo'
|
||||
import Icon from '../Icon'
|
||||
import styles from './index.module.css'
|
||||
|
||||
|
@ -13,7 +13,7 @@ export function getIconName(theme: string) {
|
||||
|
||||
export default function ThemeSwitch() {
|
||||
const { theme, themes, resolvedTheme, setTheme } = useTheme()
|
||||
const iconName = getIconName(resolvedTheme)
|
||||
const iconName = getIconName(resolvedTheme || '')
|
||||
|
||||
// hydration errors workaround
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
@ -47,6 +47,12 @@ export const moveInBottom = {
|
||||
}
|
||||
}
|
||||
|
||||
export const fadeIn = {
|
||||
initial: { opacity: 0 },
|
||||
enter: { opacity: 1 },
|
||||
exit: { opacity: 0 }
|
||||
}
|
||||
|
||||
export function getAnimationProps(shouldReduceMotion: boolean) {
|
||||
return {
|
||||
initial: `${shouldReduceMotion ? 'enter' : 'initial'}`,
|
||||
|
@ -1,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()
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
|
52
src/components/Vcard/imageToDataUrl.test.ts
Normal file
52
src/components/Vcard/imageToDataUrl.test.ts
Normal 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')
|
||||
})
|
||||
})
|
16
src/components/Vcard/imageToDataUrl.ts
Normal file
16
src/components/Vcard/imageToDataUrl.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
@ -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()
|
||||
|
@ -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 (
|
||||
|
@ -1,53 +0,0 @@
|
||||
import {
|
||||
getAllProjects,
|
||||
getProjectBySlug,
|
||||
getProjectImages,
|
||||
getProjectSlugs
|
||||
} from './content'
|
||||
|
||||
jest.setTimeout(20000)
|
||||
|
||||
describe('lib/content', () => {
|
||||
test('getProjectSlugs', async () => {
|
||||
const slugs: string[] = getProjectSlugs()
|
||||
expect(slugs).toContain('ipixelpad')
|
||||
})
|
||||
|
||||
test('getProjectBySlug', async () => {
|
||||
const project = await getProjectBySlug('ipixelpad')
|
||||
expect(project).toBeDefined()
|
||||
expect(project.images[0].src).toContain('ipixelpad')
|
||||
})
|
||||
|
||||
test('getProjectBySlug returns early', async () => {
|
||||
const project = await getProjectBySlug('gibberish')
|
||||
expect(project).not.toBeDefined()
|
||||
})
|
||||
|
||||
test('getProjectImages', async () => {
|
||||
const images = await getProjectImages('ipixelpad')
|
||||
expect(images).toBeDefined()
|
||||
expect(images[0].src).toContain('ipixelpad')
|
||||
// expect(images[0].blurDataURL).toBeDefined()
|
||||
expect(images[0].width).toBeDefined()
|
||||
expect(images[0].height).toBeDefined()
|
||||
expect(images[0].format).toBeDefined()
|
||||
})
|
||||
|
||||
test('getAllProjects', async () => {
|
||||
const projects = await getAllProjects([
|
||||
'title',
|
||||
'description',
|
||||
'slug',
|
||||
'images',
|
||||
'techstack',
|
||||
'links'
|
||||
])
|
||||
expect(projects).toBeDefined()
|
||||
})
|
||||
|
||||
test('getAllProjects without fields', async () => {
|
||||
const projects = await getAllProjects()
|
||||
expect(projects).toBeDefined()
|
||||
})
|
||||
})
|
@ -1,85 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import yaml from 'js-yaml'
|
||||
import { join } from 'path'
|
||||
import sharp from 'sharp'
|
||||
import type ImageType from '../types/image'
|
||||
import type ProjectType from '../types/project'
|
||||
import { markdownToHtml } from './markdown'
|
||||
|
||||
const contentDirectory = join(process.cwd(), '_content')
|
||||
const imagesDirectory = join(process.cwd(), 'public', 'images')
|
||||
const projects = yaml.load(
|
||||
fs.readFileSync(`${contentDirectory}/projects.yml`, 'utf8')
|
||||
) as Partial<ProjectType>[]
|
||||
|
||||
export function getProjectSlugs() {
|
||||
return projects.map(({ slug }: { slug: string }) => slug)
|
||||
}
|
||||
|
||||
// Pixel GIF code adapted from https://stackoverflow.com/a/33919020/266535
|
||||
// const keyStr =
|
||||
// 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
|
||||
|
||||
// const triplet = (e1: number, e2: number, e3: number) =>
|
||||
// keyStr.charAt(e1 >> 2) +
|
||||
// keyStr.charAt(((e1 & 3) << 4) | (e2 >> 4)) +
|
||||
// keyStr.charAt(((e2 & 15) << 2) | (e3 >> 6)) +
|
||||
// keyStr.charAt(e3 & 63)
|
||||
|
||||
// export const rgbDataURL = ({ r, g, b }: { r: number; g: number; b: number }) =>
|
||||
// `data:image/gif;base64,R0lGODlhAQABAPAA${
|
||||
// triplet(0, r, g) + triplet(b, 255, 255)
|
||||
// }/yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==`
|
||||
|
||||
export async function getProjectImages(slug: string) {
|
||||
const allImages = fs.readdirSync(imagesDirectory, 'utf8')
|
||||
const projectImages = allImages.filter((image) => image.includes(slug))
|
||||
|
||||
let images: ImageType[] = []
|
||||
|
||||
await Promise.all(
|
||||
projectImages.map(async (image) => {
|
||||
const file = `${imagesDirectory}/${image}`
|
||||
const transformer = sharp(file)
|
||||
const { width, height, format } = await transformer.metadata()
|
||||
// const { dominant } = await transformer.stats()
|
||||
// const blurDataURL = rgbDataURL(dominant)
|
||||
|
||||
const imageType: ImageType = {
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
// blurDataURL,
|
||||
src: `/images/${image}`
|
||||
}
|
||||
images.push(imageType)
|
||||
})
|
||||
)
|
||||
// Sort images by sequentially numbered name to be sure
|
||||
images = images.sort((a, b) => a.src.localeCompare(b.src))
|
||||
return images
|
||||
}
|
||||
|
||||
export async function getProjectBySlug(slug: string, fields: string[] = []) {
|
||||
const project = projects.find((item) => item.slug === slug)
|
||||
if (!project) return
|
||||
|
||||
// enhance data with additional fields
|
||||
const descriptionHtml = await markdownToHtml(project.description)
|
||||
project.descriptionHtml = descriptionHtml
|
||||
|
||||
const images = await getProjectImages(slug)
|
||||
project.images = images
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
export async function getAllProjects(
|
||||
fields: string[] = []
|
||||
): Promise<Partial<ProjectType>[]> {
|
||||
const slugs = getProjectSlugs()
|
||||
const projects = await Promise.all(
|
||||
slugs.map(async (slug: string) => await getProjectBySlug(slug, fields))
|
||||
)
|
||||
return projects
|
||||
}
|
6
src/lib/getAllSlugs.ts
Normal file
6
src/lib/getAllSlugs.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import projects from '@generated/projects.json'
|
||||
|
||||
export function getAllSlugs() {
|
||||
const slugs = projects.map(({ slug }: { slug: string }) => slug)
|
||||
return slugs
|
||||
}
|
20
src/lib/getLocation.ts
Normal file
20
src/lib/getLocation.ts
Normal 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)
|
||||
}
|
||||
})
|
5
src/lib/getProjectBySlug.ts
Normal file
5
src/lib/getProjectBySlug.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import projects from '@generated/projects.json'
|
||||
|
||||
export function getProjectBySlug(slug: string) {
|
||||
return projects.find((item) => item.slug === slug)
|
||||
}
|
@ -1,26 +1,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
57
src/lib/getRepos.test.ts
Normal 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
64
src/lib/getRepos.ts
Normal 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)
|
||||
}
|
||||
})
|
@ -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
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
declare type ImageType = {
|
||||
src: string
|
||||
width: number
|
||||
height: number
|
||||
format: string
|
||||
width?: number
|
||||
height?: number
|
||||
format?: string
|
||||
blurDataURL?: string
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -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
|
@ -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"]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user