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:
commit
9d3f7e4d2c
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@ -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') }}
|
||||||
|
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@ -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
1
.gitignore
vendored
@ -40,3 +40,4 @@ public/matomo.js
|
|||||||
# public/favicon*
|
# public/favicon*
|
||||||
# public/apple-touch-icon*
|
# public/apple-touch-icon*
|
||||||
# public/manifest*
|
# public/manifest*
|
||||||
|
generated
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
3420
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>",
|
"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%",
|
||||||
|
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:
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
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 { 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 }))
|
||||||
}
|
}
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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'
|
||||||
|
@ -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`,
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
|
@ -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 />)
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -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() {
|
||||||
|
@ -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
|
|
||||||
|
@ -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'
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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'
|
||||||
|
@ -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) {
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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} />
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -1 +1 @@
|
|||||||
export * from './Location'
|
export { default } from './Location'
|
||||||
|
@ -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}`}>
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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}>
|
||||||
|
@ -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({
|
||||||
|
@ -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}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
@ -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" />
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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 -
|
||||||
|
@ -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}>
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -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()
|
|
||||||
})
|
|
||||||
})
|
})
|
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'
|
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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'}`,
|
||||||
|
@ -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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
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 { 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()
|
||||||
|
@ -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 (
|
||||||
|
@ -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'
|
'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
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 = {
|
declare type ImageType = {
|
||||||
src: string
|
src: string
|
||||||
width: number
|
width?: number
|
||||||
height: number
|
height?: number
|
||||||
format: string
|
format?: string
|
||||||
blurDataURL?: string
|
blurDataURL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
@ -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
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user