Files
how2ice 005cf56baf
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
init project
2025-12-12 14:26:25 +09:00

353 lines
11 KiB
JavaScript
Executable File

import path from 'path';
import fs from 'node:fs/promises';
import yargs from 'yargs';
import { rimrafSync } from 'rimraf';
import Mustache from 'mustache';
import globAsync from 'fast-glob';
import * as svgo from 'svgo';
import { fileURLToPath } from 'url';
import { intersection } from 'es-toolkit/array';
import { Queue } from '@mui/internal-waterfall';
import { hideBin } from 'yargs/helpers';
const currentDirectory = path.dirname(fileURLToPath(new URL(import.meta.url)));
export const RENAME_FILTER_DEFAULT = './renameFilters/default.mjs';
export const RENAME_FILTER_MUI = './renameFilters/material-design-icons.mjs';
/**
* Converts directory separators to slashes, so the path can be used in fast-glob.
* @param {string} pathToNormalize
* @returns
*/
function normalizePath(pathToNormalize) {
return pathToNormalize.replace(/\\/g, '/');
}
/**
* Return Pascal-Cased component name.
* @param {string} destPath
* @returns {string} class name
*/
export function getComponentName(destPath) {
const splitregex = new RegExp(`[\\${path.sep}-]+`);
const parts = destPath
.replace('.js', '')
.split(splitregex)
.map((part) => part.charAt(0).toUpperCase() + part.substring(1));
return parts.join('');
}
async function generateIndex(options) {
const files = await globAsync(normalizePath(path.join(options.outputDir, '*.js')));
const index = files
.map((file) => {
const typename = path.basename(file).replace('.js', '');
return `export { default as ${typename} } from './${typename}';\n`;
})
.sort()
.join('');
await fs.writeFile(path.join(options.outputDir, 'index.js'), index);
}
// Noise introduced by Google by mistake
const noises = [
['="M0 0h24v24H0V0zm0 0h24v24H0V0z', '="'],
['="M0 0h24v24H0zm0 0h24v24H0zm0 0h24v24H0z', '="'],
];
function removeNoise(input, prevInput = null) {
if (input === prevInput) {
return input;
}
let output = input;
noises.forEach(([search, replace]) => {
if (output.includes(search)) {
output = output.replace(search, replace);
}
});
return removeNoise(output, input);
}
export function cleanPaths({ svgPath, data }) {
// Remove hardcoded color fill before optimizing so that empty groups are removed
const input = data
.replace(/ fill="#010101"/g, '')
.replace(/<rect fill="none" width="24" height="24"\/>/g, '')
.replace(/<rect id="SVGID_1_" width="24" height="24"\/>/g, '');
const result = svgo.optimize(input, {
floatPrecision: 4,
multipass: true,
plugins: [
{ name: 'cleanupAttrs' },
{ name: 'removeDoctype' },
{ name: 'removeXMLProcInst' },
{ name: 'removeComments' },
{ name: 'removeMetadata' },
{ name: 'removeTitle' },
{ name: 'removeDesc' },
{ name: 'removeUselessDefs' },
{ name: 'removeEditorsNSData' },
{ name: 'removeEmptyAttrs' },
{ name: 'removeHiddenElems' },
{ name: 'removeEmptyText' },
{ name: 'removeViewBox' },
{ name: 'cleanupEnableBackground' },
{ name: 'minifyStyles' },
{ name: 'convertStyleToAttrs' },
{ name: 'convertColors' },
{ name: 'convertPathData' },
{ name: 'convertTransform' },
{ name: 'removeUnknownsAndDefaults' },
{ name: 'removeNonInheritableGroupAttrs' },
{
name: 'removeUselessStrokeAndFill',
params: {
// https://github.com/svg/svgo/issues/727#issuecomment-303115276
removeNone: true,
},
},
{ name: 'removeUnusedNS' },
{ name: 'cleanupIds' },
{ name: 'cleanupNumericValues' },
{ name: 'cleanupListOfValues' },
{ name: 'moveElemsAttrsToGroup' },
{ name: 'moveGroupAttrsToElems' },
{ name: 'collapseGroups' },
{ name: 'removeRasterImages' },
{ name: 'mergePaths' },
{ name: 'convertShapeToPath' },
{ name: 'sortAttrs' },
{ name: 'removeDimensions' },
{ name: 'removeElementsByAttr' },
{ name: 'removeStyleElement' },
{ name: 'removeScripts' },
{ name: 'removeEmptyContainers' },
],
});
// True if the svg has multiple children
let childrenAsArray = false;
const jsxResult = svgo.optimize(result.data, {
plugins: [
{
name: 'svgAsReactFragment',
fn: () => {
return {
root: {
enter(root) {
const [svg, ...rootChildren] = root.children;
if (rootChildren.length > 0) {
throw new Error('Expected a single child of the root');
}
if (svg.type !== 'element' || svg.name !== 'svg') {
throw new Error('Expected an svg element as the root child');
}
if (svg.children.length > 1) {
childrenAsArray = true;
svg.children.forEach((svgChild, index) => {
svgChild.attributes.key = index;
// Original name will be restored later
// We just need a mechanism to convert the resulting
// svg string into an array of JSX elements
svgChild.name = `SVGChild:${svgChild.name}`;
});
}
root.children = svg.children;
},
},
};
},
},
],
});
// Extract the paths from the svg string
// Clean xml paths
// TODO: Implement as svgo plugins instead
let paths = jsxResult.data
.replace(/"\/>/g, '" />')
.replace(/fill-opacity=/g, 'fillOpacity=')
.replace(/xlink:href=/g, 'xlinkHref=')
.replace(/clip-rule=/g, 'clipRule=')
.replace(/fill-rule=/g, 'fillRule=')
.replace(/ clip-path=".+?"/g, '') // Fix visibility issue and save some bytes.
.replace(/<clipPath.+?<\/clipPath>/g, ''); // Remove unused definitions
const sizeMatch = svgPath.match(/^.*_([0-9]+)px.svg$/);
const size = sizeMatch ? Number(sizeMatch[1]) : null;
if (size !== 24) {
const scale = Math.round((24 / size) * 100) / 100; // Keep a maximum of 2 decimals
paths = paths.replace('clipPath="url(#b)" ', '');
paths = paths.replace(/<path /g, `<path transform="scale(${scale}, ${scale})" `);
}
paths = removeNoise(paths);
if (childrenAsArray) {
const pathsCommaSeparated = paths
// handle self-closing tags
.replace(/key="\d+" \/>/g, '$&,')
// handle the rest
.replace(/<\/SVGChild:(\w+)>/g, '</$1>,');
paths = `[${pathsCommaSeparated}]`;
}
paths = paths.replace(/SVGChild:/g, '');
return paths;
}
async function worker({ progress, svgPath, options, renameFilter, template }) {
progress();
const normalizedSvgPath = path.normalize(svgPath);
const svgPathObj = path.parse(normalizedSvgPath);
const innerPath = path
.dirname(normalizedSvgPath)
.replace(options.svgDir, '')
.replace(path.relative(process.cwd(), options.svgDir), ''); // for relative dirs
const destPath = renameFilter(svgPathObj, innerPath, options);
const outputFileDir = path.dirname(path.join(options.outputDir, destPath));
await fs.mkdir(outputFileDir, { recursive: true });
const data = await fs.readFile(svgPath, { encoding: 'utf8' });
const paths = cleanPaths({ svgPath, data });
const componentName = getComponentName(destPath);
const fileString = Mustache.render(template, {
paths,
componentName,
});
const absDestPath = path.join(options.outputDir, destPath);
await fs.writeFile(absDestPath, fileString);
}
export async function handler(options) {
const progress = options.disableLog ? () => {} : () => process.stdout.write('.');
rimrafSync(`${options.outputDir}/*.js`, { glob: true }); // Clean old files
let renameFilter = options.renameFilter;
if (typeof renameFilter === 'string') {
const renameFilterModule = await import(renameFilter);
renameFilter = renameFilterModule.default;
}
if (typeof renameFilter !== 'function') {
throw new Error('renameFilter must be a function');
}
await fs.mkdir(options.outputDir, { recursive: true });
const [svgPaths, template] = await Promise.all([
globAsync(normalizePath(path.join(options.svgDir, options.glob))),
fs.readFile(path.join(currentDirectory, 'templateSvgIcon.js'), {
encoding: 'utf8',
}),
]);
const queue = new Queue(
(svgPath) =>
worker({
progress,
svgPath,
options,
renameFilter,
template,
}),
{ concurrency: 8 },
);
queue.push(svgPaths);
await queue.wait({ empty: true });
let legacyFiles = await globAsync(normalizePath(path.join(currentDirectory, '/legacy', '*.js')));
legacyFiles = legacyFiles.map((file) => path.basename(file));
let generatedFiles = await globAsync(normalizePath(path.join(options.outputDir, '*.js')));
generatedFiles = generatedFiles.map((file) => path.basename(file));
const duplicatedIconsLegacy = intersection(legacyFiles, generatedFiles);
if (duplicatedIconsLegacy.length > 0) {
throw new Error(
`Duplicated icons in legacy folder. Either \n` +
`1. Remove these from the /legacy folder\n` +
`2. Add them to the blacklist to keep the legacy version\n` +
`The following icons are duplicated: \n${duplicatedIconsLegacy.join('\n')}`,
);
}
await fs.cp(path.join(currentDirectory, '/legacy'), options.outputDir, { recursive: true });
await fs.cp(path.join(currentDirectory, '/custom'), options.outputDir, { recursive: true });
await generateIndex(options);
}
const nodePath = path.resolve(process.argv[1]);
const modulePath = path.resolve(fileURLToPath(import.meta.url));
const isRunningDirectlyViaCLI = nodePath === modulePath;
if (isRunningDirectlyViaCLI) {
yargs()
.command({
command: '$0>',
description: "Build JSX components from SVG's.",
handler,
builder: (command) => {
command
.option('output-dir', {
required: true,
type: 'string',
describe: 'Directory to output jsx components',
})
.option('svg-dir', {
required: true,
type: 'string',
describe: 'Directory to output jsx components',
})
.option('glob', {
type: 'string',
describe: 'Glob to match inside of --svg-dir',
default: '**/*.svg',
})
.option('inner-path', {
type: 'string',
describe:
'"Reach into" subdirs, since libraries like material-design-icons' +
' use arbitrary build directories to organize icons' +
' e.g. "action/svg/production/icon_3d_rotation_24px.svg"',
default: '',
})
.option('file-suffix', {
type: 'string',
describe:
'Filter only files ending with a suffix (pretty much only for @mui/icons-material)',
})
.option('rename-filter', {
type: 'string',
describe: 'Path to JS module used to rename destination filename and path.',
default: RENAME_FILTER_DEFAULT,
})
.option('disable-log', {
type: 'boolean',
describe: 'If true, does not produce any output in STDOUT.',
default: false,
});
},
})
.help()
.strict(true)
.version(false)
.parse(hideBin(process.argv));
}