feat(site-builder): Font Awesome 7.3.1 icons (per-network brands) in editor and exports

Exporters emit fa-solid/fa-brands/fa-regular classes through utils/fa-icon.ts;
SocialLinks gets X/TikTok/Bluesky/Threads/Discord/Mastodon/Reddit/Telegram;
the editor loads all.min.css + v4-shims (SRI) so editor chrome FA4 names work.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXgKV8v9BUrQWXSUW4P
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-31 11:59:46 -07:00
1 parent bb97e5b396
commit 7b3baa815c
17 files changed
+157 -48

No files matched your search

+3 -1
View File
@@ -7,7 +7,9 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<!-- Font Awesome Free 7.3.1 + the v4 shims (editor chrome still uses FA4 names like fa fa-trash-o) -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css" rel="stylesheet" integrity="sha384-qrALq7+6jBOZIQsNnT6xGkMDru64qD6uTlDra39xrt2SoXl4pO3FX6Roz/RpR/BS" crossorigin="anonymous" referrerpolicy="no-referrer">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/v4-shims.min.css" rel="stylesheet" integrity="sha384-BOBgwI2GEcZ4l/UA/+WOVnLChe2S4c7UhUCtdmX1m0v7Wdyk0S/tDoJOBci2ywvl" crossorigin="anonymous" referrerpolicy="no-referrer">
</head>
<body>
<div id="root"></div>
@@ -6,7 +6,7 @@ const toHtml = (Icon as any).toHtml;
describe('Icon.toHtml normal rendering', () => {
test('renders icon class, size/color style, and link href', () => {
const { html } = toHtml({ icon: 'fa-star', size: '32px', color: '#3b82f6', link: 'https://example.com' }, '');
expect(html).toContain('class="fa fa-star"');
expect(html).toContain('class="fa-solid fa-star"');
expect(html).toContain('font-size:32px');
expect(html).toContain('color:#3b82f6');
expect(html).toContain('href="https://example.com"');
+3 -2
View File
@@ -3,6 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
import { faClass } from '../../utils/fa-icon';
interface IconProps {
icon?: string;
@@ -46,7 +47,7 @@ export const Icon: UserComponent<IconProps> = ({
const iconEl = (
<i
className={`fa ${icon}`}
className={faClass(icon)}
style={{ fontSize: size, color, lineHeight: 1 }}
/>
);
@@ -130,7 +131,7 @@ Icon.craft = {
} = props;
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
let iconHtml = `<i class="fa ${escapeAttr(icon)}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
let iconHtml = `<i class="${escapeAttr(faClass(icon))}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
if (hasBg) {
@@ -6,7 +6,7 @@ const toHtml = (SearchBar as any).toHtml;
describe('SearchBar.toHtml decorative icons (F2.5)', () => {
test('the input-adjacent search icon is aria-hidden', () => {
const { html } = toHtml({}, '');
const icons = html.match(/<i class="fa fa-search"[^>]*>/g) || [];
const icons = html.match(/<i class="fa-solid fa-magnifying-glass"[^>]*>/g) || [];
expect(icons.length).toBeGreaterThan(0);
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
+4 -4
View File
@@ -51,7 +51,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
>
<div style={{ position: 'relative', flex: 1 }}>
<i
className="fa fa-search"
className="fa-solid fa-magnifying-glass"
style={{
position: 'absolute',
left: '14px',
@@ -99,7 +99,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
gap: '6px',
}}
>
<i className="fa fa-search" style={{ fontSize: '13px' }} />
<i className="fa-solid fa-magnifying-glass" style={{ fontSize: '13px' }} />
{buttonText}
</button>
)}
@@ -151,7 +151,7 @@ SearchBar.craft = {
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid ${tokenRef('border')};border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:${tokenRef('bg')};color:${tokenRef('text')};outline:none;box-sizing:border-box`;
const btnHtml = showButton
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:${tokenRef('bg')};background-color:${tokenRef('primary')};border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:${tokenRef('bg')};background-color:${tokenRef('primary')};border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa-solid fa-magnifying-glass" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
: '';
// F2: previously a purely decorative <form> -- no action/method/input
@@ -165,7 +165,7 @@ SearchBar.craft = {
return {
html: `<form role="search" action="${actionAttr}" method="GET"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1">
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:${tokenRef('muted')};font-size:14px;pointer-events:none" aria-hidden="true"></i>
<i class="fa-solid fa-magnifying-glass" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:${tokenRef('muted')};font-size:14px;pointer-events:none" aria-hidden="true"></i>
<input type="search" name="q" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
</div>
${btnHtml}
@@ -11,7 +11,7 @@ describe('SocialLinks.toHtml accessibility (F2.5)', () => {
test('the icon glyph itself is aria-hidden', () => {
const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, '');
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
expect(html).toMatch(/<i class="fa-brands fa-x-twitter"[^>]*aria-hidden="true"/);
});
});
+28 -16
View File
@@ -26,18 +26,24 @@ interface SocialLinksProps {
}
const platformIcons: Record<string, string> = {
facebook: 'fa-facebook',
twitter: 'fa-twitter',
instagram: 'fa-instagram',
linkedin: 'fa-linkedin',
youtube: 'fa-youtube',
github: 'fa-github',
tiktok: 'fa-music',
pinterest: 'fa-pinterest',
snapchat: 'fa-snapchat',
whatsapp: 'fa-whatsapp',
spotify: 'fa-spotify',
twitch: 'fa-twitch',
facebook: 'fa-brands fa-facebook',
twitter: 'fa-brands fa-x-twitter',
instagram: 'fa-brands fa-instagram',
linkedin: 'fa-brands fa-linkedin',
youtube: 'fa-brands fa-youtube',
github: 'fa-brands fa-github',
tiktok: 'fa-brands fa-tiktok',
pinterest: 'fa-brands fa-pinterest',
snapchat: 'fa-brands fa-snapchat',
whatsapp: 'fa-brands fa-whatsapp',
spotify: 'fa-brands fa-spotify',
bluesky: 'fa-brands fa-bluesky',
threads: 'fa-brands fa-threads',
discord: 'fa-brands fa-discord',
mastodon: 'fa-brands fa-mastodon',
reddit: 'fa-brands fa-reddit',
telegram: 'fa-brands fa-telegram',
twitch: 'fa-brands fa-twitch',
};
const platformLabels: Record<string, string> = {
@@ -53,6 +59,12 @@ const platformLabels: Record<string, string> = {
whatsapp: 'WhatsApp',
spotify: 'Spotify',
twitch: 'Twitch',
bluesky: 'Bluesky',
threads: 'Threads',
discord: 'Discord',
mastodon: 'Mastodon',
reddit: 'Reddit',
telegram: 'Telegram',
};
const defaultLinks: SocialLink[] = [
@@ -116,7 +128,7 @@ export const SocialLinks: UserComponent<SocialLinksProps> = ({
}}
>
{links.map((link, i) => {
const iconClass = platformIcons[link.platform] || 'fa-link';
const iconClass = platformIcons[link.platform] || 'fa-solid fa-link';
const shapeStyle = getShapeStyle(iconShape, iconSize);
const hasBg = iconShape !== 'none';
@@ -136,7 +148,7 @@ export const SocialLinks: UserComponent<SocialLinksProps> = ({
...shapeStyle,
}}
>
<i className={`fa ${iconClass}`} style={{ fontSize: iconSize }} />
<i className={iconClass} style={{ fontSize: iconSize }} />
</a>
);
})}
@@ -210,7 +222,7 @@ SocialLinks.craft = {
};
const linksHtml = links.map((link) => {
const iconClass = platformIcons[link.platform] || 'fa-link';
const iconClass = platformIcons[link.platform] || 'fa-solid fa-link';
const title = platformLabels[link.platform] || link.platform;
let aStyle = `text-decoration:none;color:${iconColor};background-color:${hasBg ? iconBgColor : 'transparent'}`;
if (hasBg) {
@@ -220,7 +232,7 @@ SocialLinks.craft = {
// aria-hidden and the accessible name lives on the link (aria-label,
// mirroring the existing `title` tooltip since title support in
// screen readers is inconsistent).
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
}).join('\n ');
return {
@@ -11,7 +11,7 @@ describe('StarRating.toHtml accessibility (F2.2)', () => {
test('individual star glyphs are aria-hidden', () => {
const { html } = toHtml({ rating: 3, maxStars: 5 }, '');
const glyphs = html.match(/<i class="fa fa-star"[^>]*>/g) || [];
const glyphs = html.match(/<i class="fa-solid fa-star"[^>]*>/g) || [];
expect(glyphs.length).toBeGreaterThan(0);
glyphs.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
@@ -58,7 +58,7 @@ describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2
test('a non-numeric maxStars does not blow up the star loop (no NaN glyph count, no huge output)', () => {
const malicious = '5" onmouseover="alert(1)';
const { html } = toHtml({ rating: 3, maxStars: malicious as any }, '');
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
const glyphs = html.match(/<i class="fa-solid fa-star"/g) || [];
// Falls back to a sane default star count rather than looping 0 or NaN times.
expect(glyphs.length).toBeGreaterThan(0);
expect(glyphs.length).toBeLessThanOrEqual(50);
@@ -66,7 +66,7 @@ describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2
test('an absurdly large maxStars is clamped to a sane maximum instead of looping unboundedly', () => {
const { html } = toHtml({ rating: 3, maxStars: 1e9 as any }, '');
const glyphs = html.match(/<i class="fa fa-star"/g) || [];
const glyphs = html.match(/<i class="fa-solid fa-star"/g) || [];
expect(glyphs.length).toBeLessThanOrEqual(50);
});
+7 -7
View File
@@ -40,7 +40,7 @@ export const StarRating: UserComponent<StarRatingProps> = ({
stars.push(
<i
key={i}
className="fa fa-star"
className="fa-solid fa-star"
style={{ color: filledColor, fontSize: size }}
/>
);
@@ -48,9 +48,9 @@ export const StarRating: UserComponent<StarRatingProps> = ({
// Half star
stars.push(
<span key={i} style={{ position: 'relative', display: 'inline-block', fontSize: size }}>
<i className="fa fa-star" style={{ color: emptyColor }} />
<i className="fa-solid fa-star" style={{ color: emptyColor }} />
<span style={{ position: 'absolute', left: 0, top: 0, overflow: 'hidden', width: '50%' }}>
<i className="fa fa-star" style={{ color: filledColor }} />
<i className="fa-solid fa-star" style={{ color: filledColor }} />
</span>
</span>
);
@@ -59,7 +59,7 @@ export const StarRating: UserComponent<StarRatingProps> = ({
stars.push(
<i
key={i}
className="fa fa-star"
className="fa-solid fa-star"
style={{ color: emptyColor, fontSize: size }}
/>
);
@@ -136,11 +136,11 @@ StarRating.craft = {
let starsHtml = '';
for (let i = 1; i <= maxStars; i++) {
if (i <= Math.floor(rating)) {
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
starsHtml += `<i class="fa-solid fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa-solid fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa-solid fa-star" style="color:${filledColor}"></i></span></span>`;
} else {
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
starsHtml += `<i class="fa-solid fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
}
}
+2 -2
View File
@@ -113,7 +113,7 @@ const VIDEO_PLACEHOLDER = (
textAlign: 'center' as const,
}}
>
<i className="fa fa-play-circle" style={{ fontSize: 36, opacity: 0.5 }} />
<i className="fa-solid fa-circle-play" style={{ fontSize: 36, opacity: 0.5 }} />
<span>Add a video URL in settings</span>
</div>
);
@@ -217,7 +217,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
zIndex: 0,
}}
>
<i className="fa fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
<i className="fa-solid fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
</div>
)}
{/* Overlay */}
@@ -30,8 +30,8 @@ describe('ContentSlider.toHtml accessibility (F1.1)', () => {
test('decorative chevron icons in arrows are aria-hidden', () => {
const { html } = toHtml({ slides, showArrows: true }, '');
expect(html).toMatch(/<i class="fa fa-chevron-left" aria-hidden="true"><\/i>/);
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
expect(html).toMatch(/<i class="fa-solid fa-chevron-left" aria-hidden="true"><\/i>/);
expect(html).toMatch(/<i class="fa-solid fa-chevron-right" aria-hidden="true"><\/i>/);
});
});
@@ -194,10 +194,10 @@ export const ContentSlider: UserComponent<ContentSliderProps> = ({
{showArrows && items.length > 1 && (
<>
<button onClick={goPrev} style={{ ...arrowStyle, left: '16px' }}>
<i className="fa fa-chevron-left" />
<i className="fa-solid fa-chevron-left" />
</button>
<button onClick={goNext} style={{ ...arrowStyle, right: '16px' }}>
<i className="fa fa-chevron-right" />
<i className="fa-solid fa-chevron-right" />
</button>
</>
)}
@@ -329,8 +329,8 @@ ContentSlider.craft = {
}).join('\n ');
const arrowsHtml = showArrows && items.length > 1
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left" aria-hidden="true"></i></button>
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right" aria-hidden="true"></i></button>`
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i></button>
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa-solid fa-chevron-right" aria-hidden="true"></i></button>`
: '';
const dotsHtml = showDots && items.length > 1
@@ -41,7 +41,7 @@ describe('Testimonials.toHtml single-layout export parity', () => {
describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
test('star glyphs are aria-hidden', () => {
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
const stars = html.match(/<i class="fa fa-star[^"]*"[^>]*>/g) || [];
const stars = html.match(/<i class="fa-(?:solid|regular) fa-star"[^>]*>/g) || [];
expect(stars.length).toBeGreaterThan(0);
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
@@ -52,7 +52,7 @@ function renderStars(count: number, color: string): React.ReactNode {
{[1, 2, 3, 4, 5].map((i) => (
<i
key={i}
className={`fa ${i <= count ? 'fa-star' : 'fa-star-o'}`}
className={i <= count ? 'fa-solid fa-star' : 'fa-regular fa-star'}
style={{ color, fontSize: '14px' }}
/>
))}
@@ -68,7 +68,7 @@ function starsHtml(count: number, color: string): string {
// non-numeric/garbage value).
const safeCount = Number(count) || 0;
const stars = [1, 2, 3, 4, 5].map((i) =>
`<i class="fa ${i <= safeCount ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
`<i class="${i <= safeCount ? 'fa-solid fa-star' : 'fa-regular fa-star'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
).join('');
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px" role="img" aria-label="${escapeAttr(`Rating: ${safeCount} out of 5`)}">${stars}</div>`;
}
@@ -108,7 +108,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
<option value="">+ Add Platform...</option>
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch'].map((p) => (
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch', 'bluesky', 'threads', 'discord', 'mastodon', 'reddit', 'telegram'].map((p) => (
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
))}
</select>
+39
View File
@@ -0,0 +1,39 @@
import { describe, test, expect } from 'vitest';
import { faClass } from './fa-icon';
import { SocialLinks } from '../components/basic/SocialLinks';
describe('faClass (FA4 name -> FA7 class string)', () => {
test('solid default, brand and regular mappings', () => {
expect(faClass('fa-star')).toBe('fa-solid fa-star');
expect(faClass('fa-facebook')).toBe('fa-brands fa-facebook');
expect(faClass('fa-star-o')).toBe('fa-regular fa-star');
expect(faClass('fa-picture-o')).toBe('fa-regular fa-image');
expect(faClass('fa-star-half-o')).toBe('fa-regular fa-star-half-stroke');
});
test('legacy "fa fa-x" and already-styled values are normalised, extra tokens kept', () => {
expect(faClass('fa fa-github')).toBe('fa-brands fa-github');
expect(faClass('fa-brands fa-x-twitter')).toBe('fa-brands fa-x-twitter');
expect(faClass('fa-star fa-spin')).toBe('fa-solid fa-star fa-spin');
});
test('empty falls back to the star', () => {
expect(faClass('')).toBe('fa-solid fa-star');
expect(faClass(undefined)).toBe('fa-solid fa-star');
});
});
describe('SocialLinks exports per-network FA7 brand icons', () => {
const toHtml = (SocialLinks as any).toHtml;
const cases: Array<[string, string]> = [
['twitter', 'fa-x-twitter'], ['tiktok', 'fa-tiktok'], ['bluesky', 'fa-bluesky'], ['threads', 'fa-threads'],
['discord', 'fa-discord'], ['mastodon', 'fa-mastodon'], ['facebook', 'fa-facebook'], ['spotify', 'fa-spotify'],
['twitch', 'fa-twitch'], ['youtube', 'fa-youtube'], ['reddit', 'fa-reddit'], ['telegram', 'fa-telegram'],
];
test.each(cases)('%s -> fa-brands %s', (platform, icon) => {
const { html } = toHtml({ links: [{ platform, url: '#' }] }, '');
expect(html).toContain(`<i class="fa-brands ${icon}"`);
expect(html).not.toMatch(/class="fa /);
});
test('an unknown platform falls back to a solid link icon', () => {
expect(toHtml({ links: [{ platform: 'nope', url: '#' }] }, '').html).toContain('<i class="fa-solid fa-link"');
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Font Awesome 7 (Free) class helper. The builder used Font Awesome 4 names
* (`fa fa-facebook`); v7 needs a style class per icon (`fa-brands fa-facebook`,
* `fa-solid fa-star`, `fa-regular fa-star`). Everything the exporters emit goes
* through here so the mapping lives in one place.
*/
/** Brand glyphs (Font Awesome Brands font). Legacy FA4 brand names are a subset. */
const BRANDS = new Set([
'facebook', 'facebook-f', 'facebook-square', 'twitter', 'twitter-square', 'x-twitter', 'instagram', 'linkedin',
'linkedin-in', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch',
'bluesky', 'threads', 'discord', 'mastodon', 'reddit', 'telegram', 'google', 'apple', 'amazon', 'paypal',
'stripe', 'vimeo', 'wordpress', 'slack', 'dribbble', 'behance', 'medium', 'tumblr', 'soundcloud', 'skype',
]);
/** FA4 `-o` (outline) names whose v7 regular-style name differs from `<name>` minus `-o`. */
const REGULAR_RENAMES: Record<string, string> = {
'picture-o': 'image',
'star-half-o': 'star-half-stroke',
'trash-o': 'trash-can',
'file-o': 'file',
'folder-o': 'folder',
'envelope-o': 'envelope',
};
const STYLE_TOKENS = new Set([
'fa-solid', 'fa-regular', 'fa-brands', 'fa-light', 'fa-thin', 'fa-duotone', 'fa-classic', 'fas', 'far', 'fab',
]);
/**
* Class string for an icon value: a bare/legacy name (`fa-star`, `fa-star-o`,
* `fa-facebook`, `fa fa-star`) or an already-styled one (`fa-brands fa-x-twitter`).
* Returns e.g. `fa-solid fa-star`. Extra tokens (fa-spin, fa-2x) pass through.
*/
export function faClass(icon: string | undefined, fallback = 'fa-star'): string {
const tokens = String(icon ?? '').trim().split(/\s+/).filter((t) => t && t !== 'fa');
if (tokens.length === 0) tokens.push(fallback);
if (tokens.some((t) => STYLE_TOKENS.has(t))) return tokens.join(' ');
const idx = tokens.findIndex((t) => /^fa-/.test(t));
if (idx < 0) return ['fa-solid', ...tokens].join(' ');
const name = tokens[idx].slice(3);
const rest = tokens.filter((_, i) => i !== idx);
let style = 'fa-solid';
let out = name;
if (BRANDS.has(name)) {
style = 'fa-brands';
} else if (name in REGULAR_RENAMES) {
style = 'fa-regular';
out = REGULAR_RENAMES[name];
} else if (/-o$/.test(name)) {
style = 'fa-regular';
out = name.slice(0, -2);
}
return [style, `fa-${out}`, ...rest].join(' ');
}