Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions .github/scripts/writeBadgeData.mts
Original file line number Diff line number Diff line change
@@ -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 <T,>(filePath: string): Promise<T> =>
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<number> => {
const coverageSummary =
await readJsonFile<CoverageSummary>(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<string> => {
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<void> => {
await fs.writeFile(
path.resolve(badgeDataDirectory, fileName),
`${JSON.stringify(payload, null, 4)}\n`,
'utf8',
);
};

const main = async (): Promise<void> => {
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();
55 changes: 55 additions & 0 deletions .github/workflows/readme-badges.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test-results/
*concatenated.txt
.react-router/
.claude/
.github/badge-data/

# env
.env
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down
3 changes: 2 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -227,6 +227,7 @@ export default defineConfig(
files: [
'eslint.config.js',
'src/utils/build/**/*.ts',
'.github/scripts/**/*.{ts,mts}',
'e2e/support/serveDistClient.ts',
],
rules: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ProjectMarkdown
markdown={
'[![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)\n[![Netlify status](https://api.netlify.com/api/v1/badges/example/deploy-status)](https://app.netlify.com/sites/example/deploys)'
}
repo="test-repo"
/>,
);

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(
<ProjectMarkdown
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ type ProjectMarkdownProps = {
repo: string;
};

const BADGE_IMAGE_ORIGINS = new Set([
'https://api.netlify.com',
'https://badge.fury.io',
'https://d25lcipzij17d.cloudfront.net',
'https://img.shields.io',
]);

function isBadgeImageUrl(src: string | undefined): boolean {
if (!src) {
return false;
}

try {
const normalizedUrl = src.startsWith('//') ? `https:${src}` : src;
const url = new URL(normalizedUrl);

if (!BADGE_IMAGE_ORIGINS.has(url.origin)) {
return false;
}

if (url.origin === 'https://api.netlify.com') {
return url.pathname.startsWith('/api/v1/badges/');
}

if (url.origin === 'https://d25lcipzij17d.cloudfront.net') {
return url.pathname.endsWith('/badge.svg');
}

return true;
} catch {
return false;
}
}

const ProjectMarkdown = ({
markdown,
repo,
Expand Down Expand Up @@ -129,13 +163,17 @@ const ProjectMarkdown = ({
);
},
img({ node: _node, className, src, alt, ...props }) {
const imageClassName = isBadgeImageUrl(src)
? styles.badgeImage
: styles.markdownImage;

return (
<img
alt={alt}
className={
className
? `${styles.markdownImage} ${className}`
: styles.markdownImage
? `${imageClassName} ${className}`
: imageClassName
}
decoding="async"
src={src}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,6 @@
margin-bottom: 0.5rem;
}

img {
border-radius: 4px;
margin: 0.5rem 0 0 0;

@media (max-width: 768px) {
max-width: 100%;
height: auto;
}
}

pre {
overflow-x: auto;
margin: 0.5rem 0;
Expand Down Expand Up @@ -120,6 +110,17 @@
display: block;
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 0.5rem 0 0 0;
}

.badgeImage {
display: inline-block;
max-width: 100%;
height: auto;
margin: 0;
border-radius: 0;
vertical-align: middle;
}
Comment on lines +117 to +124
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.badgeImage won’t override the existing .markdownContainer img styles for margin and border-radius because .markdownContainer img has higher selector specificity (class + element) than .badgeImage (class only). As a result, badge images will still inherit the 0.5rem top margin and 4px border radius from the container rule. Consider scoping the generic image rule to .markdownImage instead, or increase specificity for the badge rule (e.g., .markdownContainer img.badgeImage / .markdownContainer .badgeImage) so the badge overrides actually take effect.

Copilot uses AI. Check for mistakes.

.codeBlock {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ declare const markdownContainer: string;
declare const dateBadge: string;
declare const videoPlayer: string;
declare const markdownImage: string;
declare const badgeImage: string;
declare const codeBlock: string;
declare const inlineCode: string;

Expand All @@ -18,6 +19,7 @@ export {
dateBadge,
videoPlayer,
markdownImage,
badgeImage,
codeBlock,
inlineCode
};
Expand All @@ -27,6 +29,7 @@ declare const __default_export__: {
dateBadge: typeof dateBadge;
videoPlayer: typeof videoPlayer;
markdownImage: typeof markdownImage;
badgeImage: typeof badgeImage;
codeBlock: typeof codeBlock;
inlineCode: typeof inlineCode;
};
Expand Down
Loading
Loading