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
73 changes: 73 additions & 0 deletions lib/svg/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2695,6 +2695,79 @@ describe('XML Validation - All Generator Outputs', () => {
});
});

describe('label parameter custom title rendering', () => {
const baseParams = {
user: 'avi',
bg: hexColor('0d1117'),
text: hexColor('c9d1d9'),
accent: hexColor('58a6ff'),
speed: '8s',
scale: 'linear',
} as const;

it('renders custom label instead of uppercase username when custom label is supplied', () => {
const svg = generateSVG(
mockStats,
{
...baseParams,
label: 'Team Streak',
},
mockCalendar
);

assertValidSVG(svg);
expect(svg).toContain('Team Streak');
expect(svg).not.toContain('AVI');
});

it('sanitizes custom label to prevent XSS / XML Injection', () => {
const svg = generateSVG(
mockStats,
{
...baseParams,
label: '<script>alert(1)</script>',
},
mockCalendar
);

assertValidSVG(svg);
expect(svg).not.toContain('<script>');
expect(svg).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});

it('truncates custom label if it exceeds 40 characters', () => {
const longLabel = 'a'.repeat(50);
const expectedLabel = 'a'.repeat(40) + '...';
const svg = generateSVG(
mockStats,
{
...baseParams,
label: longLabel,
},
mockCalendar
);

assertValidSVG(svg);
expect(svg).toContain(expectedLabel);
expect(svg).not.toContain(longLabel);
});

it('works correctly in auto-theme mode', () => {
const svg = generateSVG(
mockStats,
{
user: 'avi',
autoTheme: true,
label: 'Auto Label Title',
} as unknown as BadgeParams,
mockCalendar
);

assertValidSVG(svg);
expect(svg).toContain('Auto Label Title');
});
});

function assertValidSVG(svgString: string): void {
const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml');
const parserError = doc.querySelector('parsererror');
Expand Down
34 changes: 26 additions & 8 deletions lib/svg/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ export function truncateUsername(username: string): string {
: username;
}

export function truncateLabel(label: string): string {
return label.length > 40 ? `${label.slice(0, 40)}...` : label;
}

export function getUsernameFontSize(username: string): number {
const len = username.length;
if (len <= 12) return 18;
Expand Down Expand Up @@ -730,17 +734,24 @@ function renderFooter(
? 'class="cp-accent-fill scan-line"'
: `fill="${accent}" class="cp-accent-fill scan-line"`;

const displayTitle = params.custom_title
? sanitizeCustomText(params.custom_title)
: `${truncateUsername(safeUser).toUpperCase()}${isWinner ? ' 👑' : ''}`;
const displayTitle =
typeof params.label === 'string'
? sanitizeCustomText(truncateLabel(params.label))
: params.custom_title
? sanitizeCustomText(params.custom_title)
: `${truncateUsername(safeUser).toUpperCase()}${isWinner ? ' 👑' : ''}`;

const titleText = `${displayTitle}${
params.isOfflineFallback
? '<tspan fill="#ff9f43" font-size="10px" font-weight="bold"> [STALE CACHE]</tspan>'
: ''
}`;

const titleFontSize = getUsernameFontSize(params.custom_title || truncateUsername(safeUser));
const titleFontSize = getUsernameFontSize(
typeof params.label === 'string'
? truncateLabel(params.label)
: params.custom_title || truncateUsername(safeUser)
);

let subtitleElement = '';
if (params.custom_subtitle && !params.hide_title && params.label !== false) {
Expand Down Expand Up @@ -1162,17 +1173,24 @@ function generateAutoThemeSVG(

const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase();

const displayTitle = params.custom_title
? sanitizeCustomText(params.custom_title)
: truncateUsername(safeUser).toUpperCase();
const displayTitle =
typeof params.label === 'string'
? sanitizeCustomText(truncateLabel(params.label))
: params.custom_title
? sanitizeCustomText(params.custom_title)
: truncateUsername(safeUser).toUpperCase();

const titleText = `${displayTitle}${
params.isOfflineFallback
? '<tspan fill="#ff9f43" font-size="10px" font-weight="bold"> [STALE CACHE]</tspan>'
: ''
}`;

const titleFontSize = getUsernameFontSize(params.custom_title || truncateUsername(safeUser));
const titleFontSize = getUsernameFontSize(
typeof params.label === 'string'
? truncateLabel(params.label)
: params.custom_title || truncateUsername(safeUser)
);

let subtitleElement = '';
if (params.custom_subtitle && !params.hide_title && params.label !== false) {
Expand Down
34 changes: 34 additions & 0 deletions lib/validations.streakParamsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,38 @@ describe('streakParamsSchema', () => {
const result = streakParamsSchema.safeParse({ user: 'octocat', versus: 'bad user!' });
expect(result.success).toBe(false);
});

describe('label parameter', () => {
it('transforms string "false" to boolean false', () => {
const result = streakParamsSchema.safeParse({ user: 'octocat', label: 'false' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.label).toBe(false);
}
});

it('transforms string "true" to boolean true', () => {
const result = streakParamsSchema.safeParse({ user: 'octocat', label: 'true' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.label).toBe(true);
}
});

it('retains custom label strings', () => {
const result = streakParamsSchema.safeParse({ user: 'octocat', label: 'Team Streak' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.label).toBe('Team Streak');
}
});

it('retains undefined if label parameter is missing', () => {
const result = streakParamsSchema.safeParse({ user: 'octocat' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.label).toBeUndefined();
}
});
});
});
7 changes: 6 additions & 1 deletion lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,12 @@ const baseStreakParamsSchema = z.object({
label: z
.string()
.optional()
.transform((v) => v !== 'false'),
.transform((v) => {
if (v === undefined) return undefined;
if (v === 'false') return false;
if (v === 'true') return true;
return v;
}),

theme: z
.string()
Expand Down
2 changes: 1 addition & 1 deletion types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export interface BadgeParams {
/** GitHub username whose contribution data will be fetched and rendered. Required. */
user: string;

label?: boolean;
label?: string | boolean;
/** GitHub username of the opponent to compare against. */
versus?: string;

Expand Down
Loading