mirror of
https://github.com/simple-icons/simple-icons.git
synced 2024-11-16 00:59:07 +02:00
1169 lines
40 KiB
JavaScript
1169 lines
40 KiB
JavaScript
/**
|
|
* @file
|
|
* Linting rules for SVGLint to check SVG icons.
|
|
*/
|
|
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import {svgPathBbox} from 'svg-path-bbox';
|
|
import parsePath from 'svg-path-segments';
|
|
import svgpath from 'svgpath';
|
|
import {
|
|
SVG_PATH_REGEX,
|
|
collator,
|
|
getDirnameFromImportMeta,
|
|
getIconsData,
|
|
htmlFriendlyToTitle,
|
|
} from './sdk.mjs';
|
|
|
|
const __dirname = getDirnameFromImportMeta(import.meta.url);
|
|
const htmlNamedEntitiesFile = path.join(
|
|
__dirname,
|
|
'node_modules',
|
|
'named-html-entities-json',
|
|
'index.json',
|
|
);
|
|
const svglintIgnoredFile = path.join(__dirname, '.svglint-ignored.json');
|
|
|
|
const icons = await getIconsData();
|
|
const htmlNamedEntities = JSON.parse(
|
|
await fs.readFile(htmlNamedEntitiesFile, 'utf8'),
|
|
);
|
|
/** @type {{ [key: string]: { [key: string]: string } }} */
|
|
const svglintIgnores = JSON.parse(
|
|
await fs.readFile(svglintIgnoredFile, 'utf8'),
|
|
);
|
|
|
|
const svgRegexp =
|
|
/^<svg( \S*=".*"){3}><title>.*<\/title><path d=".*"\/><\/svg>$/;
|
|
const negativeZerosRegexp = /-0(?=[^.]|[\s\d\w]|$)/g;
|
|
|
|
const iconSize = 24;
|
|
const iconTargetCenter = iconSize / 2;
|
|
const iconFloatPrecision = 3;
|
|
const iconMaxFloatPrecision = 5;
|
|
const iconTolerance = 0.001;
|
|
|
|
// Set env SI_UPDATE_IGNORE to recreate the ignore file
|
|
const updateIgnoreFile = process.env.SI_UPDATE_IGNORE === 'true';
|
|
const ignoreFile = './.svglint-ignored.json';
|
|
const iconIgnored = updateIgnoreFile ? {} : svglintIgnores;
|
|
|
|
/**
|
|
* Sort an object alphabetically by key converting it to an array.
|
|
* @param {{ [key: string]: any }} object Object to sort by key.
|
|
* @returns {{ [key: string]: any }} Object sorted by key.
|
|
*/
|
|
const sortObjectByKey = (object) => {
|
|
return Object.fromEntries(
|
|
Object.keys(object)
|
|
.sort()
|
|
.map((k) => [k, object[k]]),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Sort an object alphabetically by value converting it to an array.
|
|
* @param {{ [key: string]: any }} object Object to sort by value.
|
|
* @returns {{ [key: string]: any }} Object sorted by value.
|
|
*/
|
|
const sortObjectByValue = (object) => {
|
|
return Object.fromEntries(
|
|
Object.keys(object)
|
|
.sort((a, b) => collator.compare(object[a], object[b]))
|
|
.map((k) => [k, object[k]]),
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Remove leading zeros from a number as a string.
|
|
* @param {number | string} numberOrString The number or string to remove leading zeros from.
|
|
* @returns {string} The number as a string without leading zeros.
|
|
*/
|
|
const removeLeadingZeros = (numberOrString) => {
|
|
// Convert 0.03 to '.03'
|
|
return numberOrString.toString().replace(/^(-?)(0)(\.?.+)/, '$1$3');
|
|
};
|
|
|
|
/**
|
|
* Given three points, returns if the middle one (x2, y2) is collinear
|
|
* to the line formed by the two limit points.
|
|
* @param {number} x1 The x coordinate of the first point.
|
|
* @param {number} y1 The y coordinate of the first point.
|
|
* @param {number} x2 The x coordinate of the second point.
|
|
* @param {number} y2 The y coordinate of the second point.
|
|
* @param {number} x3 The x coordinate of the third point.
|
|
* @param {number} y3 The y coordinate of the third point.
|
|
* @returns {boolean} Whether the middle point is collinear to the line.
|
|
*/
|
|
// eslint-disable-next-line max-params
|
|
const collinear = (x1, y1, x2, y2, x3, y3) => {
|
|
return x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2) === 0;
|
|
};
|
|
|
|
/**
|
|
* Returns the number of digits after the decimal point.
|
|
* @param {number} number_ The number to count the decimals of.
|
|
* @returns {number} The number of digits after the decimal point.
|
|
*/
|
|
const countDecimals = (number_) => {
|
|
if (number_ && number_ % 1) {
|
|
const [base, op, trail] = number_.toExponential().split(/e([+-])/);
|
|
const elen = Number.parseInt(trail, 10);
|
|
const index = base.indexOf('.');
|
|
return index === -1
|
|
? elen
|
|
: base.length - index - 1 + (op === '+' ? -elen : elen);
|
|
}
|
|
|
|
return 0;
|
|
};
|
|
|
|
/**
|
|
* Get the index at which the first path value of an SVG starts.
|
|
* @param {string} svgFileContent The raw SVG as text.
|
|
* @returns {number} The index at which the path value starts.
|
|
*/
|
|
const getPathDIndex = (svgFileContent) => {
|
|
const pathDStart = '<path d="';
|
|
return svgFileContent.indexOf(pathDStart) + pathDStart.length;
|
|
};
|
|
|
|
/**
|
|
* Get the index at which the text of the first `<title></title>` tag starts.
|
|
* @param {string} svgFileContent The raw SVG as text.
|
|
* @returns {number} The index at which the title text starts.
|
|
*/
|
|
const getTitleTextIndex = (svgFileContent) => {
|
|
const titleStart = '<title>';
|
|
return svgFileContent.indexOf(titleStart) + titleStart.length;
|
|
};
|
|
|
|
/**
|
|
* Shorten a string with ellipsis if it exceeds 20 characters.
|
|
* @param {string} string_ The string to shorten.
|
|
* @returns {string} The shortened string.
|
|
*/
|
|
const maybeShortenedWithEllipsis = (string_) => {
|
|
return string_.length > 20 ? `${string_.slice(0, 20)}...` : string_;
|
|
};
|
|
|
|
/**
|
|
* Check if a string is a number.
|
|
* @param {string} string_ The string to check.
|
|
* @returns {boolean} Whether the string is a number.
|
|
*/
|
|
const isNumber = (string_) =>
|
|
[...string_].every((character) => '0123456789'.includes(character));
|
|
|
|
/**
|
|
* Memoize a function which accepts a single argument.
|
|
* A second argument can be passed to be used as key.
|
|
* @param {(arg0: any) => any} function_ The function to memoize.
|
|
* @returns {(arg0: any) => any} The memoized function.
|
|
*/
|
|
const memoize = (function_) => {
|
|
/** @type {{ [key: string]: any }} */
|
|
const results = {};
|
|
|
|
/**
|
|
* Memoized function.
|
|
* @param {any} argument The argument to memoize.
|
|
* @returns {any} The result of the memoized function.
|
|
*/
|
|
return (argument) => {
|
|
results[argument] ||= function_(argument);
|
|
|
|
return results[argument];
|
|
};
|
|
};
|
|
|
|
/** @typedef {import('cheerio').Cheerio<import('domhandler').Document>} Cheerio */
|
|
|
|
/** @type {($icon: Cheerio) => string} */
|
|
const getIconPath = memoize(($icon) => $icon.find('path').attr('d'));
|
|
/** @type {(iconPath: string) => import('svg-path-segments').Segment[]} */
|
|
const getIconPathSegments = memoize((iconPath) => parsePath(iconPath));
|
|
/** @type {(iconPath: string) => import('svg-path-bbox').BBox} */
|
|
const getIconPathBbox = memoize((iconPath) => svgPathBbox(iconPath));
|
|
|
|
if (updateIgnoreFile) {
|
|
process.on('exit', async () => {
|
|
// Ensure object output order is consistent due to async svglint processing
|
|
const sorted = sortObjectByKey(iconIgnored);
|
|
for (const linterName in sorted) {
|
|
if (linterName) {
|
|
sorted[linterName] = sortObjectByValue(sorted[linterName]);
|
|
}
|
|
}
|
|
|
|
await fs.writeFile(ignoreFile, JSON.stringify(sorted, null, 2) + '\n', {
|
|
flag: 'w',
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if an icon is ignored by a linter rule.
|
|
* @param {string} linterRule The name of the linter rule.
|
|
* @param {string} path SVG path of the icon.
|
|
* @returns {boolean} Whether the icon is ignored by the linter rule.
|
|
*/
|
|
const isIgnored = (linterRule, path) => {
|
|
return (
|
|
iconIgnored[linterRule] && Object.hasOwn(iconIgnored[linterRule], path)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Ignore an icon for a linter rule.
|
|
* @param {string} linterRule The name of the linter rule.
|
|
* @param {string} path SVG path of the icon.
|
|
* @param {Cheerio} $ The SVG object.
|
|
*/
|
|
const ignoreIcon = (linterRule, path, $) => {
|
|
iconIgnored[linterRule] ||= {};
|
|
|
|
const title = $.find('title').text();
|
|
const iconName = htmlFriendlyToTitle(title);
|
|
|
|
iconIgnored[linterRule][path] = iconName;
|
|
};
|
|
|
|
/** @type {import('svglint').Config} */
|
|
const config = {
|
|
rules: {
|
|
elm: {
|
|
svg: 1,
|
|
'svg > title': 1,
|
|
'svg > path': 1,
|
|
'*': false,
|
|
},
|
|
attr: [
|
|
{
|
|
// Ensure that the SVG element has the appropriate attributes
|
|
// alphabetically ordered
|
|
role: 'img',
|
|
viewBox: `0 0 ${iconSize} ${iconSize}`,
|
|
xmlns: 'http://www.w3.org/2000/svg',
|
|
'rule::selector': 'svg',
|
|
'rule::whitelist': true,
|
|
'rule::order': true,
|
|
},
|
|
{
|
|
// Ensure that the title element has the appropriate attribute
|
|
'rule::selector': 'svg > title',
|
|
'rule::whitelist': true,
|
|
},
|
|
{
|
|
// Ensure that the path element only has the 'd' attribute
|
|
// (no style, opacity, etc.)
|
|
d: SVG_PATH_REGEX,
|
|
'rule::selector': 'svg > path',
|
|
'rule::whitelist': true,
|
|
},
|
|
],
|
|
custom: [
|
|
// eslint-disable-next-line complexity
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'icon-title';
|
|
|
|
const iconTitleText = $.find('title').text();
|
|
const xmlNamedEntitiesCodepoints = [38, 60, 62];
|
|
const xmlNamedEntities = ['amp', 'lt', 'gt'];
|
|
let _validCodepointsRepr = true;
|
|
|
|
// Avoid character codepoints as hexadecimal representation
|
|
const hexadecimalCodepoints = [
|
|
...iconTitleText.matchAll(/&#x([A-Fa-f\d]+);/g),
|
|
];
|
|
if (hexadecimalCodepoints.length > 0) {
|
|
_validCodepointsRepr = false;
|
|
|
|
for (const match of hexadecimalCodepoints) {
|
|
const charHexReprIndex =
|
|
getTitleTextIndex(ast.source) + match.index + 1;
|
|
const charDec = Number.parseInt(match[1], 16);
|
|
|
|
let charRepr;
|
|
if (xmlNamedEntitiesCodepoints.includes(charDec)) {
|
|
charRepr = `&${
|
|
xmlNamedEntities[xmlNamedEntitiesCodepoints.indexOf(charDec)]
|
|
};`;
|
|
} else if (charDec < 128) {
|
|
charRepr = String.fromCodePoint(charDec);
|
|
} else {
|
|
charRepr = `&#${charDec};`;
|
|
}
|
|
|
|
reporter.error(
|
|
'Hexadecimal representation of encoded character' +
|
|
` "${match[0]}" found at index ${charHexReprIndex}:` +
|
|
` replace it with "${charRepr}".`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Avoid character codepoints as named entities
|
|
const namedEntitiesCodepoints = [
|
|
...iconTitleText.matchAll(/&([A-Za-z\d]+);/g),
|
|
];
|
|
if (namedEntitiesCodepoints.length > 0) {
|
|
for (const match of namedEntitiesCodepoints) {
|
|
const namedEntiyReprIndex =
|
|
getTitleTextIndex(ast.source) + match.index + 1;
|
|
|
|
if (!xmlNamedEntities.includes(match[1].toLowerCase())) {
|
|
_validCodepointsRepr = false;
|
|
const namedEntityJsRepr = htmlNamedEntities[match[1]];
|
|
let replacement;
|
|
|
|
if (
|
|
namedEntityJsRepr === undefined ||
|
|
namedEntityJsRepr.length !== 1
|
|
) {
|
|
replacement = 'its decimal or literal representation';
|
|
} else {
|
|
const namedEntityDec = namedEntityJsRepr.codePointAt(0);
|
|
replacement =
|
|
namedEntityDec < 128
|
|
? `"${namedEntityJsRepr}"`
|
|
: `"&#${namedEntityDec};"`;
|
|
}
|
|
|
|
reporter.error(
|
|
'Named entity representation of encoded character' +
|
|
` "${match[0]}" found at index ${namedEntiyReprIndex}.` +
|
|
` Replace it with ${replacement}.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_validCodepointsRepr) {
|
|
// Compare encoded title with original title and report error if not equal
|
|
const encodingMatches = [
|
|
...iconTitleText.matchAll(/&(#(\d+)|(amp|quot|lt|gt));/g),
|
|
];
|
|
const encodedBuf = [];
|
|
|
|
const indexesToIgnore = [];
|
|
for (const match of encodingMatches) {
|
|
for (let r = match.index; r < match.index + match[0].length; r++) {
|
|
indexesToIgnore.push(r);
|
|
}
|
|
}
|
|
|
|
for (let i = iconTitleText.length - 1; i >= 0; i--) {
|
|
if (indexesToIgnore.includes(i)) {
|
|
encodedBuf.unshift(iconTitleText[i]);
|
|
} else {
|
|
// Encode all non ascii characters plus "'&<> (XML named entities)
|
|
/** @type {number} */
|
|
// @ts-ignore
|
|
const charDecimalCode = iconTitleText.codePointAt(i);
|
|
|
|
if (charDecimalCode > 127) {
|
|
encodedBuf.unshift(`&#${charDecimalCode};`);
|
|
} else if (xmlNamedEntitiesCodepoints.includes(charDecimalCode)) {
|
|
encodedBuf.unshift(
|
|
`&${
|
|
xmlNamedEntities[
|
|
xmlNamedEntitiesCodepoints.indexOf(charDecimalCode)
|
|
]
|
|
};`,
|
|
);
|
|
} else {
|
|
encodedBuf.unshift(iconTitleText[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
const encodedIconTitleText = encodedBuf.join('');
|
|
if (encodedIconTitleText !== iconTitleText) {
|
|
_validCodepointsRepr = false;
|
|
|
|
reporter.error(
|
|
`Unencoded unicode characters found in title "${iconTitleText}":` +
|
|
` rewrite it as "${encodedIconTitleText}".`,
|
|
);
|
|
}
|
|
|
|
// Check if there are some other encoded characters in decimal notation
|
|
// which shouldn't be encoded
|
|
const numberMatches = encodingMatches.filter(
|
|
(m) => m[2] !== undefined && isNumber(m[2]),
|
|
);
|
|
for (const match of numberMatches) {
|
|
const decimalNumber = Number.parseInt(match[2], 10);
|
|
if (decimalNumber > 127) {
|
|
continue;
|
|
}
|
|
|
|
_validCodepointsRepr = false;
|
|
|
|
const decimalCodepointCharIndex =
|
|
getTitleTextIndex(ast.source) + match.index + 1;
|
|
let replacement;
|
|
if (xmlNamedEntitiesCodepoints.includes(decimalNumber)) {
|
|
replacement = `"&${
|
|
xmlNamedEntities[
|
|
xmlNamedEntitiesCodepoints.indexOf(decimalNumber)
|
|
]
|
|
};"`;
|
|
} else {
|
|
replacement = String.fromCodePoint(decimalNumber);
|
|
replacement = replacement === '"' ? `'"'` : `"${replacement}"`;
|
|
}
|
|
|
|
reporter.error(
|
|
`Unnecessary encoded character "${match[0]}" found` +
|
|
` at index ${decimalCodepointCharIndex}:` +
|
|
` replace it with ${replacement}.`,
|
|
);
|
|
}
|
|
|
|
if (_validCodepointsRepr) {
|
|
const iconName = htmlFriendlyToTitle(iconTitleText);
|
|
const iconExists = icons.some((icon) => icon.title === iconName);
|
|
if (!iconExists) {
|
|
reporter.error(
|
|
`No icon with title "${iconName}" found in simple-icons.json`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
(reporter, $) => {
|
|
reporter.name = 'icon-size';
|
|
|
|
const iconPath = getIconPath($);
|
|
if (!updateIgnoreFile && isIgnored(reporter.name, iconPath)) {
|
|
return;
|
|
}
|
|
|
|
const [minX, minY, maxX, maxY] = getIconPathBbox(iconPath);
|
|
const width = Number((maxX - minX).toFixed(iconFloatPrecision));
|
|
const height = Number((maxY - minY).toFixed(iconFloatPrecision));
|
|
|
|
if (width === 0 && height === 0) {
|
|
reporter.error(
|
|
'Path bounds were reported as 0 x 0; check if the path is valid',
|
|
);
|
|
if (updateIgnoreFile) {
|
|
ignoreIcon(reporter.name, iconPath, $);
|
|
}
|
|
} else if (width !== iconSize && height !== iconSize) {
|
|
reporter.error(
|
|
`Size of <path> must be exactly ${iconSize} in one dimension;` +
|
|
` the size is currently ${width} x ${height}`,
|
|
);
|
|
if (updateIgnoreFile) {
|
|
ignoreIcon(reporter.name, iconPath, $);
|
|
}
|
|
}
|
|
},
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'icon-precision';
|
|
|
|
const iconPath = getIconPath($);
|
|
const segments = getIconPathSegments(iconPath);
|
|
|
|
for (const segment of segments) {
|
|
/** @type {number[]} */
|
|
// @ts-ignore
|
|
const numberParameters = segment.params.slice(1);
|
|
const precisionMax = Math.max(
|
|
// eslint-disable-next-line unicorn/no-array-callback-reference
|
|
...numberParameters.map(countDecimals),
|
|
);
|
|
if (precisionMax > iconMaxFloatPrecision) {
|
|
let errorMessage =
|
|
`found ${precisionMax} decimals in segment` +
|
|
` "${iconPath.slice(segment.start, segment.end)}"`;
|
|
if (segment.chain !== undefined) {
|
|
const readableChain = maybeShortenedWithEllipsis(
|
|
iconPath.slice(segment.chain.start, segment.chain.end),
|
|
);
|
|
errorMessage += ` of chain "${readableChain}"`;
|
|
}
|
|
|
|
errorMessage += ` at index ${
|
|
segment.start + getPathDIndex(ast.source)
|
|
}`;
|
|
reporter.error(
|
|
'Maximum precision should not be greater than' +
|
|
` ${iconMaxFloatPrecision}; ${errorMessage}`,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'ineffective-segments';
|
|
|
|
const iconPath = getIconPath($);
|
|
const segments = getIconPathSegments(iconPath);
|
|
|
|
/** @type {import('svg-path-segments').Segment[]} */
|
|
// TODO: svgpath does not includes the `segments` property on the interface,
|
|
// see https://github.com/fontello/svgpath/pull/67/files
|
|
//
|
|
/** @typedef {[string, ...number[]]} Segment */
|
|
/** @type {Segment[]} */
|
|
const absSegments =
|
|
// @ts-ignore
|
|
svgpath(iconPath).abs().unshort().segments;
|
|
|
|
const lowerMovementCommands = ['m', 'l'];
|
|
const lowerDirectionCommands = ['h', 'v'];
|
|
const lowerCurveCommand = 'c';
|
|
const lowerShorthandCurveCommand = 's';
|
|
const lowerCurveCommands = [
|
|
lowerCurveCommand,
|
|
lowerShorthandCurveCommand,
|
|
];
|
|
const upperMovementCommands = ['M', 'L'];
|
|
const upperHorDirectionCommand = 'H';
|
|
const upperVersionDirectionCommand = 'V';
|
|
const upperDirectionCommands = [
|
|
upperHorDirectionCommand,
|
|
upperVersionDirectionCommand,
|
|
];
|
|
const upperCurveCommand = 'C';
|
|
const upperShorthandCurveCommand = 'S';
|
|
const upperCurveCommands = [
|
|
upperCurveCommand,
|
|
upperShorthandCurveCommand,
|
|
];
|
|
const curveCommands = [...lowerCurveCommands, ...upperCurveCommands];
|
|
const commands = new Set([
|
|
...lowerMovementCommands,
|
|
...lowerDirectionCommands,
|
|
...upperMovementCommands,
|
|
...upperDirectionCommands,
|
|
...curveCommands,
|
|
]);
|
|
|
|
/**
|
|
* Check if a segment is ineffective.
|
|
* @param {import('svg-path-segments').Segment} segment The segment to check.
|
|
* @param {number} index The index of the segment in the path.
|
|
* @param {boolean} previousSegmentIsZ Whether the previous segment is a Z command.
|
|
* @returns {boolean} Whether the segment is ineffective.
|
|
*/
|
|
// eslint-disable-next-line complexity
|
|
const isInvalidSegment = (segment, index, previousSegmentIsZ) => {
|
|
const [command, x1Coord, y1Coord, ...rest] = segment.params;
|
|
if (commands.has(command)) {
|
|
// Relative directions (h or v) having a length of 0
|
|
if (lowerDirectionCommands.includes(command) && x1Coord === 0) {
|
|
return true;
|
|
}
|
|
|
|
// Relative movement (m or l) having a distance of 0
|
|
if (
|
|
index > 0 &&
|
|
lowerMovementCommands.includes(command) &&
|
|
x1Coord === 0 &&
|
|
y1Coord === 0
|
|
) {
|
|
// When the path is closed (z), the new segment can start with
|
|
// a relative placement (m) as if it were absolute (M)
|
|
return command.toLowerCase() === 'm' ? !previousSegmentIsZ : true;
|
|
}
|
|
|
|
if (
|
|
lowerCurveCommands.includes(command) &&
|
|
x1Coord === 0 &&
|
|
y1Coord === 0
|
|
) {
|
|
const [x2Coord, y2Coord] = rest;
|
|
if (
|
|
// Relative shorthand curve (s) having a control point of 0
|
|
command === lowerShorthandCurveCommand ||
|
|
// Relative bézier curve (c) having control points of 0
|
|
(command === lowerCurveCommand &&
|
|
x2Coord === 0 &&
|
|
y2Coord === 0)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (index > 0) {
|
|
let [yPreviousCoord, xPreviousCoord] = [
|
|
...absSegments[index - 1],
|
|
].reverse();
|
|
// If the previous command was a direction one,
|
|
// we need to iterate back until we find the missing coordinates
|
|
// @ts-ignore
|
|
if (upperDirectionCommands.includes(xPreviousCoord)) {
|
|
// @ts-ignore
|
|
xPreviousCoord = undefined;
|
|
// @ts-ignore
|
|
yPreviousCoord = undefined;
|
|
let index_ = index;
|
|
while (
|
|
--index_ > 0 &&
|
|
(xPreviousCoord === undefined || yPreviousCoord === undefined)
|
|
) {
|
|
let [yPreviousCoordDeep, xPreviousCoordDeep] = [
|
|
...absSegments[index_],
|
|
].reverse();
|
|
|
|
// If the previous command was a horizontal movement,
|
|
// we need to consider the single coordinate as x
|
|
if (upperHorDirectionCommand === xPreviousCoordDeep) {
|
|
xPreviousCoordDeep = yPreviousCoordDeep;
|
|
// @ts-ignore
|
|
yPreviousCoordDeep = undefined;
|
|
}
|
|
|
|
// If the previous command was a vertical movement,
|
|
// we need to consider the single coordinate as y
|
|
if (upperVersionDirectionCommand === xPreviousCoordDeep) {
|
|
// @ts-ignore
|
|
xPreviousCoordDeep = undefined;
|
|
}
|
|
|
|
if (
|
|
xPreviousCoord === undefined &&
|
|
xPreviousCoordDeep !== undefined
|
|
) {
|
|
xPreviousCoord = xPreviousCoordDeep;
|
|
}
|
|
|
|
if (
|
|
yPreviousCoord === undefined &&
|
|
yPreviousCoordDeep !== undefined
|
|
) {
|
|
yPreviousCoord = yPreviousCoordDeep;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (upperCurveCommands.includes(command)) {
|
|
const [x2Coord, y2Coord, xCoord, yCoord] = rest;
|
|
// Absolute shorthand curve (S) having
|
|
// the same coordinate as the previous segment
|
|
// and a control point equal to the ending point
|
|
if (
|
|
upperShorthandCurveCommand === command &&
|
|
x1Coord === xPreviousCoord &&
|
|
y1Coord === yPreviousCoord &&
|
|
x1Coord === x2Coord &&
|
|
y1Coord === y2Coord
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Absolute bézier curve (C) having
|
|
// the same coordinate as the previous segment
|
|
// and last control point equal to the ending point
|
|
if (
|
|
upperCurveCommand === command &&
|
|
x1Coord === xPreviousCoord &&
|
|
y1Coord === yPreviousCoord &&
|
|
x2Coord === xCoord &&
|
|
y2Coord === yCoord
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return (
|
|
// Absolute horizontal direction (H) having
|
|
// the same x coordinate as the previous segment
|
|
(upperHorDirectionCommand === command &&
|
|
x1Coord === xPreviousCoord) ||
|
|
// Absolute vertical direction (V) having
|
|
// the same y coordinate as the previous segment
|
|
(upperVersionDirectionCommand === command &&
|
|
x1Coord === yPreviousCoord) ||
|
|
// Absolute movement (M or L) having the same
|
|
// coordinate as the previous segment
|
|
(upperMovementCommands.includes(command) &&
|
|
x1Coord === xPreviousCoord &&
|
|
y1Coord === yPreviousCoord)
|
|
);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
for (let index = 0; index < segments.length; index++) {
|
|
const segment = segments[index];
|
|
const previousSegmentIsZ =
|
|
index > 0 && segments[index - 1].params[0].toLowerCase() === 'z';
|
|
|
|
if (isInvalidSegment(segment, index, previousSegmentIsZ)) {
|
|
const [command, _x1, _y1, ...rest] = segment.params;
|
|
|
|
let errorMessage = `Ineffective segment "${iconPath.slice(
|
|
segment.start,
|
|
segment.end,
|
|
)}" found`;
|
|
let resolutionTip = 'should be removed';
|
|
|
|
if (curveCommands.includes(command)) {
|
|
const [x2, y2, x, y] = rest;
|
|
|
|
if (
|
|
command === lowerShorthandCurveCommand &&
|
|
(x2 !== 0 || y2 !== 0)
|
|
) {
|
|
resolutionTip = `should be "l${removeLeadingZeros(
|
|
x2,
|
|
)} ${removeLeadingZeros(y2)}" or removed`;
|
|
}
|
|
|
|
if (command === upperShorthandCurveCommand) {
|
|
resolutionTip = `should be "L${removeLeadingZeros(
|
|
x2,
|
|
)} ${removeLeadingZeros(y2)}" or removed`;
|
|
}
|
|
|
|
if (command === lowerCurveCommand && (x !== 0 || y !== 0)) {
|
|
resolutionTip = `should be "l${removeLeadingZeros(
|
|
x,
|
|
)} ${removeLeadingZeros(y)}" or removed`;
|
|
}
|
|
|
|
if (command === upperCurveCommand) {
|
|
resolutionTip = `should be "L${removeLeadingZeros(
|
|
x,
|
|
)} ${removeLeadingZeros(y)}" or removed`;
|
|
}
|
|
}
|
|
|
|
if (segment.chain !== undefined) {
|
|
const readableChain = maybeShortenedWithEllipsis(
|
|
iconPath.slice(segment.chain.start, segment.chain.end),
|
|
);
|
|
errorMessage += ` in chain "${readableChain}"`;
|
|
}
|
|
|
|
errorMessage += ` at index ${
|
|
segment.start + getPathDIndex(ast.source)
|
|
}`;
|
|
|
|
reporter.error(`${errorMessage} (${resolutionTip})`);
|
|
}
|
|
}
|
|
},
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'collinear-segments';
|
|
/**
|
|
* Extracts collinear coordinates from SVG path straight lines
|
|
* (does not extracts collinear coordinates from curves).
|
|
* @param {string} iconPath The SVG path of the icon.
|
|
* @returns {import('svg-path-segments').Segment[]} The collinear segments.
|
|
*/
|
|
// eslint-disable-next-line complexity
|
|
const getCollinearSegments = (iconPath) => {
|
|
const segments = getIconPathSegments(iconPath);
|
|
const collinearSegments = [];
|
|
const straightLineCommands = 'HhVvLlMm';
|
|
|
|
let currentLine = [];
|
|
let currentAbsCoord = [undefined, undefined];
|
|
let startPoint;
|
|
let _inStraightLine = false;
|
|
let _nextInStraightLine = false;
|
|
let _resetStartPoint = false;
|
|
|
|
for (let s = 0; s < segments.length; s++) {
|
|
const seg = segments[s];
|
|
const parms = seg.params;
|
|
const cmd = parms[0];
|
|
const nextCmd =
|
|
s + 1 < segments.length ? segments[s + 1].params[0] : null;
|
|
|
|
switch (cmd) {
|
|
// Next switch cases have been ordered by frequency
|
|
// of occurrence in the SVG paths of the icons
|
|
case 'M': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = parms[1];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = parms[2];
|
|
// SVG 1.1:
|
|
// If a moveto is followed by multiple pairs of coordinates,
|
|
// the subsequent pairs are treated as implicit lineto commands.
|
|
if (seg.chain === undefined || seg.chain.start === seg.start) {
|
|
startPoint = undefined;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case 'm': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2];
|
|
if (seg.chain === undefined || seg.chain.start === seg.start) {
|
|
startPoint = undefined;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case 'H': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = parms[1];
|
|
break;
|
|
}
|
|
|
|
case 'h': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
|
|
break;
|
|
}
|
|
|
|
case 'V': {
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = parms[1];
|
|
break;
|
|
}
|
|
|
|
case 'v': {
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[1];
|
|
break;
|
|
}
|
|
|
|
case 'L': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = parms[1];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = parms[2];
|
|
break;
|
|
}
|
|
|
|
case 'l': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2];
|
|
break;
|
|
}
|
|
|
|
case 'Z':
|
|
case 'z': {
|
|
// TODO: Overlapping in Z should be handled in another rule
|
|
// @ts-ignore
|
|
currentAbsCoord = [startPoint[0], startPoint[1]];
|
|
_resetStartPoint = true;
|
|
break;
|
|
}
|
|
|
|
case 'C': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = parms[5];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = parms[6];
|
|
break;
|
|
}
|
|
|
|
case 'c': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[5];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[6];
|
|
break;
|
|
}
|
|
|
|
case 'A': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = parms[6];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = parms[7];
|
|
break;
|
|
}
|
|
|
|
case 'a': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[6];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[7];
|
|
break;
|
|
}
|
|
|
|
case 's': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2];
|
|
break;
|
|
}
|
|
|
|
case 'S': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = parms[1];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = parms[2];
|
|
break;
|
|
}
|
|
|
|
case 't': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2];
|
|
break;
|
|
}
|
|
|
|
case 'T': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = parms[1];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = parms[2];
|
|
break;
|
|
}
|
|
|
|
case 'Q': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = parms[3];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = parms[4];
|
|
break;
|
|
}
|
|
|
|
case 'q': {
|
|
// @ts-ignore
|
|
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[3];
|
|
// @ts-ignore
|
|
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[4];
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
throw new Error(`"${cmd}" command not handled`);
|
|
}
|
|
}
|
|
|
|
if (startPoint === undefined) {
|
|
startPoint = [currentAbsCoord[0], currentAbsCoord[1]];
|
|
} else if (_resetStartPoint) {
|
|
startPoint = undefined;
|
|
_resetStartPoint = false;
|
|
}
|
|
|
|
// @ts-ignore
|
|
_nextInStraightLine = straightLineCommands.includes(nextCmd);
|
|
const _exitingStraightLine =
|
|
_inStraightLine && !_nextInStraightLine;
|
|
_inStraightLine = straightLineCommands.includes(cmd);
|
|
|
|
if (_inStraightLine) {
|
|
currentLine.push([currentAbsCoord[0], currentAbsCoord[1]]);
|
|
} else {
|
|
if (_exitingStraightLine) {
|
|
if (straightLineCommands.includes(cmd)) {
|
|
currentLine.push([currentAbsCoord[0], currentAbsCoord[1]]);
|
|
}
|
|
|
|
// Get collinear coordinates
|
|
for (let p = 1; p < currentLine.length - 1; p++) {
|
|
const _collinearCoord = collinear(
|
|
// @ts-ignore
|
|
currentLine[p - 1][0],
|
|
currentLine[p - 1][1],
|
|
currentLine[p][0],
|
|
currentLine[p][1],
|
|
currentLine[p + 1][0],
|
|
currentLine[p + 1][1],
|
|
);
|
|
if (_collinearCoord) {
|
|
collinearSegments.push(
|
|
segments[s - currentLine.length + p + 1],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
currentLine = [];
|
|
}
|
|
}
|
|
|
|
return collinearSegments;
|
|
};
|
|
|
|
const iconPath = getIconPath($);
|
|
const collinearSegments = getCollinearSegments(iconPath);
|
|
if (collinearSegments.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const pathDIndex = getPathDIndex(ast.source);
|
|
for (const segment of collinearSegments) {
|
|
let errorMessage = `Collinear segment "${iconPath.slice(
|
|
segment.start,
|
|
segment.end,
|
|
)}" found`;
|
|
if (segment.chain !== undefined) {
|
|
const readableChain = maybeShortenedWithEllipsis(
|
|
iconPath.slice(segment.chain.start, segment.chain.end),
|
|
);
|
|
errorMessage += ` in chain "${readableChain}"`;
|
|
}
|
|
|
|
errorMessage += ` at index ${
|
|
segment.start + pathDIndex
|
|
} (should be removed)`;
|
|
reporter.error(errorMessage);
|
|
}
|
|
},
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'extraneous';
|
|
|
|
if (!svgRegexp.test(ast.source)) {
|
|
if (ast.source.includes('\n') || ast.source.includes('\r')) {
|
|
reporter.error(
|
|
'Unexpected newline character(s) detected in SVG markup',
|
|
);
|
|
} else {
|
|
reporter.error(
|
|
'Unexpected character(s), most likely extraneous' +
|
|
' whitespace, detected in SVG markup',
|
|
);
|
|
}
|
|
}
|
|
},
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'negative-zeros';
|
|
|
|
const iconPath = getIconPath($);
|
|
|
|
// Find negative zeros inside path
|
|
const negativeZeroMatches = [...iconPath.matchAll(negativeZerosRegexp)];
|
|
if (negativeZeroMatches.length > 0) {
|
|
// Calculate the index for each match in the file
|
|
const pathDIndex = getPathDIndex(ast.source);
|
|
|
|
for (const match of negativeZeroMatches) {
|
|
const negativeZeroFileIndex = match.index + pathDIndex;
|
|
const previousChar = ast.source[negativeZeroFileIndex - 1];
|
|
const replacement = '0123456789'.includes(previousChar)
|
|
? ' 0'
|
|
: '0';
|
|
reporter.error(
|
|
`Found "-0" at index ${negativeZeroFileIndex} (should` +
|
|
` be "${replacement}")`,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
(reporter, $) => {
|
|
reporter.name = 'icon-centered';
|
|
|
|
const iconPath = getIconPath($);
|
|
if (!updateIgnoreFile && isIgnored(reporter.name, iconPath)) {
|
|
return;
|
|
}
|
|
|
|
const [minX, minY, maxX, maxY] = getIconPathBbox(iconPath);
|
|
const centerX = Number(((minX + maxX) / 2).toFixed(iconFloatPrecision));
|
|
const devianceX = centerX - iconTargetCenter;
|
|
const centerY = Number(((minY + maxY) / 2).toFixed(iconFloatPrecision));
|
|
const devianceY = centerY - iconTargetCenter;
|
|
|
|
if (
|
|
Math.abs(devianceX) > iconTolerance ||
|
|
Math.abs(devianceY) > iconTolerance
|
|
) {
|
|
reporter.error(
|
|
`<path> must be centered at (${iconTargetCenter}, ${iconTargetCenter});` +
|
|
` the center is currently (${centerX}, ${centerY})`,
|
|
);
|
|
if (updateIgnoreFile) {
|
|
ignoreIcon(reporter.name, iconPath, $);
|
|
}
|
|
}
|
|
},
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'final-closepath';
|
|
|
|
const iconPath = getIconPath($);
|
|
const segments = getIconPathSegments(iconPath);
|
|
|
|
// Unnecessary characters after the final closepath
|
|
/** @type {import('svg-path-segments').Segment} */
|
|
// @ts-ignore
|
|
const lastSegment = segments.at(-1);
|
|
const endsWithZ = ['z', 'Z'].includes(lastSegment.params[0]);
|
|
if (endsWithZ && lastSegment.end - lastSegment.start > 1) {
|
|
const ending = iconPath.slice(lastSegment.start + 1);
|
|
const closepath = iconPath.at(lastSegment.start);
|
|
const pathDIndex = getPathDIndex(ast.source);
|
|
const index = pathDIndex + lastSegment.start + 2;
|
|
const errorMessage =
|
|
`Invalid character(s) "${ending}" after the final` +
|
|
` closepath command "${closepath}" at index ${index}` +
|
|
` (should be removed)`;
|
|
reporter.error(errorMessage);
|
|
}
|
|
},
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'path-format';
|
|
|
|
const iconPath = getIconPath($);
|
|
|
|
if (!SVG_PATH_REGEX.test(iconPath)) {
|
|
const errorMessage = 'Invalid path format';
|
|
let reason;
|
|
|
|
if (!iconPath.startsWith('M') && !iconPath.startsWith('m')) {
|
|
// Doesn't start with moveto
|
|
reason =
|
|
'should start with "moveto" command ("M" or "m"),' +
|
|
` but starts with "${iconPath[0]}"`;
|
|
reporter.error(`${errorMessage}: ${reason}`);
|
|
}
|
|
|
|
const validPathCharacters = SVG_PATH_REGEX.source.replaceAll(
|
|
/[[\]+^$]/g,
|
|
'',
|
|
);
|
|
const invalidCharactersMsgs = [];
|
|
const pathDIndex = getPathDIndex(ast.source);
|
|
|
|
for (const [i, char] of Object.entries(iconPath)) {
|
|
if (!validPathCharacters.includes(char)) {
|
|
invalidCharactersMsgs.push(
|
|
`"${char}" at index ${pathDIndex + Number.parseInt(i, 10)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Contains invalid characters
|
|
if (invalidCharactersMsgs.length > 0) {
|
|
reason = `unexpected character${
|
|
invalidCharactersMsgs.length > 1 ? 's' : ''
|
|
} found (${invalidCharactersMsgs.join(', ')})`;
|
|
reporter.error(`${errorMessage}: ${reason}`);
|
|
}
|
|
}
|
|
},
|
|
(reporter, $, ast) => {
|
|
reporter.name = 'svg-format';
|
|
|
|
// Don't allow explicit '</path>' closing tag
|
|
if (ast.source.includes('</path>')) {
|
|
const reason =
|
|
`found a closing "path" tag at index ${ast.source.indexOf(
|
|
'</path>',
|
|
)}. The path should be self-closing,` +
|
|
' use "/>" instead of "></path>".';
|
|
reporter.error(`Invalid SVG content format: ${reason}`);
|
|
}
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
export default config;
|