Document application modes (development/debug/production) Add global file drop handler, order column normalization, SPA hash fix Serve CDN assets via /_vendor/ URLs instead of merging into bundles Add production minification with license preservation Improve JSON formatting for debugging and production optimization Add CDN asset caching with CSS URL inlining for production builds Add three-mode system (development, debug, production) Update Manifest CLAUDE.md to reflect helper class architecture Refactor Manifest.php into helper classes for better organization Pre-manifest-refactor checkpoint: Add app_mode documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
534 lines
14 KiB
JavaScript
Executable File
534 lines
14 KiB
JavaScript
Executable File
import fs from 'fs';
|
|
import path from 'path';
|
|
import colors from 'picocolors';
|
|
import { fileURLToPath } from 'url';
|
|
import { decodeSVGDatauri, encodeSVGDatauri } from './tools.js';
|
|
import { loadConfig, optimize } from '../svgo-node.js';
|
|
import { builtinPlugins } from '../builtin.js';
|
|
import { SvgoParserError } from '../parser.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const pkgPath = path.join(__dirname, '../../package.json');
|
|
const PKG = JSON.parse(await fs.promises.readFile(pkgPath, 'utf-8'));
|
|
|
|
/**
|
|
* Synchronously check if path is a directory. Tolerant to errors like ENOENT.
|
|
*
|
|
* @param {string} filePath
|
|
*/
|
|
export function checkIsDir(filePath) {
|
|
try {
|
|
return fs.lstatSync(filePath).isDirectory();
|
|
} catch {
|
|
return filePath.endsWith(path.sep);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('commander').Command} program
|
|
*/
|
|
export default function makeProgram(program) {
|
|
program
|
|
.name(PKG.name)
|
|
.description(PKG.description)
|
|
.version(PKG.version, '-v, --version')
|
|
.argument('[INPUT...]', 'Alias to --input')
|
|
.option('-i, --input <INPUT...>', 'Input files, "-" for STDIN')
|
|
.option('-s, --string <STRING>', 'Input SVG data string')
|
|
.option(
|
|
'-f, --folder <FOLDER>',
|
|
'Input folder, optimize and rewrite all *.svg files',
|
|
)
|
|
.option(
|
|
'-o, --output <OUTPUT...>',
|
|
'Output file or folder (by default the same as the input), "-" for STDOUT',
|
|
)
|
|
.option(
|
|
'-p, --precision <INTEGER>',
|
|
'Set number of digits in the fractional part, overrides plugins params',
|
|
)
|
|
.option(
|
|
'--config <CONFIG>',
|
|
'Custom config file, only .js, .mjs, and .cjs is supported',
|
|
)
|
|
.option(
|
|
'--datauri <FORMAT>',
|
|
'Output as Data URI string (base64), URI encoded (enc) or unencoded (unenc)',
|
|
)
|
|
.option(
|
|
'--multipass',
|
|
'Pass over SVGs multiple times to ensure all optimizations are applied',
|
|
)
|
|
.option('--pretty', 'Make SVG pretty printed')
|
|
.option('--indent <INTEGER>', 'Indent number when pretty printing SVGs')
|
|
.option(
|
|
'--eol <EOL>',
|
|
'Line break to use when outputting SVG: lf, crlf. If unspecified, uses platform default.',
|
|
)
|
|
.option('--final-newline', 'Ensure SVG ends with a line break')
|
|
.option(
|
|
'-r, --recursive',
|
|
"Use with '--folder'. Optimizes *.svg files in folders recursively.",
|
|
)
|
|
.option(
|
|
'--exclude <PATTERN...>',
|
|
"Use with '--folder'. Exclude files matching regular expression pattern.",
|
|
)
|
|
.option(
|
|
'-q, --quiet',
|
|
'Only output error messages, not regular status messages',
|
|
)
|
|
.option('--show-plugins', 'Show available plugins and exit')
|
|
// used by picocolors internally
|
|
.option('--no-color', 'Output plain text without color')
|
|
.action(action);
|
|
}
|
|
|
|
/**
|
|
* @param {ReadonlyArray<string>} args
|
|
* @param {any} opts
|
|
* @param {import('commander').Command} command
|
|
* @returns
|
|
*/
|
|
async function action(args, opts, command) {
|
|
const input = opts.input || args;
|
|
let output = opts.output;
|
|
/** @type {any} */
|
|
let config = {};
|
|
|
|
if (opts.datauri != null) {
|
|
if (
|
|
opts.datauri !== 'base64' &&
|
|
opts.datauri !== 'enc' &&
|
|
opts.datauri !== 'unenc'
|
|
) {
|
|
console.error(
|
|
"error: option '--datauri' must have one of the following values: 'base64', 'enc' or 'unenc'",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (opts.indent != null) {
|
|
const number = Number.parseInt(opts.indent, 10);
|
|
if (Number.isNaN(number)) {
|
|
console.error(
|
|
"error: option '--indent' argument must be an integer number",
|
|
);
|
|
process.exit(1);
|
|
} else {
|
|
opts.indent = number;
|
|
}
|
|
}
|
|
|
|
if (opts.eol != null && opts.eol !== 'lf' && opts.eol !== 'crlf') {
|
|
console.error(
|
|
"error: option '--eol' must have one of the following values: 'lf' or 'crlf'",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// --show-plugins
|
|
if (opts.showPlugins) {
|
|
showAvailablePlugins();
|
|
return;
|
|
}
|
|
|
|
// w/o anything
|
|
if (
|
|
(input.length === 0 || input[0] === '-') &&
|
|
!opts.string &&
|
|
!opts.stdin &&
|
|
!opts.folder &&
|
|
process.stdin.isTTY === true
|
|
) {
|
|
return command.help();
|
|
}
|
|
|
|
if (process?.versions?.node && PKG.engines.node) {
|
|
// @ts-expect-error We control this and ensure it is never null.
|
|
const nodeVersion = String(PKG.engines.node).match(/\d*(\.\d+)*/)[0];
|
|
if (parseFloat(process.versions.node) < parseFloat(nodeVersion)) {
|
|
throw Error(
|
|
`${PKG.name} requires Node.js version ${nodeVersion} or higher.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// --config
|
|
const loadedConfig = await loadConfig(opts.config);
|
|
if (loadedConfig != null) {
|
|
config = loadedConfig;
|
|
}
|
|
|
|
// --quiet
|
|
if (opts.quiet) {
|
|
config.quiet = opts.quiet;
|
|
}
|
|
|
|
// --recursive
|
|
if (opts.recursive) {
|
|
config.recursive = opts.recursive;
|
|
}
|
|
|
|
// --exclude
|
|
config.exclude = opts.exclude
|
|
? opts.exclude.map((/** @type {string} */ pattern) => RegExp(pattern))
|
|
: [];
|
|
|
|
// --precision
|
|
if (opts.precision != null) {
|
|
const number = Number.parseInt(opts.precision, 10);
|
|
if (Number.isNaN(number)) {
|
|
console.error(
|
|
"error: option '-p, --precision' argument must be an integer number",
|
|
);
|
|
process.exit(1);
|
|
} else {
|
|
config.floatPrecision = Math.min(Math.max(0, number), 20);
|
|
}
|
|
}
|
|
|
|
// --multipass
|
|
if (opts.multipass) {
|
|
config.multipass = true;
|
|
}
|
|
|
|
// --pretty
|
|
if (opts.pretty) {
|
|
config.js2svg = config.js2svg || {};
|
|
config.js2svg.pretty = true;
|
|
if (opts.indent != null) {
|
|
config.js2svg.indent = opts.indent;
|
|
}
|
|
}
|
|
|
|
// --eol
|
|
if (opts.eol) {
|
|
config.js2svg = config.js2svg || {};
|
|
config.js2svg.eol = opts.eol;
|
|
}
|
|
|
|
// --final-newline
|
|
if (opts.finalNewline) {
|
|
config.js2svg = config.js2svg || {};
|
|
config.js2svg.finalNewline = true;
|
|
}
|
|
|
|
// --output
|
|
if (output) {
|
|
if (input.length && input[0] != '-') {
|
|
if (output.length == 1 && checkIsDir(output[0])) {
|
|
const dir = output[0];
|
|
for (let i = 0; i < input.length; i++) {
|
|
output[i] = checkIsDir(input[i])
|
|
? input[i]
|
|
: path.resolve(dir, path.basename(input[i]));
|
|
}
|
|
} else if (output.length < input.length) {
|
|
output = output.concat(input.slice(output.length));
|
|
}
|
|
}
|
|
} else if (input.length) {
|
|
output = input;
|
|
} else if (opts.string) {
|
|
output = '-';
|
|
}
|
|
|
|
if (opts.datauri) {
|
|
config.datauri = opts.datauri;
|
|
}
|
|
|
|
// --folder
|
|
if (opts.folder) {
|
|
const outputFolder = (output && output[0]) || opts.folder;
|
|
await optimizeFolder(config, opts.folder, outputFolder);
|
|
}
|
|
|
|
// --input
|
|
if (input.length !== 0) {
|
|
// STDIN
|
|
if (input[0] === '-') {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '';
|
|
const file = output[0];
|
|
|
|
process.stdin
|
|
.on('data', (chunk) => (data += chunk))
|
|
.once('end', () =>
|
|
processSVGData(config, null, data, file).then(resolve, reject),
|
|
);
|
|
});
|
|
// file
|
|
} else {
|
|
await Promise.all(
|
|
input.map((/** @type {string} */ file, /** @type {number} */ n) =>
|
|
optimizeFile(config, file, output[n]),
|
|
),
|
|
);
|
|
}
|
|
|
|
// --string
|
|
} else if (opts.string) {
|
|
const data = decodeSVGDatauri(opts.string);
|
|
|
|
return processSVGData(config, null, data, output[0]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optimize SVG files in a directory.
|
|
*
|
|
* @param {any} config options
|
|
* @param {string} dir input directory
|
|
* @param {string} output output directory
|
|
* @return {Promise<any>}
|
|
*/
|
|
function optimizeFolder(config, dir, output) {
|
|
if (!config.quiet) {
|
|
console.log(`Processing directory '${dir}':\n`);
|
|
}
|
|
return fs.promises
|
|
.readdir(dir)
|
|
.then((files) => processDirectory(config, dir, files, output));
|
|
}
|
|
|
|
/**
|
|
* Process given files, take only SVG.
|
|
*
|
|
* @param {any} config options
|
|
* @param {string} dir input directory
|
|
* @param {ReadonlyArray<string>} files list of file names in the directory
|
|
* @param {string} output output directory
|
|
* @return {Promise<any>}
|
|
*/
|
|
function processDirectory(config, dir, files, output) {
|
|
// take only *.svg files, recursively if necessary
|
|
const svgFilesDescriptions = getFilesDescriptions(config, dir, files, output);
|
|
|
|
return svgFilesDescriptions.length
|
|
? Promise.all(
|
|
svgFilesDescriptions.map((fileDescription) =>
|
|
optimizeFile(
|
|
config,
|
|
fileDescription.inputPath,
|
|
fileDescription.outputPath,
|
|
),
|
|
),
|
|
)
|
|
: Promise.reject(
|
|
new Error(`No SVG files have been found in '${dir}' directory.`),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get SVG files descriptions.
|
|
*
|
|
* @param {any} config options
|
|
* @param {string} dir input directory
|
|
* @param {ReadonlyArray<string>} files list of file names in the directory
|
|
* @param {string} output output directory
|
|
* @return {any[]}
|
|
*/
|
|
function getFilesDescriptions(config, dir, files, output) {
|
|
const filesInThisFolder = files
|
|
.filter(
|
|
(name) =>
|
|
name.slice(-4).toLowerCase() === '.svg' &&
|
|
!config.exclude.some((/** @type {RegExp} */ regExclude) =>
|
|
regExclude.test(name),
|
|
),
|
|
)
|
|
.map((name) => ({
|
|
inputPath: path.resolve(dir, name),
|
|
outputPath: path.resolve(output, name),
|
|
}));
|
|
|
|
if (!config.recursive) {
|
|
return filesInThisFolder;
|
|
}
|
|
|
|
return filesInThisFolder.concat(
|
|
files
|
|
.filter((name) => checkIsDir(path.resolve(dir, name)))
|
|
.map((subFolderName) => {
|
|
const subFolderPath = path.resolve(dir, subFolderName);
|
|
const subFolderFiles = fs.readdirSync(subFolderPath);
|
|
const subFolderOutput = path.resolve(output, subFolderName);
|
|
return getFilesDescriptions(
|
|
config,
|
|
subFolderPath,
|
|
subFolderFiles,
|
|
subFolderOutput,
|
|
);
|
|
})
|
|
.reduce((a, b) => a.concat(b), []),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Read SVG file and pass to processing.
|
|
*
|
|
* @param {any} config options
|
|
* @param {string} file
|
|
* @param {string} output
|
|
* @return {Promise<any>}
|
|
*/
|
|
function optimizeFile(config, file, output) {
|
|
return fs.promises.readFile(file, 'utf8').then(
|
|
(data) => processSVGData(config, { path: file }, data, output, file),
|
|
(error) => checkOptimizeFileError(config, file, output, error),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Optimize SVG data.
|
|
*
|
|
* @param {any} config options
|
|
* @param {?{ path: string }} info
|
|
* @param {string} data SVG content to optimize
|
|
* @param {string} output where to write optimized file
|
|
* @param {any=} input input file name (being used if output is a directory)
|
|
* @return {Promise<any>}
|
|
*/
|
|
function processSVGData(config, info, data, output, input) {
|
|
const startTime = Date.now();
|
|
const prevFileSize = Buffer.byteLength(data, 'utf8');
|
|
|
|
let result;
|
|
try {
|
|
result = optimize(data, { ...config, ...info });
|
|
} catch (error) {
|
|
if (error instanceof SvgoParserError) {
|
|
console.error(colors.red(error.toString()));
|
|
process.exit(1);
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
if (config.datauri) {
|
|
result.data = encodeSVGDatauri(result.data, config.datauri);
|
|
}
|
|
const resultFileSize = Buffer.byteLength(result.data, 'utf8');
|
|
const processingTime = Date.now() - startTime;
|
|
|
|
return writeOutput(input, output, result.data).then(
|
|
function () {
|
|
if (!config.quiet && output != '-') {
|
|
if (input) {
|
|
console.log(`\n${path.basename(input)}:`);
|
|
}
|
|
printTimeInfo(processingTime);
|
|
printProfitInfo(prevFileSize, resultFileSize);
|
|
}
|
|
},
|
|
(error) =>
|
|
Promise.reject(
|
|
new Error(
|
|
error.code === 'ENOTDIR'
|
|
? `Error: output '${output}' is not a directory.`
|
|
: error,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Write result of an optimization.
|
|
*
|
|
* @param {string} input
|
|
* @param {string} output output file name. '-' for stdout
|
|
* @param {string} data data to write
|
|
* @return {Promise<void>}
|
|
*/
|
|
async function writeOutput(input, output, data) {
|
|
if (output == '-') {
|
|
process.stdout.write(data);
|
|
return Promise.resolve();
|
|
}
|
|
|
|
await fs.promises.mkdir(path.dirname(output), { recursive: true });
|
|
|
|
return fs.promises
|
|
.writeFile(output, data, 'utf8')
|
|
.catch((error) => checkWriteFileError(input, output, data, error));
|
|
}
|
|
|
|
/**
|
|
* Write time taken to optimize.
|
|
*
|
|
* @param {number} time time in milliseconds.
|
|
*/
|
|
function printTimeInfo(time) {
|
|
console.log(`Done in ${time} ms!`);
|
|
}
|
|
|
|
/**
|
|
* Write optimizing stats in a human-readable format.
|
|
*
|
|
* @param {number} inBytes size before optimization.
|
|
* @param {number} outBytes size after optimization.
|
|
*/
|
|
function printProfitInfo(inBytes, outBytes) {
|
|
const profitPercent = 100 - (outBytes * 100) / inBytes;
|
|
/** @type {[string, Function]} */
|
|
const ui = profitPercent < 0 ? ['+', colors.red] : ['-', colors.green];
|
|
|
|
console.log(
|
|
Math.round((inBytes / 1024) * 1000) / 1000 + ' KiB',
|
|
ui[0],
|
|
ui[1](Math.abs(Math.round(profitPercent * 10) / 10) + '%'),
|
|
'=',
|
|
Math.round((outBytes / 1024) * 1000) / 1000 + ' KiB',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check for errors, if it's a dir optimize the dir.
|
|
*
|
|
* @param {any} config
|
|
* @param {string} input
|
|
* @param {string} output
|
|
* @param {Error & { code: string, path: string }} error
|
|
* @return {Promise<void>}
|
|
*/
|
|
function checkOptimizeFileError(config, input, output, error) {
|
|
if (error.code == 'EISDIR') {
|
|
return optimizeFolder(config, input, output);
|
|
} else if (error.code == 'ENOENT') {
|
|
return Promise.reject(
|
|
new Error(`Error: no such file or directory '${error.path}'.`),
|
|
);
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
/**
|
|
* Check for saving file error. If the output is a dir, then write file there.
|
|
*
|
|
* @param {string} input
|
|
* @param {string} output
|
|
* @param {string} data
|
|
* @param {Error & { code: string }} error
|
|
* @return {Promise<void>}
|
|
*/
|
|
function checkWriteFileError(input, output, data, error) {
|
|
if (error.code == 'EISDIR' && input) {
|
|
return fs.promises.writeFile(
|
|
path.resolve(output, path.basename(input)),
|
|
data,
|
|
'utf8',
|
|
);
|
|
} else {
|
|
return Promise.reject(error);
|
|
}
|
|
}
|
|
|
|
/** Show list of available plugins with short description. */
|
|
function showAvailablePlugins() {
|
|
const list = builtinPlugins
|
|
.map((plugin) => ` [ ${colors.green(plugin.name)} ] ${plugin.description}`)
|
|
.join('\n');
|
|
console.log('Currently available plugins:\n' + list);
|
|
}
|