diff --git a/.github/scripts/update-contributors.js b/.github/scripts/update-contributors.js
new file mode 100644
index 0000000..d23d3ee
--- /dev/null
+++ b/.github/scripts/update-contributors.js
@@ -0,0 +1,223 @@
+const fs = require('fs');
+const path = require('path');
+
+const README_PATH = path.join(__dirname, '../../README.md');
+
+function getContributionType(prPayload) {
+ const pr = prPayload.pull_request;
+ const labels = (pr.labels || []).map(l => l.name.toLowerCase());
+
+ if (labels.some(l => l.includes('frontend') || l.includes('ui') || l.includes('css') || l.includes('design') || l.includes('style'))) {
+ return '๐จ Frontend';
+ }
+ if (labels.some(l => l.includes('design') || l.includes('ux'))) {
+ return '๐จ Design';
+ }
+ if (labels.some(l => l.includes('backend') || l.includes('api') || l.includes('database') || l.includes('db') || l.includes('server'))) {
+ return '๐ป Code';
+ }
+ if (labels.some(l => l.includes('docs') || l.includes('documentation') || l.includes('readme') || l.includes('wiki'))) {
+ return '๐ Docs';
+ }
+ if (labels.some(l => l.includes('bug') || l.includes('fix') || l.includes('hotfix') || l.includes('patch'))) {
+ return '๐ Bug fix';
+ }
+ if (labels.some(l => l.includes('test') || l.includes('testing') || l.includes('spec') || l.includes('jest') || l.includes('pytest'))) {
+ return '๐งช Tests';
+ }
+ if (labels.some(l => l.includes('performance') || l.includes('speed') || l.includes('perf'))) {
+ return 'โก Performance';
+ }
+ if (labels.some(l => l.includes('security') || l.includes('vuln') || l.includes('auth'))) {
+ return '๐ Security';
+ }
+ if (labels.some(l => l.includes('infra') || l.includes('ci') || l.includes('cd') || l.includes('workflow') || l.includes('docker') || l.includes('github-actions'))) {
+ return '๐ Infrastructure';
+ }
+ if (labels.some(l => l.includes('accessibility') || l.includes('a11y'))) {
+ return 'โฟ Accessibility';
+ }
+
+ // Fallback to title matching
+ const title = (pr.title || '').toLowerCase();
+ if (title.startsWith('feat') || title.includes('frontend') || title.includes('ui')) {
+ if (title.includes('frontend') || title.includes('ui')) return '๐จ Frontend';
+ if (title.includes('design')) return '๐จ Design';
+ if (title.includes('docs') || title.includes('readme')) return '๐ Docs';
+ if (title.includes('test')) return '๐งช Tests';
+ if (title.includes('perf')) return 'โก Performance';
+ if (title.includes('security') || title.includes('auth')) return '๐ Security';
+ if (title.includes('infra') || title.includes('workflow') || title.includes('github action')) return '๐ Infrastructure';
+ return '๐ป Code';
+ }
+ if (title.startsWith('fix') || title.includes('bug')) {
+ return '๐ Bug fix';
+ }
+ if (title.startsWith('docs')) {
+ return '๐ Docs';
+ }
+ if (title.startsWith('test')) {
+ return '๐งช Tests';
+ }
+
+ return '๐ป Code';
+}
+
+function parseExistingContributors(sectionContent) {
+ const contributors = [];
+ const regex = /
\s*\s* ]*\/>\s* \s*([^<]+)<\/b><\/sub>\s* \s*([^<]+)<\/sub>\s*<\/a>\s*<\/td>/gi;
+
+ let match;
+ while ((match = regex.exec(sectionContent)) !== null) {
+ contributors.push({
+ username: match[1].trim(),
+ avatarUrl: match[2].trim(),
+ name: match[3].trim(),
+ role: match[4].trim()
+ });
+ }
+ return contributors;
+}
+
+function generateTableHTML(contributors) {
+ const columnsPerRow = 5;
+ let html = '\n\n\n \n \n';
+
+ for (let i = 0; i < contributors.length; i += columnsPerRow) {
+ html += ' \n';
+ const chunk = contributors.slice(i, i + columnsPerRow);
+ for (const c of chunk) {
+ html += `
+
+
+
+ ${c.name}
+
+ ${c.role}
+
+ | \n`;
+ }
+ html += ' \n';
+ }
+
+ html += ' \n \n\n \n';
+ return html;
+}
+
+async function main() {
+ const eventPath = process.env.GITHUB_EVENT_PATH;
+ if (!eventPath) {
+ console.error('Error: GITHUB_EVENT_PATH not set.');
+ process.exit(1);
+ }
+
+ let eventPayload;
+ try {
+ eventPayload = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
+ } catch (err) {
+ console.error('Error reading event payload:', err.message);
+ process.exit(1);
+ }
+
+ const pr = eventPayload.pull_request;
+ if (!pr) {
+ console.error('Error: Event payload is missing pull_request info.');
+ process.exit(1);
+ }
+
+ // Handle Bot accounts
+ const username = pr.user.login;
+ const isBot = pr.user.type === 'Bot' || username.toLowerCase().endsWith('[bot]') || username.toLowerCase() === 'github-actions';
+ if (isBot) {
+ console.log(`Skipping bot contributor: ${username}`);
+ process.exit(0);
+ }
+
+ const avatarUrl = `https://github.com/${username}.png`;
+ let name = username; // Default to username
+
+ // Fetch real profile name from GitHub API (authenticated if GITHUB_TOKEN is available)
+ const headers = { 'User-Agent': 'learnhub-contributors-automation' };
+ if (process.env.GITHUB_TOKEN) {
+ headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
+ }
+
+ try {
+ const response = await fetch(`https://api.github.com/users/${username}`, { headers });
+ if (response.ok) {
+ const userData = await response.json();
+ if (userData.name) {
+ name = userData.name;
+ console.log(`Fetched display name: "${name}" for user ${username}`);
+ }
+ } else {
+ console.warn(`GitHub API returned status ${response.status} when fetching profile for ${username}`);
+ }
+ } catch (err) {
+ console.warn(`Failed to fetch user profile name from GitHub API, using username instead: ${err.message}`);
+ }
+
+ const role = getContributionType(eventPayload);
+
+ // Read README
+ if (!fs.existsSync(README_PATH)) {
+ console.error(`Error: README.md not found at ${README_PATH}`);
+ process.exit(1);
+ }
+
+ let readmeContent = fs.readFileSync(README_PATH, 'utf8');
+
+ // Locate the contributors section
+ const startComment = '';
+ const endComment = '';
+
+ const startIndex = readmeContent.indexOf(startComment);
+ const endIndex = readmeContent.indexOf(endComment);
+
+ if (startIndex === -1 || endIndex === -1 || startIndex >= endIndex) {
+ console.error('Error: Contributors placeholder comments not found in README.md.');
+ process.exit(1);
+ }
+
+ const sectionContent = readmeContent.substring(startIndex + startComment.length, endIndex);
+ const contributors = parseExistingContributors(sectionContent);
+
+ // Check for duplicates
+ const alreadyExists = contributors.some(c => c.username.toLowerCase() === username.toLowerCase());
+ if (alreadyExists) {
+ console.log(`Contributor ${username} already exists in README.md. Skipping.`);
+ process.exit(0);
+ }
+
+ // Add new contributor
+ contributors.push({
+ username,
+ avatarUrl,
+ name,
+ role
+ });
+
+ console.log(`Adding new contributor: ${username} with role: ${role}`);
+
+ // Generate new HTML table
+ const newTableHTML = generateTableHTML(contributors);
+
+ // Construct new README content
+ const updatedReadmeContent =
+ readmeContent.substring(0, startIndex + startComment.length) +
+ newTableHTML +
+ readmeContent.substring(endIndex);
+
+ fs.writeFileSync(README_PATH, updatedReadmeContent, 'utf8');
+ console.log('README.md successfully updated.');
+}
+
+if (require.main === module) {
+ main();
+}
+
+module.exports = {
+ getContributionType,
+ parseExistingContributors,
+ generateTableHTML
+};
diff --git a/.github/scripts/update-contributors.test.js b/.github/scripts/update-contributors.test.js
new file mode 100644
index 0000000..4e05622
--- /dev/null
+++ b/.github/scripts/update-contributors.test.js
@@ -0,0 +1,273 @@
+/**
+ * Tests for update-contributors.js
+ *
+ * Run with: node .github/scripts/update-contributors.test.js
+ * (No extra dependencies needed โ uses Node's built-in assert module)
+ */
+
+'use strict';
+
+const assert = require('assert');
+const { getContributionType, parseExistingContributors, generateTableHTML } = require('./update-contributors');
+
+let passed = 0;
+let failed = 0;
+
+function test(description, fn) {
+ try {
+ fn();
+ console.log(` โ
${description}`);
+ passed++;
+ } catch (err) {
+ console.error(` โ ${description}`);
+ console.error(` ${err.message}`);
+ failed++;
+ }
+}
+
+function makePR(labels = [], title = '') {
+ return {
+ pull_request: {
+ labels: labels.map(name => ({ name })),
+ title
+ }
+ };
+}
+
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// getContributionType
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+console.log('\ngetContributionType');
+
+test('returns ๐จ Frontend for label "frontend"', () => {
+ assert.strictEqual(getContributionType(makePR(['frontend'])), '๐จ Frontend');
+});
+
+test('returns ๐จ Frontend for label "ui"', () => {
+ assert.strictEqual(getContributionType(makePR(['ui'])), '๐จ Frontend');
+});
+
+test('returns ๐จ Frontend for label "css"', () => {
+ assert.strictEqual(getContributionType(makePR(['css'])), '๐จ Frontend');
+});
+
+test('returns ๐ป Code for label "backend"', () => {
+ assert.strictEqual(getContributionType(makePR(['backend'])), '๐ป Code');
+});
+
+test('returns ๐ป Code for label "api"', () => {
+ assert.strictEqual(getContributionType(makePR(['api'])), '๐ป Code');
+});
+
+test('returns ๐ Docs for label "docs"', () => {
+ assert.strictEqual(getContributionType(makePR(['docs'])), '๐ Docs');
+});
+
+test('returns ๐ Docs for label "documentation"', () => {
+ assert.strictEqual(getContributionType(makePR(['documentation'])), '๐ Docs');
+});
+
+test('returns ๐ Bug fix for label "bug"', () => {
+ assert.strictEqual(getContributionType(makePR(['bug'])), '๐ Bug fix');
+});
+
+test('returns ๐ Bug fix for label "fix"', () => {
+ assert.strictEqual(getContributionType(makePR(['fix'])), '๐ Bug fix');
+});
+
+test('returns ๐งช Tests for label "test"', () => {
+ assert.strictEqual(getContributionType(makePR(['test'])), '๐งช Tests');
+});
+
+test('returns ๐งช Tests for label "jest"', () => {
+ assert.strictEqual(getContributionType(makePR(['jest'])), '๐งช Tests');
+});
+
+test('returns โก Performance for label "performance"', () => {
+ assert.strictEqual(getContributionType(makePR(['performance'])), 'โก Performance');
+});
+
+test('returns ๐ Security for label "security"', () => {
+ assert.strictEqual(getContributionType(makePR(['security'])), '๐ Security');
+});
+
+test('returns ๐ Infrastructure for label "ci"', () => {
+ assert.strictEqual(getContributionType(makePR(['ci'])), '๐ Infrastructure');
+});
+
+test('returns ๐ Infrastructure for label "docker"', () => {
+ assert.strictEqual(getContributionType(makePR(['docker'])), '๐ Infrastructure');
+});
+
+test('returns โฟ Accessibility for label "a11y"', () => {
+ assert.strictEqual(getContributionType(makePR(['a11y'])), 'โฟ Accessibility');
+});
+
+// Label takes priority over title
+test('label takes priority over title prefix', () => {
+ assert.strictEqual(getContributionType(makePR(['docs'], 'feat: add new thing')), '๐ Docs');
+});
+
+// Title fallback tests (no labels)
+test('title starting with "fix" returns ๐ Bug fix', () => {
+ assert.strictEqual(getContributionType(makePR([], 'fix: correct typo')), '๐ Bug fix');
+});
+
+test('title starting with "docs" returns ๐ Docs', () => {
+ assert.strictEqual(getContributionType(makePR([], 'docs: update README')), '๐ Docs');
+});
+
+test('title starting with "test" returns ๐งช Tests', () => {
+ assert.strictEqual(getContributionType(makePR([], 'test: add unit tests')), '๐งช Tests');
+});
+
+test('title with "ui" keyword returns ๐จ Frontend', () => {
+ assert.strictEqual(getContributionType(makePR([], 'feat: improve ui layout')), '๐จ Frontend');
+});
+
+test('generic feat title defaults to ๐ป Code', () => {
+ assert.strictEqual(getContributionType(makePR([], 'feat: some backend feature')), '๐ป Code');
+});
+
+test('returns ๐ป Code as ultimate fallback (no labels, unknown title)', () => {
+ assert.strictEqual(getContributionType(makePR([], 'chore: misc update')), '๐ป Code');
+});
+
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// parseExistingContributors
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+console.log('\nparseExistingContributors');
+
+const SINGLE_ENTRY_HTML = `
+
+
+
+
+ Alice Smith
+
+ ๐ป Code
+
+ | `;
+
+const MULTI_ENTRY_HTML = `
+
+
+
+
+ Alice Smith
+
+ ๐ป Code
+
+ |
+
+
+
+
+ Bob Jones
+
+ ๐ Docs
+
+ | `;
+
+test('returns empty array for empty section', () => {
+ const result = parseExistingContributors('');
+ assert.deepStrictEqual(result, []);
+});
+
+test('parses a single contributor entry correctly', () => {
+ const result = parseExistingContributors(SINGLE_ENTRY_HTML);
+ assert.strictEqual(result.length, 1);
+ assert.strictEqual(result[0].username, 'alice');
+ assert.strictEqual(result[0].avatarUrl, 'https://github.com/alice.png');
+ assert.strictEqual(result[0].name, 'Alice Smith');
+ assert.strictEqual(result[0].role, '๐ป Code');
+});
+
+test('parses multiple contributor entries', () => {
+ const result = parseExistingContributors(MULTI_ENTRY_HTML);
+ assert.strictEqual(result.length, 2);
+ assert.strictEqual(result[0].username, 'alice');
+ assert.strictEqual(result[1].username, 'bob');
+ assert.strictEqual(result[1].role, '๐ Docs');
+});
+
+test('is case-insensitive for username matching (regex)', () => {
+ // The regex uses the gi flag; verify duplicates found regardless of case
+ const result = parseExistingContributors(SINGLE_ENTRY_HTML);
+ const found = result.some(c => c.username.toLowerCase() === 'alice');
+ assert.ok(found);
+});
+
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// generateTableHTML
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+console.log('\ngenerateTableHTML');
+
+const ONE_CONTRIBUTOR = [
+ { username: 'alice', avatarUrl: 'https://github.com/alice.png', name: 'Alice Smith', role: '๐ป Code' }
+];
+
+const SIX_CONTRIBUTORS = Array.from({ length: 6 }, (_, i) => ({
+ username: `user${i}`,
+ avatarUrl: `https://github.com/user${i}.png`,
+ name: `User ${i}`,
+ role: '๐ป Code'
+}));
+
+test('returns a string', () => {
+ assert.strictEqual(typeof generateTableHTML(ONE_CONTRIBUTOR), 'string');
+});
+
+test('contains tags', () => {
+ const html = generateTableHTML(ONE_CONTRIBUTOR);
+ assert.ok(html.includes(''), 'Missing ');
+ assert.ok(html.includes(' '), 'Missing ');
+});
+
+test('contains correct github profile link for contributor', () => {
+ const html = generateTableHTML(ONE_CONTRIBUTOR);
+ assert.ok(html.includes('https://github.com/alice'));
+});
+
+test('contains contributor avatar img', () => {
+ const html = generateTableHTML(ONE_CONTRIBUTOR);
+ assert.ok(html.includes('https://github.com/alice.png'));
+});
+
+test('contains contributor display name', () => {
+ const html = generateTableHTML(ONE_CONTRIBUTOR);
+ assert.ok(html.includes('Alice Smith'));
+});
+
+test('contains contributor role', () => {
+ const html = generateTableHTML(ONE_CONTRIBUTOR);
+ assert.ok(html.includes('๐ป Code'));
+});
+
+test('wraps into multiple rows for 6 contributors (max 5 per row)', () => {
+ const html = generateTableHTML(SIX_CONTRIBUTORS);
+ // Should have at least 2 tags
+ const trCount = (html.match(/ /g) || []).length;
+ assert.ok(trCount >= 2, `Expected โฅ2 tags, got ${trCount}`);
+});
+
+test('first row has exactly 5 entries for 6 contributors', () => {
+ const html = generateTableHTML(SIX_CONTRIBUTORS);
+ // Count td elements โ 6 total across 2 rows
+ const tdCount = (html.match(/| /g) || []).length;
+ assert.strictEqual(tdCount, 6, `Expected 6 | entries, got ${tdCount}`);
+});
+
+test('empty contributors array returns table with no rows', () => {
+ const html = generateTableHTML([]);
+ assert.ok(!html.includes(' | '), 'Should have no for empty input');
+});
+
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// Summary
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+console.log(`\n${'โ'.repeat(50)}`);
+console.log(`Results: ${passed} passed, ${failed} failed`);
+if (failed > 0) {
+ process.exit(1);
+}
diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml
new file mode 100644
index 0000000..a3d431f
--- /dev/null
+++ b/.github/workflows/update-contributors.yml
@@ -0,0 +1,43 @@
+name: Automate Contributors README Update
+
+on:
+ pull_request:
+ types: [closed]
+ branches: [main]
+
+permissions:
+ contents: write
+
+concurrency:
+ group: update-contributors
+ cancel-in-progress: false
+
+jobs:
+ update-readme:
+ if: github.event.pull_request.merged == true
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Update README Contributors
+ env:
+ GITHUB_EVENT_PATH: ${{ github.event_path }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node .github/scripts/update-contributors.js
+
+ - name: Commit changes
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: "docs: update contributors in README [skip ci]"
+ file_pattern: "README.md"
+ commit_user_name: "github-actions[bot]"
+ commit_user_email: "github-actions[bot]@users.noreply.github.com"
diff --git a/README.md b/README.md
index 7909876..2476174 100644
--- a/README.md
+++ b/README.md
@@ -364,6 +364,7 @@ Contributors who submit PRs will be added to the table below.
+
@@ -383,6 +384,7 @@ Contributors who submit PRs will be added to the table below.
+
|