From b3edd1facbd001484fb2b4caf693f6053cad5962 Mon Sep 17 00:00:00 2001 From: Tyler Daniels Date: Wed, 4 Mar 2026 16:01:38 -0800 Subject: [PATCH] Normalize social inputs and canonicalize profile URL generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize social inputs before URL generation Add canonical URL builder for social links Correct Medium, YouTube, and HackerEarth profile URL formats Use normalized handles for GitHub/Twitter badge and stats parameters Add regression tests covering username, @handle, and pasted profile URL inputs Verification: npx vitest run src/lib/markdown-generator.test.ts → 3/3 tests passed npm run type-check passed Fixes malformed links when users provide usernames or full profile URLs in social fields. --- src/lib/markdown-generator.test.ts | 70 +++++++++++++++ src/lib/markdown-generator.ts | 131 +++++++++++++++++++++++++---- 2 files changed, 184 insertions(+), 17 deletions(-) create mode 100644 src/lib/markdown-generator.test.ts diff --git a/src/lib/markdown-generator.test.ts b/src/lib/markdown-generator.test.ts new file mode 100644 index 00000000..2084075f --- /dev/null +++ b/src/lib/markdown-generator.test.ts @@ -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'); + }); +}); diff --git a/src/lib/markdown-generator.ts b/src/lib/markdown-generator.ts index a2f572f9..1bd08922 100644 --- a/src/lib/markdown-generator.ts +++ b/src/lib/markdown-generator.ts @@ -14,6 +14,81 @@ interface GenerateMarkdownOptions { skills: Record; } +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> = { github: (u) => `https://github.com/${u}`, linkedin: (u) => `https://linkedin.com/in/${u}`, @@ -25,8 +100,9 @@ const socialPlatformUrls: Record 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}`, @@ -34,11 +110,22 @@ const socialPlatformUrls: Record string> = { 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 = { github: 'github.svg', linkedin: 'linked-in-alt.svg', @@ -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) { @@ -336,18 +431,18 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string { } // Visitor Badge - if (profile.visitorsBadge && social.github) { - markdown += `

${social.github}

\n\n`; + if (profile.visitorsBadge && githubUsername) { + markdown += `

${githubUsername}

\n\n`; } // GitHub Trophy - if (profile.githubProfileTrophy && social.github) { - markdown += `

${social.github}

\n\n`; + if (profile.githubProfileTrophy && githubUsername) { + markdown += `

${githubUsername}

\n\n`; } // Twitter Badge - if (social.twitterBadge && social.twitter) { - markdown += `

${social.twitter}

\n\n`; + if (social.twitterBadge && twitterUsername) { + markdown += `

${twitterUsername}

\n\n`; } // About sections @@ -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 += `${username as string}\n`; + const profileUrl = buildSocialUrl(platform, username as string); + const normalizedHandle = normalizeSocialHandle(platform, username as string); + + if (icon && profileUrl && normalizedHandle) { + markdown += `${normalizedHandle}\n`; } }); @@ -442,14 +539,14 @@ export function generateMarkdown(options: GenerateMarkdownOptions): string { } // GitHub Stats - if (profile.githubStats && social.github) { - markdown += `

${social.github}

\n\n`; - markdown += `

 ${social.github}

\n\n`; + if (profile.githubStats && githubUsername) { + markdown += `

${githubUsername}

\n\n`; + markdown += `

 ${githubUsername}

\n\n`; } // Streak Stats - if (profile.streakStats && social.github) { - markdown += `

${social.github}

\n\n`; + if (profile.streakStats && githubUsername) { + markdown += `

${githubUsername}

\n\n`; } return markdown;