libs/@guardian/source/scripts/create-icons/create-icon-component.ts (162 lines of code) (raw):
import type { TransformOptions } from '@babel/core';
import { transform } from '@svgr/core';
import type { Config } from '@svgr/core';
import { labels } from '../../src/react-components/icons/labels';
// SVGR uses babel under the hood to create the jsx
// this is our custom transform for it to use
const createBabelConfig = ({ retainFill }: { retainFill: boolean }) => {
const babelConfig: TransformOptions = {};
const plugins = [];
// remove fill attribute from all icons except those with special fill colours
if (!retainFill) {
plugins.push([
'@svgr/babel-plugin-remove-jsx-attribute',
{
elements: ['svg', 'path'],
attributes: ['fill'],
},
]);
plugins.push([
'@svgr/babel-plugin-add-jsx-attribute',
{
elements: ['path'],
attributes: [
{
name: 'fill',
value: 'theme?.fill',
spread: false,
literal: true,
position: 'end',
},
],
},
]);
}
// replace viewbox with legacy 30x30 viewbox
// TODO: a future version of Source should expose icons with the viewboxes
// defined in Figma
plugins.push([
'@svgr/babel-plugin-replace-jsx-attribute-value',
{
values: [
{ value: '0 0 24 24', newValue: '-3 -3 30 30' },
// legacy 50x20 wide viewbox
{ value: '0 0 48 24', newValue: '-1 2 50 20' },
],
},
]);
if (plugins.length === 0) {
return {};
}
babelConfig.plugins = plugins;
return babelConfig;
};
type CreateIconComponentProps = {
icon: { name: string; svg: string };
retainFill: boolean;
isWideIcon: boolean;
};
export const createIconComponent = async ({
icon,
retainFill,
isWideIcon,
}: CreateIconComponentProps) => {
// SVGR template
// https://react-svgr.com/docs/node-api/
const template: Config['template'] = (variables, { tpl }) => {
if (!retainFill) {
return tpl`
import { css } from '@emotion/react';
import { iconSize, visuallyHidden } from '../../../foundations';
import type { IconProps } from '../..';
${variables.imports};
const ${variables.componentName} = ({
size,
theme,
}: IconProps) => (
${variables.jsx}
);
`;
}
return tpl`
import { css } from '@emotion/react';
import { iconSize, visuallyHidden } from '../../../foundations';
import type { IconProps } from '../..';
${variables.imports};
const ${variables.componentName} = ({
size,
}: IconProps) => (
${variables.jsx}
);
`;
};
const svgProps = {
focusable: '{false}',
'aria-hidden': '{true}',
width: isWideIcon ? '{undefined}' : '{size ? iconSize[size] : undefined}',
height: isWideIcon ? '{size ? iconSize[size] : undefined}' : '{undefined}',
};
const svgComponentName = 'Svg';
const iconComponentName =
'Svg' +
icon.name
.split('-')
.map((s) => {
const [firstLetter, ...rest] = s;
if (firstLetter) {
return firstLetter.toLocaleUpperCase() + rest.join('');
}
return s;
})
.join('');
const iconComponent = await transform(
icon.svg,
{
icon: true,
plugins: [
'@svgr/plugin-svgo',
'@svgr/plugin-jsx',
'@svgr/plugin-prettier',
],
jsx: {
babelConfig: createBabelConfig({ retainFill }),
},
svgoConfig: {
plugins: [
{ name: 'cleanupIds', params: { minify: true } },
{ name: 'prefixIds', params: { prefix: icon.name } },
{ name: 'convertPathData' },
],
},
typescript: true,
jsxRuntime: 'automatic',
expandProps: false,
svgProps,
template,
},
{
componentName: svgComponentName,
},
);
const label = labels[icon.name];
if (!label) {
// This error is thrown when the accessible label data for the icon is missing in Figma.
throw new Error(
`Warning: No accessible label found for ${icon.name}! Please double check that it is specified correctly in Figma.`,
);
}
const iconComponentExport = `
export const ${iconComponentName} = ({
size,${!retainFill ? '\ntheme,' : ''}
isAnnouncedByScreenReader = false,
}: IconProps) => (
<>
<${svgComponentName} size={size} ${!retainFill ? 'theme={theme}' : ''} />
{isAnnouncedByScreenReader ? (
<span
css={css\`
\${visuallyHidden}
\`}
>
${label}
</span>
) : (
''
)}
</>
);`;
return {
componentName: iconComponentName,
component: [iconComponent, iconComponentExport].join('\n'),
};
};