diff --git a/src/server/message/font.js b/src/server/message/font.js index 794e655dcb..cb1bb3fd38 100644 --- a/src/server/message/font.js +++ b/src/server/message/font.js @@ -90,3 +90,25 @@ export const getFontRules = style => { } return rules; }; + +// Shared validation and @font-face generation for v2 stylesheet. +const isSafeFontName = value => typeof value === 'string' && /^[\w\s-]+$/.test(value.trim()); +const isSafeFontSource = value => typeof value === 'string' && /^https:\/\/[^'")\s]+$/i.test(value); + +export function buildFontRules({ fontSource, fontFamily, fallbackStack, defaultFontFamily, fontNamePrefix }) { + const sources = Array.isArray(fontSource) ? fontSource.filter(isSafeFontSource) : []; + const families = Array.isArray(fontFamily) ? fontFamily.filter(isSafeFontName).map(formatFontFamilyName) : []; + + const fontFaceRules = sources + .map((url, i) => `@font-face { font-family: '${fontNamePrefix} ${i + 1}'; src: url('${url}'); }`) + .join('\n'); + + const customSourceNames = sources.map((_, i) => `'${fontNamePrefix} ${i + 1}'`); + + const effectiveFontFamily = + customSourceNames.length > 0 || families.length > 0 + ? [...customSourceNames, ...families, fallbackStack].join(', ') + : defaultFontFamily; + + return { fontFaceRules, effectiveFontFamily }; +} diff --git a/src/server/v2/message.jsx b/src/server/v2/message.jsx index 58e5fb63d0..5e43458548 100644 --- a/src/server/v2/message.jsx +++ b/src/server/v2/message.jsx @@ -27,10 +27,19 @@ function renderBlock(item) { } } +function renderLogo(block, className) { + if (!block) return null; + return ( + + {renderBlock(block)} + + ); +} + export default function V2Message({ options, v2Content }) { const { style } = options; - const logoPosition = style.logo?.type === 'inline' ? 'inline' : style.logo?.position ?? 'left'; const logoType = style.logo?.type ?? 'primary'; + const logoPosition = style.logo?.position ?? 'left'; const textColor = style.layout === 'flex' ? style.color ?? 'black' : style.text?.color ?? 'black'; const mainItems = v2Content?.main_items ?? []; @@ -38,6 +47,7 @@ export default function V2Message({ options, v2Content }) { const disclaimerItems = v2Content?.disclaimer_items ?? []; const { logoBlock, hasInitialLogo, hasRightLogo, mainBlocks } = buildLogoConfiguration({ + logoType, logoPosition, mainItems }); @@ -53,24 +63,30 @@ export default function V2Message({ options, v2Content }) { const actionLabel = buildContentLabel(actionItems); return ( -
+
{/* eslint-disable react/no-danger */} Pay Later. Subject to approval.Learn more
" +`; + +exports[`v2 render snapshots renders the v2 stylesheet once 1`] = ` +"" +`; diff --git a/tests/unit/spec/server/v2/render.test.js b/tests/unit/spec/server/v2/render.test.js index 842893e63b..487d707896 100644 --- a/tests/unit/spec/server/v2/render.test.js +++ b/tests/unit/spec/server/v2/render.test.js @@ -1,4 +1,5 @@ import render from 'server/v2/render'; +import validateStyle from 'server/v2/validateStyle'; const mockLog = jest.fn(); @@ -134,7 +135,7 @@ describe('v2 render', () => { expect(result).toMatch(/class="logo[^"]*top[^"]*"/); }); - test('inline logo type keeps logo in main blocks without a standalone logo span', () => { + test('inline logo type renders logo inside main span via logo-aware path', () => { const options = { style: { ...baseOptions.style, logo: { type: 'inline', position: 'left' } } }; const content = { ...baseV2Content, @@ -149,8 +150,12 @@ describe('v2 render', () => { ] }; const result = render(options, content, mockLog); - // no standalone logo span — logo rendered inline within main blocks - expect(result).not.toMatch(/role="img"/); + // logo is wrapped in .logo.inline span for color-filter CSS to apply + expect(result).toMatch(/class="logo[^"]*inline[^"]*"/); + // logo span is nested inside the main span, not a standalone sibling + const mainIdx = result.indexOf('class="main'); + const logoIdx = result.indexOf('class="logo'); + expect(logoIdx).toBeGreaterThan(mainIdx); }); test('logo type none suppresses logo span even when IMAGE item is present', () => { @@ -201,6 +206,18 @@ describe('v2 render', () => { expect(result).toMatch(/class="main[^"]*white[^"]*"/); }); + test('greyscale alias normalizes to grayscale class after validation', () => { + const raw = { + layout: 'text', + logo: { type: 'primary', position: 'left' }, + text: { color: 'greyscale', size: 12 } + }; + const validatedStyle = validateStyle(mockLog, raw); + const result = render({ style: validatedStyle }, baseV2Content, mockLog); + expect(result).toMatch(/class="main[^"]*grayscale[^"]*"/); + expect(result).not.toContain('greyscale'); + }); + test('handles empty main_items gracefully', () => { const content = { ...baseV2Content, main_items: [] }; const result = render(baseOptions, content, mockLog); @@ -219,4 +236,260 @@ describe('v2 render', () => { expect(result).not.toContain('message__headline'); expect(result).not.toContain('message__foreground'); }); + + test('maps validated v5 text style options to root data attributes', () => { + const options = { + style: { + layout: 'text', + logo: { type: 'primary', position: 'top' }, + text: { align: 'center', color: 'white', size: 16 } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toContain('data-pp-style-layout="text"'); + expect(result).toContain('data-pp-style-logo-position="top"'); + expect(result).toContain('data-pp-style-logo-type="primary"'); + expect(result).toContain('data-pp-style-text-align="center"'); + expect(result).toContain('data-pp-style-text-color="white"'); + expect(result).toContain('data-pp-style-text-size="16"'); + }); +}); + +describe('v2 render fontSource', () => { + test('generates @font-face rule for a single fontSource URL', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontSource: ['https://example.com/font.woff2'] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toContain('@font-face'); + expect(result).toContain("url('https://example.com/font.woff2')"); + expect(result).toContain("font-family: 'PP Merchant Font 1'"); + }); + + test('prepends custom font name to font-family stack', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontSource: ['https://example.com/font.woff2'] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toMatch(/font-family:\s*'PP Merchant Font 1',/); + expect(result).not.toContain('"PayPal Pro"'); + }); + + test('generates one @font-face rule per fontSource URL', () => { + const options = { + style: { + ...baseOptions.style, + text: { + color: 'black', + size: 12, + fontSource: ['https://example.com/font1.woff2', 'https://example.com/font2.woff2'] + } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toContain("url('https://example.com/font1.woff2')"); + expect(result).toContain("url('https://example.com/font2.woff2')"); + expect(result).toContain("'PP Merchant Font 1'"); + expect(result).toContain("'PP Merchant Font 2'"); + }); + + test('includes explicit fontFamily after fontSource names in font-family stack', () => { + const options = { + style: { + ...baseOptions.style, + text: { + color: 'black', + size: 12, + fontSource: ['https://example.com/font.woff2'], + fontFamily: ['MyFont'] + } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toMatch(/'PP Merchant Font 1',\s*'MyFont',/); + }); + + test('includes explicit fontFamily without fontSource', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontFamily: ['Impact', 'sans-serif'] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toContain("font-family: 'Impact', sans-serif, Helvetica"); + }); + + test('uses PayPal Pro default font-family when fontSource is not provided', () => { + const result = render(baseOptions, baseV2Content, mockLog); + expect(result).not.toContain('@font-face'); + expect(result).toContain('"PayPal Pro"'); + }); + + test('uses PayPal Pro default font-family when fontSource is empty array', () => { + const options = { + style: { ...baseOptions.style, text: { color: 'black', size: 12, fontSource: [] } } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).not.toContain('@font-face'); + expect(result).toContain('"PayPal Pro"'); + }); + + test('ignores unsafe fontSource URLs', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontSource: ['./font.woff2'] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).not.toContain('@font-face'); + expect(result).not.toContain('./font.woff2'); + }); + + test('ignores unsafe fontFamily values', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontFamily: [""] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).not.toContain('