|
| 1 | +import fs from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | + |
| 4 | +const repoRoot = process.cwd(); |
| 5 | +const coverageSummaryPath = path.join(repoRoot, 'coverage', 'coverage-summary.json'); |
| 6 | +const badgePath = path.join(repoRoot, 'resources', 'coverage-badge.svg'); |
| 7 | +const readmePath = path.join(repoRoot, 'README.md'); |
| 8 | + |
| 9 | +if (!fs.existsSync(coverageSummaryPath)) { |
| 10 | + throw new Error( |
| 11 | + 'coverage/coverage-summary.json was not found. Run `npm run coverage` before generating the badge.', |
| 12 | + ); |
| 13 | +} |
| 14 | + |
| 15 | +const summary = JSON.parse(fs.readFileSync(coverageSummaryPath, 'utf8')); |
| 16 | +const totalCoverage = summary?.total?.lines?.pct; |
| 17 | + |
| 18 | +if (typeof totalCoverage !== 'number' || Number.isNaN(totalCoverage)) { |
| 19 | + throw new Error('Unable to read total line coverage from coverage/coverage-summary.json.'); |
| 20 | +} |
| 21 | + |
| 22 | +const normalizedCoverage = Number(totalCoverage.toFixed(1)); |
| 23 | +const coverageLabel = `${normalizedCoverage}%`; |
| 24 | +const badgeColor = getCoverageColor(normalizedCoverage); |
| 25 | +const svg = buildBadgeSvg('coverage', coverageLabel, badgeColor); |
| 26 | + |
| 27 | +fs.mkdirSync(path.dirname(badgePath), { recursive: true }); |
| 28 | +fs.writeFileSync(badgePath, `${svg}\n`); |
| 29 | + |
| 30 | +const readme = fs.readFileSync(readmePath, 'utf8'); |
| 31 | +const badgeLine = ``; |
| 32 | + |
| 33 | +if ( |
| 34 | + !readme.includes('[ && |
| 35 | + !readme.includes('') |
| 36 | +) { |
| 37 | + throw new Error('README.md does not contain the expected coverage badge line.'); |
| 38 | +} |
| 39 | + |
| 40 | +const nextReadme = readme.replace(/\[!\[(?:codecov|coverage)\][^\n]+\n/, `${badgeLine}\n`); |
| 41 | + |
| 42 | +fs.writeFileSync(readmePath, nextReadme); |
| 43 | + |
| 44 | +function getCoverageColor(coverage) { |
| 45 | + if (coverage >= 90) return '#2ea043'; |
| 46 | + if (coverage >= 80) return '#4c1'; |
| 47 | + if (coverage >= 70) return '#97ca00'; |
| 48 | + if (coverage >= 60) return '#dfb317'; |
| 49 | + if (coverage >= 50) return '#fe7d37'; |
| 50 | + return '#e05d44'; |
| 51 | +} |
| 52 | + |
| 53 | +function buildBadgeSvg(label, value, color) { |
| 54 | + const labelWidth = getTextWidth(label); |
| 55 | + const valueWidth = getTextWidth(value); |
| 56 | + const totalWidth = labelWidth + valueWidth; |
| 57 | + const valueX = labelWidth + valueWidth / 2; |
| 58 | + const labelX = labelWidth / 2; |
| 59 | + |
| 60 | + return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${value}"> |
| 61 | + <title>${label}: ${value}</title> |
| 62 | + <linearGradient id="smooth" x2="0" y2="100%"> |
| 63 | + <stop offset="0" stop-color="#fff" stop-opacity=".7"/> |
| 64 | + <stop offset=".1" stop-color="#aaa" stop-opacity=".1"/> |
| 65 | + <stop offset=".9" stop-color="#000" stop-opacity=".3"/> |
| 66 | + <stop offset="1" stop-color="#000" stop-opacity=".5"/> |
| 67 | + </linearGradient> |
| 68 | + <clipPath id="round"> |
| 69 | + <rect width="${totalWidth}" height="20" rx="3" fill="#fff"/> |
| 70 | + </clipPath> |
| 71 | + <g clip-path="url(#round)"> |
| 72 | + <rect width="${labelWidth}" height="20" fill="#555"/> |
| 73 | + <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/> |
| 74 | + <rect width="${totalWidth}" height="20" fill="url(#smooth)"/> |
| 75 | + </g> |
| 76 | + <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> |
| 77 | + <text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${label}</text> |
| 78 | + <text x="${labelX}" y="14">${label}</text> |
| 79 | + <text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${value}</text> |
| 80 | + <text x="${valueX}" y="14">${value}</text> |
| 81 | + </g> |
| 82 | +</svg>`; |
| 83 | +} |
| 84 | + |
| 85 | +function getTextWidth(text) { |
| 86 | + return text.length * 7 + 10; |
| 87 | +} |
0 commit comments