Skip to content
Open
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
70 changes: 70 additions & 0 deletions src/lib/markdown-generator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';

import { generateMarkdown } from './markdown-generator';

describe('generateMarkdown social URL generation', () => {
it('builds canonical social URLs from usernames, handles, and full URLs', () => {
const markdown = generateMarkdown({
profile: {},
links: {},
social: {
github: ' @octocat ',
linkedin: 'https://www.linkedin.com/in/jane-doe/',
twitter: 'https://twitter.com/octo',
medium: '@writer',
hackerearth: 'https://www.hackerearth.com/@coder/',
youtube: 'https://www.youtube.com/@codechannel',
stackoverflow: 'https://stackoverflow.com/users/12345/jane-doe',
},
support: {},
skills: {},
});

expect(markdown).toContain('href="https://github.com/octocat"');
expect(markdown).toContain('href="https://linkedin.com/in/jane-doe"');
expect(markdown).toContain('href="https://twitter.com/octo"');
expect(markdown).toContain('href="https://medium.com/@writer"');
expect(markdown).toContain('href="https://hackerearth.com/@coder"');
expect(markdown).toContain('href="https://youtube.com/@codechannel"');
expect(markdown).toContain('href="https://stackoverflow.com/users/12345/jane-doe"');
});

it('uses normalized github username for stats and badges', () => {
const markdown = generateMarkdown({
profile: {
visitorsBadge: true,
githubProfileTrophy: true,
githubStats: true,
streakStats: true,
},
links: {},
social: {
github: 'https://github.com/octocat/',
},
support: {},
skills: {},
});

expect(markdown).toContain('ghpvc/?username=octocat');
expect(markdown).toContain('github-profile-trophy.vercel.app/?username=octocat');
expect(markdown).toContain('github-readme-stats.vercel.app/api/top-langs?username=octocat');
expect(markdown).toContain('github-readme-streak-stats.herokuapp.com/?user=octocat');
expect(markdown).not.toContain('username=https://github.com/octocat');
});

it('uses normalized twitter handle for badge URL', () => {
const markdown = generateMarkdown({
profile: {},
links: {},
social: {
twitterBadge: true,
twitter: '@devhandle',
},
support: {},
skills: {},
});

expect(markdown).toContain('href="https://twitter.com/devhandle"');
expect(markdown).toContain('img.shields.io/twitter/follow/devhandle');
});
});
131 changes: 114 additions & 17 deletions src/lib/markdown-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,81 @@ interface GenerateMarkdownOptions {
skills: Record<string, boolean>;
}

const URL_INPUT_RE = /^(https?:\/\/|www\.)/i;

function trimSlashes(value: string): string {
return value.replace(/^\/+|\/+$/g, '');
}

function extractHandleFromUrl(platform: string, value: string): string {
try {
const parsedUrl = new URL(value.startsWith('http') ? value : `https://${value}`);
const path = decodeURIComponent(trimSlashes(parsedUrl.pathname));

switch (platform) {
case 'linkedin':
return path.replace(/^in\//i, '');
case 'stackoverflow':
return path.replace(/^users\//i, '');
case 'codeforces':
return path.replace(/^profile\//i, '');
case 'codechef':
return path.replace(/^users\//i, '');
case 'topcoder':
return path.replace(/^members\//i, '');
case 'geeks_for_geeks':
return path.replace(/^user\//i, '');
case 'youtube':
return path.startsWith('@') ? path.slice(1) : path;
case 'medium':
case 'hackerearth':
return path.replace(/^@+/, '');
default:
return path.split('/')[0] || '';
}
} catch {
return value;
}
}

function normalizeSocialHandle(platform: string, value: string): string {
let handle = value.trim();

if (!handle) {
return '';
}

if (URL_INPUT_RE.test(handle)) {
handle = extractHandleFromUrl(platform, handle);
}

handle = trimSlashes(handle);

switch (platform) {
case 'linkedin':
handle = handle.replace(/^in\//i, '');
break;
case 'stackoverflow':
handle = handle.replace(/^users\//i, '');
break;
case 'codeforces':
handle = handle.replace(/^profile\//i, '');
break;
case 'codechef':
handle = handle.replace(/^users\//i, '');
break;
case 'topcoder':
handle = handle.replace(/^members\//i, '');
break;
case 'geeks_for_geeks':
handle = handle.replace(/^user\//i, '');
break;
}

handle = handle.replace(/^@+/, '');
return trimSlashes(handle);
}

const socialPlatformUrls: Record<string, (username: string) => string> = {
github: (u) => `https://github.com/${u}`,
linkedin: (u) => `https://linkedin.com/in/${u}`,
Expand All @@ -25,20 +100,32 @@ const socialPlatformUrls: Record<string, (username: string) => string> = {
instagram: (u) => `https://instagram.com/${u}`,
dribbble: (u) => `https://dribbble.com/${u}`,
behance: (u) => `https://behance.net/${u}`,
medium: (u) => `https://medium.com/${u}`,
youtube: (u) => `https://youtube.com/${u}`,
medium: (u) => `https://medium.com/@${u}`,
youtube: (u) =>
/^(channel|c|user)\//i.test(u) ? `https://youtube.com/${u}` : `https://youtube.com/@${u}`,
codepen: (u) => `https://codepen.io/${u}`,
codesandbox: (u) => `https://codesandbox.io/${u}`,
leetcode: (u) => `https://leetcode.com/${u}`,
hackerrank: (u) => `https://hackerrank.com/${u}`,
codeforces: (u) => `https://codeforces.com/profile/${u}`,
codechef: (u) => `https://codechef.com/users/${u}`,
topcoder: (u) => `https://topcoder.com/members/${u}`,
hackerearth: (u) => `https://hackerearth.com/${u}`,
hackerearth: (u) => `https://hackerearth.com/@${u}`,
geeks_for_geeks: (u) => `https://auth.geeksforgeeks.org/user/${u}`,
discord: (u) => `https://discord.gg/${u}`,
};

function buildSocialUrl(platform: string, value: string): string {
const urlBuilder = socialPlatformUrls[platform];
const normalizedHandle = normalizeSocialHandle(platform, value);

if (!urlBuilder || !normalizedHandle) {
return '';
}

return urlBuilder(normalizedHandle);
}

const socialIcons: Record<string, string> = {
github: 'github.svg',
linkedin: 'linked-in-alt.svg',
Expand Down Expand Up @@ -325,6 +412,14 @@ export function getSkillIconUrl(skill: string): string {
export function generateMarkdown(options: GenerateMarkdownOptions): string {
const { profile, links, social, support, skills } = options;
let markdown = '';
const githubUsername = normalizeSocialHandle(
'github',
typeof social.github === 'string' ? social.github : ''
);
const twitterUsername = normalizeSocialHandle(
'twitter',
typeof social.twitter === 'string' ? social.twitter : ''
);

// Title and Subtitle
if (profile.title) {
Expand All @@ -336,18 +431,18 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string {
}

// Visitor Badge
if (profile.visitorsBadge && social.github) {
markdown += `<p align="left"> <img src="https://komarev.com/ghpvc/?username=${social.github}&label=${profile.badgeLabel || 'Profile views'}&color=${profile.badgeColor || '0e75b6'}&style=${profile.badgeStyle || 'flat'}" alt="${social.github}" /> </p>\n\n`;
if (profile.visitorsBadge && githubUsername) {
markdown += `<p align="left"> <img src="https://komarev.com/ghpvc/?username=${githubUsername}&label=${profile.badgeLabel || 'Profile views'}&color=${profile.badgeColor || '0e75b6'}&style=${profile.badgeStyle || 'flat'}" alt="${githubUsername}" /> </p>\n\n`;
}

// GitHub Trophy
if (profile.githubProfileTrophy && social.github) {
markdown += `<p align="left"> <a href="https://github.com/ryo-ma/github-profile-trophy"><img src="https://github-profile-trophy.vercel.app/?username=${social.github}" alt="${social.github}" /></a> </p>\n\n`;
if (profile.githubProfileTrophy && githubUsername) {
markdown += `<p align="left"> <a href="https://github.com/ryo-ma/github-profile-trophy"><img src="https://github-profile-trophy.vercel.app/?username=${githubUsername}" alt="${githubUsername}" /></a> </p>\n\n`;
}

// Twitter Badge
if (social.twitterBadge && social.twitter) {
markdown += `<p align="left"> <a href="https://twitter.com/${social.twitter}" target="blank"><img src="https://img.shields.io/twitter/follow/${social.twitter}?logo=twitter&style=for-the-badge" alt="${social.twitter}" /></a> </p>\n\n`;
if (social.twitterBadge && twitterUsername) {
markdown += `<p align="left"> <a href="https://twitter.com/${twitterUsername}" target="blank"><img src="https://img.shields.io/twitter/follow/${twitterUsername}?logo=twitter&style=for-the-badge" alt="${twitterUsername}" /></a> </p>\n\n`;
}

// About sections
Expand Down Expand Up @@ -412,9 +507,11 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string {

socialLinks.forEach(([platform, username]) => {
const icon = socialIcons[platform];
const url = socialPlatformUrls[platform];
if (icon && url && username) {
markdown += `<a href="${url(username as string)}" target="blank"><img align="center" src="https://raw.githubusercontent.com/rahuldkjain/github-profile-readme-generator/master/src/images/icons/Social/${icon}" alt="${username as string}" height="30" width="40" /></a>\n`;
const profileUrl = buildSocialUrl(platform, username as string);
const normalizedHandle = normalizeSocialHandle(platform, username as string);

if (icon && profileUrl && normalizedHandle) {
markdown += `<a href="${profileUrl}" target="blank"><img align="center" src="https://raw.githubusercontent.com/rahuldkjain/github-profile-readme-generator/master/src/images/icons/Social/${icon}" alt="${normalizedHandle}" height="30" width="40" /></a>\n`;
}
});

Expand Down Expand Up @@ -442,14 +539,14 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string {
}

// GitHub Stats
if (profile.githubStats && social.github) {
markdown += `<p><img align="left" src="https://github-readme-stats.vercel.app/api/top-langs?username=${social.github}&show_icons=true&locale=en&layout=compact" alt="${social.github}" /></p>\n\n`;
markdown += `<p>&nbsp;<img align="center" src="https://github-readme-stats.vercel.app/api?username=${social.github}&show_icons=true&locale=en" alt="${social.github}" /></p>\n\n`;
if (profile.githubStats && githubUsername) {
markdown += `<p><img align="left" src="https://github-readme-stats.vercel.app/api/top-langs?username=${githubUsername}&show_icons=true&locale=en&layout=compact" alt="${githubUsername}" /></p>\n\n`;
markdown += `<p>&nbsp;<img align="center" src="https://github-readme-stats.vercel.app/api?username=${githubUsername}&show_icons=true&locale=en" alt="${githubUsername}" /></p>\n\n`;
}

// Streak Stats
if (profile.streakStats && social.github) {
markdown += `<p><img align="center" src="https://github-readme-streak-stats.herokuapp.com/?user=${social.github}&" alt="${social.github}" /></p>\n\n`;
if (profile.streakStats && githubUsername) {
markdown += `<p><img align="center" src="https://github-readme-streak-stats.herokuapp.com/?user=${githubUsername}&" alt="${githubUsername}" /></p>\n\n`;
}

return markdown;
Expand Down