diff --git a/.github/scripts/writeBadgeData.mts b/.github/scripts/writeBadgeData.mts new file mode 100644 index 0000000..810854c --- /dev/null +++ b/.github/scripts/writeBadgeData.mts @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type CoverageMetric = { + covered: number; + total: number; +}; + +type CoverageSummary = { + total?: { + lines?: CoverageMetric; + }; +}; + +type BadgePayload = { + color: string; + label: string; + message: string; + schemaVersion: 1; +}; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(currentDirectory, '..', '..'); +const badgeDataDirectory = path.resolve(repoRoot, '.github', 'badge-data'); +const coverageSummaryPath = path.resolve( + repoRoot, + 'coverage', + 'coverage-summary.json', +); + +const readJsonFile = async (filePath: string): Promise => + JSON.parse(await fs.readFile(filePath, 'utf8')) as T; + +const createBadgePayload = ( + label: string, + message: string, + color: string, +): BadgePayload => ({ + color, + label, + message, + schemaVersion: 1, +}); + +const resolveCoverageColor = (coveragePercent: number): string => { + if (coveragePercent >= 90) { + return 'brightgreen'; + } + + if (coveragePercent >= 80) { + return 'green'; + } + + if (coveragePercent >= 70) { + return 'yellowgreen'; + } + + if (coveragePercent >= 60) { + return 'yellow'; + } + + if (coveragePercent >= 50) { + return 'orange'; + } + + return 'red'; +}; + +const formatCoveragePercent = (coveragePercent: number): string => { + const roundedPercent = Math.round(coveragePercent * 10) / 10; + + return `${roundedPercent.toFixed(1).replace(/\.0$/, '')}%`; +}; + +const readCoveragePercent = async (): Promise => { + const coverageSummary = + await readJsonFile(coverageSummaryPath); + const lines = coverageSummary.total?.lines; + + if (!lines) { + throw new Error( + `Coverage summary at ${coverageSummaryPath} is missing total line metrics.`, + ); + } + + if (lines.total === 0) { + throw new Error('Coverage total is zero.'); + } + + return (lines.covered / lines.total) * 100; +}; + +const readNodeVersion = async (): Promise => { + const nvmrcPath = path.resolve(repoRoot, '.nvmrc'); + const nodeVersion = (await fs.readFile(nvmrcPath, 'utf8')).trim(); + + if (!nodeVersion) { + throw new Error('.nvmrc is empty.'); + } + + return nodeVersion.replace(/^v/i, ''); +}; + +const writeBadgeFile = async ( + fileName: string, + payload: BadgePayload, +): Promise => { + await fs.writeFile( + path.resolve(badgeDataDirectory, fileName), + `${JSON.stringify(payload, null, 4)}\n`, + 'utf8', + ); +}; + +const main = async (): Promise => { + await fs.mkdir(badgeDataDirectory, { + recursive: true, + }); + + const coveragePercent = await readCoveragePercent(); + const nodeVersion = await readNodeVersion(); + + await Promise.all([ + writeBadgeFile( + 'coverage.json', + createBadgePayload( + 'coverage', + formatCoveragePercent(coveragePercent), + resolveCoverageColor(coveragePercent), + ), + ), + writeBadgeFile( + 'node.json', + createBadgePayload('node', nodeVersion, '5FA04E'), + ), + ]); +}; + +void main(); diff --git a/.github/workflows/readme-badges.yml b/.github/workflows/readme-badges.yml new file mode 100644 index 0000000..44ced77 --- /dev/null +++ b/.github/workflows/readme-badges.yml @@ -0,0 +1,55 @@ +name: readme badges + +on: + push: + branches: [master, main] + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish-badge-data: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v5 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: install dependencies + run: npm ci + + - name: generate badge data + run: npm run coverage:ci + + - name: upload badge data + uses: actions/upload-artifact@v4 + with: + name: badge-data + path: | + coverage/coverage-summary.json + .github/badge-data + if-no-files-found: error + + - name: publish badge data branch + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + rm -rf badge-data-publish + mkdir -p badge-data-publish + cp -r .github/badge-data/. badge-data-publish/ + cd badge-data-publish + git init + git checkout -b badge-data + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add --all + git commit -m "Update readme badge data" + git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push --force origin badge-data diff --git a/.gitignore b/.gitignore index 1a1af95..5d473b2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ test-results/ *concatenated.txt .react-router/ .claude/ +.github/badge-data/ # env .env diff --git a/README.md b/README.md index 0a1a04e..ad261a0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,20 @@ # piech.dev [piech.dev](https://piech.dev) +[![Netlify Status](https://api.netlify.com/api/v1/badges/4df86a71-2a3f-40f9-9bd5-b6dacd4f420c/deploy-status)](https://app.netlify.com/projects/piech-dev/deploys) +[![Web status](https://img.shields.io/website?url=https%3A%2F%2Fpiech.dev&label=web%20status)](https://piech.dev) -[![Netlify Status](https://api.netlify.com/api/v1/badges/4df86a71-2a3f-40f9-9bd5-b6dacd4f420c/deploy-status)](https://app.netlify.com/sites/piech-dev/deploys) +--- + +[![Production E2E tests](https://img.shields.io/github/actions/workflow/status/Tenemo/piech.dev/production-e2e.yml?branch=master&label=production%20e2e)](https://github.com/Tenemo/piech.dev/actions/workflows/production-e2e.yml) +[![CI](https://img.shields.io/github/actions/workflow/status/Tenemo/piech.dev/ci.yml?branch=master&label=ci)](https://github.com/Tenemo/piech.dev/actions/workflows/ci.yml) +[![Tests coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Tenemo/piech.dev/badge-data/coverage.json)](https://github.com/Tenemo/piech.dev/actions/workflows/readme-badges.yml) + +--- + +[![Node version](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Tenemo/piech.dev/badge-data/node.json)](./.nvmrc) + +[![License](https://img.shields.io/github/license/Tenemo/piech.dev)](./LICENSE) My personal page. Over time it turned into a complex project itself: diff --git a/eslint.config.js b/eslint.config.js index da1d868..50bddce 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -59,7 +59,7 @@ export default defineConfig( ]), prettierPluginRecommended, { - files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'], + files: ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'], ...reactHooksPlugin.configs['recommended-latest'], plugins: { '@typescript-eslint': tsPlugin, @@ -227,6 +227,7 @@ export default defineConfig( files: [ 'eslint.config.js', 'src/utils/build/**/*.ts', + '.github/scripts/**/*.{ts,mts}', 'e2e/support/serveDistClient.ts', ], rules: { diff --git a/package.json b/package.json index 04aee99..af845b1 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "coverage:ci": "npm run test:coverage && node --experimental-strip-types .github/scripts/writeBadgeData.mts", "serve:e2e": "node --experimental-strip-types e2e/support/serveDistClient.ts", "test:e2e": "npm run build:skip && playwright test", "test:e2e:production": "cross-env PLAYWRIGHT_BASE_URL=https://piech.dev playwright test", diff --git a/src/features/Projects/ProjectItem/ProjectMarkdown/ProjectMarkdown.spec.tsx b/src/features/Projects/ProjectItem/ProjectMarkdown/ProjectMarkdown.spec.tsx index 628a16e..2e97fd1 100644 --- a/src/features/Projects/ProjectItem/ProjectMarkdown/ProjectMarkdown.spec.tsx +++ b/src/features/Projects/ProjectItem/ProjectMarkdown/ProjectMarkdown.spec.tsx @@ -157,6 +157,23 @@ describe('ProjectMarkdown', () => { expect(screen.getByAltText('Image')).toHaveClass(styles.markdownImage); }); + it('renders README badge images inline instead of as block screenshots', () => { + render( + , + ); + + expect(screen.getByAltText('CI')).toHaveClass(styles.badgeImage); + expect(screen.getByAltText('CI')).not.toHaveClass(styles.markdownImage); + expect(screen.getByAltText('Netlify status')).toHaveClass( + styles.badgeImage, + ); + }); + it('adds an empty captions track to raw HTML videos without one', () => { render(