diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5de8ca4bd..e3bff1aa1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ contact_links: - name: Website about: Issues and improvements for the website - url: https://github.com/simple-icons/simple-icons-website/issues/new?assignees=&labels=&template=website.md + url: https://github.com/simple-icons/simple-icons-website/issues/new?template=website.md diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index 790d0ed49..b1b76d5c3 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -5,18 +5,17 @@ labels: [docs] body: - type: markdown attributes: - value: >- + value: | Before opening a new issue, make sure it isn't covered by an existing issue. Please search for [issues with the `docs` label][docs-issues] (including closed issues) before you continue. - - [docs-issues]: https://github.com/simple-icons/simple-icons/issues?q=is%3Aissue+label%3Adocs + [docs-issues]: https://github.com/simple-icons/simple-icons/labels/docs - type: dropdown attributes: label: Kind of Issue - description: >- + description: | If your issue type is not here, select "other" and explain in the "Description" field below. options: [Improvement, Mistake, Other] @@ -33,15 +32,3 @@ body: placeholder: "Example: The documentation doesn't cover my use case of the NPM package..." validations: required: true - - - type: dropdown - attributes: - label: Contributing - description: >- - This is an open source project and we welcome contributions. Do you want to - work on this issue? - options: - - 'Yes' - - 'No' - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/icon_removal.yml b/.github/ISSUE_TEMPLATE/icon_removal.yml index d798cb4ec..74b4c93f7 100644 --- a/.github/ISSUE_TEMPLATE/icon_removal.yml +++ b/.github/ISSUE_TEMPLATE/icon_removal.yml @@ -1,18 +1,18 @@ name: Icon removal description: Report an icon for removal -labels: [removal request] +labels: [breaking change] +title: 'Removal: ' body: - type: markdown attributes: - value: >- + value: | Before opening a new issue, make sure it isn't covered by an existing issue. - Please search for [issues with the `removal request` or `breaking change` label][removal-issues] + Please search for [issues with the `breaking change` label][breaking-issues] (including closed issues) before you continue. If you find one for the brand you're reporting then leave a comment on it or add a reaction. - - [removal-issues]: https://github.com/simple-icons/simple-icons/issues?q=is%3Aissue+label%3A%22removal+request%22%2C%22breaking+change%22 + [breaking-issues]: https://github.com/simple-icons/simple-icons/labels/breaking%20change - type: input attributes: diff --git a/.github/ISSUE_TEMPLATE/icon_request.yml b/.github/ISSUE_TEMPLATE/icon_request.yml index df29ded6e..f5252b8b2 100644 --- a/.github/ISSUE_TEMPLATE/icon_request.yml +++ b/.github/ISSUE_TEMPLATE/icon_request.yml @@ -1,21 +1,24 @@ name: Icon request description: Request a new icon for Simple Icons labels: [new icon] +title: 'Request: ' body: - type: markdown attributes: - value: >- + value: | We won't add non-brand icons or anything related to illegal services. If in doubt, open an issue and we'll have a look. For more details see the [Contributing Guidelines]. - Before opening a new issue, make sure it isn't covered by an existing issue. Please search for [issues with the `new icon` label][new-icon-issues] (including closed issues) before you continue. If you find one for the brand you're requesting then leave a comment on it or add a reaction. + > [!TIP] + > Similarweb is now trying to force users to log in in order to view statistics. You can bypass this by going directly to: + > `https://similarweb.com/website/google.com`, replacing `google.com` with the TLD you would like to get the stats on! [contributing guidelines]: https://github.com/simple-icons/simple-icons/blob/develop/CONTRIBUTING.md @@ -31,7 +34,7 @@ body: - type: input attributes: label: Website - description: >- + description: | For non-web brands you can add a relevant link. You can put "None" if you don't think there's a website. placeholder: 'Example: https://simpleicons.org' @@ -41,15 +44,13 @@ body: - type: textarea attributes: label: Popularity Metric - description: >- - Provide either a [Similarweb rank], which must range from 0-500,000 to qualify - or the number of GitHub stars, which must be above 5,000 to qualify. If - Similarweb does not have a rank for your brand or these numbers do not meet - our requirements, you can still open the issue. In this case, please provide - any information regarding the brand's popularity you think is relevant. - + description: | + Provide either a [Similarweb rank], which must be in the top 500,000 to qualify, + or failing that another metric from [our contributing guidelines] that we can + use to assess the popularity of the requested brand. [Similarweb rank]: https://www.similarweb.com + [our contributing guidelines]: https://github.com/simple-icons/simple-icons/blob/develop/CONTRIBUTING.md#assessing-popularity placeholder: 'Example: The Similarweb rank is 261,758. See https://www.similarweb.com/website/simpleicons.org' validations: required: true @@ -57,7 +58,7 @@ body: - type: textarea attributes: label: Official Resources for Icon and Color - description: >- + description: | Media kits, brand guidelines, SVG files, etc. You can set this to "None" if you are unable to find any resources. placeholder: | @@ -76,15 +77,3 @@ body: - Is the icon released under a license? - If you think the brand might not be accepted, why do you think it should be considered? placeholder: 'Example: There are two variants of this icon...' - - - type: dropdown - attributes: - label: Contributing - description: >- - This is an open source project and we welcome contributions. Do you want to - add this icon? - options: - - 'Yes' - - 'No' - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/icon_update.yml b/.github/ISSUE_TEMPLATE/icon_update.yml index f0a488559..f864075fa 100644 --- a/.github/ISSUE_TEMPLATE/icon_update.yml +++ b/.github/ISSUE_TEMPLATE/icon_update.yml @@ -1,18 +1,18 @@ name: Icon update description: Help us improve by reporting outdated icons -labels: [icon outdated] +labels: [update icon/data] +title: 'Update: ' body: - type: markdown attributes: - value: >- + value: | Before opening a new issue, make sure it isn't covered by an existing issue. - Please search for [issues with the `icon outdated` label][icon-outdated-issues] + Please search for [issues with the `update icon/data` label][update-icon-data-issues] (including closed issues) before you continue. If you find one for the brand you're reporting then leave a comment on it or add a reaction. - - [icon-outdated-issues]: https://github.com/simple-icons/simple-icons/issues?q=is%3Aissue+label%3A%22icon+outdated%22 + [update-icon-data-issues]: https://github.com/simple-icons/simple-icons/labels/update%20icon/data - type: input attributes: @@ -24,7 +24,7 @@ body: - type: textarea attributes: label: Official Resources for Icon and Color - description: >- + description: | Media kits, brand guidelines, SVG files, etc. You can set this to "None" if you are unable to find any resources. placeholder: | @@ -42,15 +42,3 @@ body: - Are there multiple options for the logo and/or color? - Is the icon released under a license? placeholder: 'Example: There are two variants of this icon...' - - - type: dropdown - attributes: - label: Contributing - description: >- - This is an open source project and we welcome contributions. Do you want to - update this icon? - options: - - 'Yes' - - 'No' - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/package.yml b/.github/ISSUE_TEMPLATE/package.yml index 1127ed314..ed213e88f 100644 --- a/.github/ISSUE_TEMPLATE/package.yml +++ b/.github/ISSUE_TEMPLATE/package.yml @@ -1,22 +1,21 @@ name: Packages -description: Report problems and suggest ideas for the packages +description: Report problems and suggest ideas for the NPM and Packagist packages labels: [package] body: - type: markdown attributes: - value: >- + value: | Before opening a new issue, make sure it isn't covered by an existing issue. Please search for [issues with the `package` label][package-issues] (including closed issues) before you continue. - - [package-issues]: https://github.com/simple-icons/simple-icons/issues?q=is%3Aissue+label%3Apackage + [package-issues]: https://github.com/simple-icons/simple-icons/labels/package - type: dropdown attributes: label: Kind of Issue - description: >- + description: | If your issue type is not here, select "other" and explain in the "Description" field below. options: [Bug, Feature, Performance, Other] @@ -41,7 +40,7 @@ body: - type: textarea attributes: label: Other Software - description: >- + description: | The software that you are using the package with (Node.js & NPM, PHP & Packagist, Browser(s), other) and their versions. You can put "None" if you are unsure. placeholder: | diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 10a31e306..f34a6f9e7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,22 +3,25 @@ Before opening your pull request, have a quick look at our contribution guidelin https://github.com/simple-icons/simple-icons/blob/develop/CONTRIBUTING.md Consider adding a preview image of your submission using: -https://petershaggynoble.github.io/SI-Sandbox/preview/ +https://wasm.simpleicons.org/preview --> **Issue:** closes # -**Similarweb rank:** - +**Popularity metric:** + + ### Checklist - - [ ] I updated the JSON data in `_data/simple-icons.json` - - [ ] I optimized the icon with SVGO or SVGOMG - - [ ] The SVG `viewbox` is `0 0 24 24` + +- [ ] I updated the JSON data in `_data/simple-icons.json` +- [ ] I optimized the icon with SVGO or SVGOMG +- [ ] The SVG `viewbox` is `0 0 24 24` ### Description + # Simple Icons slugs @@ -26,14 +26,11 @@ update the script at '${path.relative(rootDir, __filename)}'. | :--- | :--- | `; -(async () => { - const icons = await getIconsData(); +const icons = await getIconsData(); +for (const icon of icons) { + const brandName = icon.title; + const brandSlug = getIconSlug(icon); + content += `| \`${brandName}\` | \`${brandSlug}\` |\n`; +} - icons.forEach((icon) => { - const brandName = icon.title; - const brandSlug = getIconSlug(icon); - content += `| \`${brandName}\` | \`${brandSlug}\` |\n`; - }); - - await fs.writeFile(slugsFile, content); -})(); +await fs.writeFile(slugsFile, content); diff --git a/scripts/release/update-svgs-count.js b/scripts/release/update-svgs-count.js index 484c891e4..b90f7af1c 100644 --- a/scripts/release/update-svgs-count.js +++ b/scripts/release/update-svgs-count.js @@ -5,39 +5,37 @@ * at README every time the number of current icons is more than `updateRange` * more than the previous milestone. */ -import { promises as fs } from 'node:fs'; + +import fs from 'node:fs/promises'; import path from 'node:path'; -import { getDirnameFromImportMeta, getIconsData } from '../../sdk.mjs'; +import process from 'node:process'; +import {getDirnameFromImportMeta, getIconsData} from '../../sdk.mjs'; const regexMatcher = /Over\s(\d+)\s/; const updateRange = 100; const __dirname = getDirnameFromImportMeta(import.meta.url); +const rootDirectory = path.resolve(__dirname, '..', '..'); +const readmeFile = path.resolve(rootDirectory, 'README.md'); -const rootDir = path.resolve(__dirname, '..', '..'); -const readmeFile = path.resolve(rootDir, 'README.md'); +const readmeContent = await fs.readFile(readmeFile, 'utf8'); -(async () => { - const readmeContent = await fs.readFile(readmeFile, 'utf-8'); +let overNIconsInReadme; +try { + overNIconsInReadme = Number.parseInt(regexMatcher.exec(readmeContent)[1], 10); +} catch (error) { + console.error( + 'Failed to obtain number of SVG icons of current milestone in README:', + error, + ); + process.exit(1); +} - let overNIconsInReadme; - try { - overNIconsInReadme = parseInt(regexMatcher.exec(readmeContent)[1]); - } catch (err) { - console.error( - 'Failed to obtain number of SVG icons of current milestone in README:', - err, - ); - process.exit(1); - } - - const nIcons = (await getIconsData()).length; - const newNIcons = overNIconsInReadme + updateRange; - - if (nIcons <= newNIcons) { - process.exit(0); - } +const iconsData = await getIconsData(); +const nIcons = iconsData.length; +const newNIcons = overNIconsInReadme + updateRange; +if (nIcons > newNIcons) { const newContent = readmeContent.replace(regexMatcher, `Over ${newNIcons} `); await fs.writeFile(readmeFile, newContent); -})(); +} diff --git a/scripts/utils.js b/scripts/utils.js index c0b2c913f..474a4ba4a 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -1,17 +1,17 @@ -import path from 'node:path'; import fs from 'node:fs/promises'; -import { getDirnameFromImportMeta, getIconDataPath } from '../sdk.mjs'; +import path from 'node:path'; +import {getDirnameFromImportMeta, getIconDataPath} from '../sdk.mjs'; const __dirname = getDirnameFromImportMeta(import.meta.url); /** * Get JSON schema data. - * @param {String|undefined} rootDir Path to the root directory of the project. + * @param {String} rootDirectory Path to the root directory of the project. */ export const getJsonSchemaData = async ( - rootDir = path.resolve(__dirname, '..'), + rootDirectory = path.resolve(__dirname, '..'), ) => { - const jsonSchemaPath = path.resolve(rootDir, '.jsonschema.json'); + const jsonSchemaPath = path.resolve(rootDirectory, '.jsonschema.json'); const jsonSchemaString = await fs.readFile(jsonSchemaPath, 'utf8'); return JSON.parse(jsonSchemaString); }; @@ -19,14 +19,14 @@ export const getJsonSchemaData = async ( /** * Write icons data to _data/simple-icons.json. * @param {Object} iconsData Icons data object. - * @param {String|undefined} rootDir Path to the root directory of the project. + * @param {String} rootDirectory Path to the root directory of the project. */ export const writeIconsData = async ( iconsData, - rootDir = path.resolve(__dirname, '..'), + rootDirectory = path.resolve(__dirname, '..'), ) => { - return fs.writeFile( - getIconDataPath(rootDir), + await fs.writeFile( + getIconDataPath(rootDirectory), `${JSON.stringify(iconsData, null, 4)}\n`, 'utf8', ); diff --git a/sdk.d.ts b/sdk.d.ts index 6c2a24841..4d7c1fd21 100644 --- a/sdk.d.ts +++ b/sdk.d.ts @@ -3,7 +3,7 @@ * Types for Simple Icons SDK. */ -import type { License } from './types.d.ts'; +import type {CustomLicense, SPDXLicense} from './types'; /** * The data for a third-party extension. @@ -33,13 +33,14 @@ type ThirdPartyExtensionSubject = { export type Aliases = { aka?: string[]; dup?: DuplicateAlias[]; - loc?: { [key: string]: string }; + loc?: Record; }; type DuplicateAlias = { title: string; hex?: string; guidelines?: string; + loc?: Record; }; /** @@ -55,12 +56,15 @@ export type IconData = { source: string; slug?: string; guidelines?: string; - license?: License; + license?: Omit | CustomLicense; aliases?: Aliases; }; -export const URL_REGEX: RegExp; +/* The next code is autogenerated from sdk.mjs */ +/* eslint-disable */ +export const URL_REGEX: RegExp; +export const SVG_PATH_REGEX: RegExp; export function getDirnameFromImportMeta(importMetaUrl: string): string; export function getIconSlug(icon: IconData): string; export function svgToPath(svg: string): string; @@ -68,9 +72,9 @@ export function titleToSlug(title: string): string; export function slugToVariableName(slug: string): string; export function titleToHtmlFriendly(brandTitle: string): string; export function htmlFriendlyToTitle(htmlFriendlyTitle: string): string; -export function getIconDataPath(rootDir?: string): string; -export function getIconsDataString(rootDir?: string): string; -export function getIconsData(rootDir?: string): IconData[]; +export function getIconDataPath(rootDirectory?: string): string; +export function getIconsDataString(rootDirectory?: string): string; +export function getIconsData(rootDirectory?: string): IconData[]; export function normalizeNewlines(text: string): string; export function normalizeColor(text: string): string; export function getThirdPartyExtensions( diff --git a/sdk.mjs b/sdk.mjs index b3851fde1..a0cfe9cd9 100644 --- a/sdk.mjs +++ b/sdk.mjs @@ -3,9 +3,9 @@ * Simple Icons SDK. */ -import path from 'node:path'; import fs from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; /** * @typedef {import("./sdk").ThirdPartyExtension} ThirdPartyExtension @@ -26,17 +26,22 @@ const TITLE_TO_SLUG_REPLACEMENTS = { ŧ: 't', }; -const TITLE_TO_SLUG_CHARS_REGEX = RegExp( +const TITLE_TO_SLUG_CHARS_REGEX = new RegExp( `[${Object.keys(TITLE_TO_SLUG_REPLACEMENTS).join('')}]`, 'g', ); -const TITLE_TO_SLUG_RANGE_REGEX = /[^a-z0-9]/g; +const TITLE_TO_SLUG_RANGE_REGEX = /[^a-z\d]/g; /** * Regex to validate HTTPs URLs. */ -export const URL_REGEX = /^https:\/\/[^\s]+$/; +export const URL_REGEX = /^https:\/\/[^\s"']+$/; + +/** + * Regex to validate SVG paths. + */ +export const SVG_PATH_REGEX = /^m[-mzlhvcsqtae\d,. ]+$/i; /** * Get the directory name where this file is located from `import.meta.url`, @@ -59,7 +64,7 @@ export const getIconSlug = (icon) => icon.slug || titleToSlug(icon.title); * @param {String} svg The icon SVG content * @returns {String} The path from the icon SVG content **/ -export const svgToPath = (svg) => svg.match(/ svg.split('"', 8)[7]; /** * Converts a brand title into a slug/filename. @@ -69,12 +74,12 @@ export const svgToPath = (svg) => svg.match(/ title .toLowerCase() - .replace( + .replaceAll( TITLE_TO_SLUG_CHARS_REGEX, (char) => TITLE_TO_SLUG_REPLACEMENTS[char], ) .normalize('NFD') - .replace(TITLE_TO_SLUG_RANGE_REGEX, ''); + .replaceAll(TITLE_TO_SLUG_RANGE_REGEX, ''); /** * Converts a slug into a variable name that can be exported. @@ -83,8 +88,7 @@ export const titleToSlug = (title) => */ export const slugToVariableName = (slug) => { const slugFirstLetter = slug[0].toUpperCase(); - const slugRest = slug.slice(1); - return `si${slugFirstLetter}${slugRest}`; + return `si${slugFirstLetter}${slug.slice(1)}`; }; /** @@ -95,12 +99,12 @@ export const slugToVariableName = (slug) => { */ export const titleToHtmlFriendly = (brandTitle) => brandTitle - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>') - .replace(/./g, (char) => { - const charCode = char.charCodeAt(0); + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll(/./g, (char) => { + const charCode = char.codePointAt(0); return charCode > 127 ? `&#${charCode};` : char; }); @@ -112,43 +116,45 @@ export const titleToHtmlFriendly = (brandTitle) => */ export const htmlFriendlyToTitle = (htmlFriendlyTitle) => htmlFriendlyTitle - .replace(/&#([0-9]+);/g, (_, num) => String.fromCharCode(parseInt(num))) - .replace( + .replaceAll(/&#(\d+);/g, (_, number_) => + String.fromCodePoint(Number.parseInt(number_, 10)), + ) + .replaceAll( /&(quot|amp|lt|gt);/g, - (_, ref) => ({ quot: '"', amp: '&', lt: '<', gt: '>' }[ref]), + (_, reference) => ({quot: '"', amp: '&', lt: '<', gt: '>'})[reference], ); /** - * Get path of *_data/simpe-icons.json*. - * @param {String|undefined} rootDir Path to the root directory of the project + * Get path of *_data/simple-icons.json*. + * @param {String} rootDirectory Path to the root directory of the project * @returns {String} Path of *_data/simple-icons.json* */ export const getIconDataPath = ( - rootDir = getDirnameFromImportMeta(import.meta.url), + rootDirectory = getDirnameFromImportMeta(import.meta.url), ) => { - return path.resolve(rootDir, '_data', 'simple-icons.json'); + return path.resolve(rootDirectory, '_data', 'simple-icons.json'); }; /** * Get contents of *_data/simple-icons.json*. - * @param {String|undefined} rootDir Path to the root directory of the project + * @param {String} rootDirectory Path to the root directory of the project * @returns {String} Content of *_data/simple-icons.json* */ export const getIconsDataString = ( - rootDir = getDirnameFromImportMeta(import.meta.url), + rootDirectory = getDirnameFromImportMeta(import.meta.url), ) => { - return fs.readFile(getIconDataPath(rootDir), 'utf8'); + return fs.readFile(getIconDataPath(rootDirectory), 'utf8'); }; /** * Get icons data as object from *_data/simple-icons.json*. - * @param {String|undefined} rootDir Path to the root directory of the project + * @param {String} rootDirectory Path to the root directory of the project * @returns {IconData[]} Icons data as array from *_data/simple-icons.json* */ export const getIconsData = async ( - rootDir = getDirnameFromImportMeta(import.meta.url), + rootDirectory = getDirnameFromImportMeta(import.meta.url), ) => { - const fileContents = await getIconsDataString(rootDir); + const fileContents = await getIconsDataString(rootDirectory); return JSON.parse(fileContents).icons; }; @@ -158,7 +164,7 @@ export const getIconsData = async ( * @returns {String} The text with Windows newline characters replaced by Unix ones */ export const normalizeNewlines = (text) => { - return text.replace(/\r\n/g, '\n'); + return text.replaceAll('\r\n', '\n'); }; /** @@ -169,16 +175,18 @@ export const normalizeNewlines = (text) => { export const normalizeColor = (text) => { let color = text.replace('#', '').toUpperCase(); if (color.length < 6) { + // eslint-disable-next-line unicorn/no-useless-spread color = [...color.slice(0, 3)].map((x) => x.repeat(2)).join(''); } else if (color.length > 6) { color = color.slice(0, 6); } + return color; }; /** * Get information about third party extensions from the README table. - * @param {String|undefined} readmePath Path to the README file + * @param {String} readmePath Path to the README file * @returns {Promise} Information about third party extensions */ export const getThirdPartyExtensions = async ( @@ -189,23 +197,19 @@ export const getThirdPartyExtensions = async ( ) => normalizeNewlines(await fs.readFile(readmePath, 'utf8')) .split('## Third-Party Extensions\n\n')[1] - .split('\n\n')[0] + .split('\n\n', 1)[0] .split('\n') .slice(2) .map((line) => { let [module, author] = line.split(' | '); - - // README shipped with package has not Github theme image links - module = module.split( - module.includes('') ? '' : '.*<\/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; + +const sortObjectByKey = (object) => { + return Object.fromEntries( + Object.keys(object) + .sort() + .map((k) => [k, object[k]]), + ); +}; + +const sortObjectByValue = (object) => { + return Object.fromEntries( + Object.keys(object) + .sort((a, b) => collator.compare(object[a], object[b])) + .map((k) => [k, object[k]]), + ); +}; + +const removeLeadingZeros = (number) => { + // Convert 0.03 to '.03' + return number.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. + **/ +// 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 num The number of interest. + */ +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 svgFileContent The raw SVG as text. + */ +const getPathDIndex = (svgFileContent) => { + const pathDStart = '<path d="'; + return svgFileContent.indexOf(pathDStart) + pathDStart.length; +}; + +/** + * Get the index at which the text of the first `<title>` tag starts. + * @param svgFileContent The raw SVG as text. + **/ +const getTitleTextIndex = (svgFileContent) => { + const titleStart = ''; + return svgFileContent.indexOf(titleStart) + titleStart.length; +}; + +/** + * Convert a hexadecimal number passed as string to decimal number as integer. + * @param hex The hexadecimal number representation to convert. + **/ +const hexadecimalToDecimal = (hex) => { + let result = 0; + let digitValue; + for (const digit of hex.toLowerCase()) { + digitValue = '0123456789abcdefgh'.indexOf(digit); + result = result * 16 + digitValue; + } + + return result; +}; + +const maybeShortenedWithEllipsis = (string_) => { + return string_.length > 20 ? `${string_.slice(0, 20)}...` : string_; +}; + +/** + * Memoize a function which accepts a single argument. + * A second argument can be passed to be used as key. + */ +const memoize = (function_) => { + const results = {}; + + return (argument, defaultKey = null) => { + const key = defaultKey || argument; + + results[key] ||= function_(argument); + + return results[key]; + }; +}; + +const getIconPath = memoize(($icon, _filepath) => $icon.find('path').attr('d')); +const getIconPathSegments = memoize((iconPath) => parsePath(iconPath)); +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', + }); + }); +} + +const isIgnored = (linterName, path) => { + return ( + iconIgnored[linterName] && Object.hasOwn(iconIgnored[linterName], path) + ); +}; + +const ignoreIcon = (linterName, path, $) => { + iconIgnored[linterName] ||= {}; + + const title = $.find('title').text(); + const iconName = htmlFriendlyToTitle(title); + + iconIgnored[linterName][path] = iconName; +}; + +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: [ + (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 = hexadecimalToDecimal(match[1]); + + 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) + 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 + // eslint-disable-next-line unicorn/prefer-number-properties + for (const match of encodingMatches.filter((m) => !isNaN(m[2]))) { + 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 = data.icons.some( + (icon) => icon.title === iconName, + ); + if (!iconExists) { + reporter.error( + `No icon with title "${iconName}" found in simple-icons.json`, + ); + } + } + } + }, + (reporter, $, ast, {filepath}) => { + reporter.name = 'icon-size'; + + const iconPath = getIconPath($, filepath); + 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, {filepath}) => { + reporter.name = 'icon-precision'; + + const iconPath = getIconPath($, filepath); + const segments = getIconPathSegments(iconPath); + + for (const segment of segments) { + const precisionMax = Math.max( + // eslint-disable-next-line unicorn/no-array-callback-reference + ...segment.params.slice(1).map(countDecimals), + ); + if (precisionMax > iconMaxFloatPrecision) { + let errorMessage = + `found ${precisionMax} decimals in segment` + + ` "${iconPath.slice(segment.start, segment.end)}"`; + if (segment.chained) { + const readableChain = maybeShortenedWithEllipsis( + iconPath.slice(segment.chainStart, segment.chainEnd), + ); + 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, {filepath}) => { + reporter.name = 'ineffective-segments'; + + const iconPath = getIconPath($, filepath); + const segments = getIconPathSegments(iconPath); + const absSegments = 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, + ]); + + const isInvalidSegment = ( + [command, x1Coord, y1Coord, ...rest], + index, + previousSegmentIsZ, + ) => { + 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 + if (upperDirectionCommands.includes(xPreviousCoord)) { + xPreviousCoord = undefined; + 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; + yPreviousCoordDeep = undefined; + } + + // If the previous command was a vertical movement, + // we need to consider the single coordinate as y + if (upperVersionDirectionCommand === xPreviousCoordDeep) { + 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) + ); + } + } + }; + + 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.params, 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.chained) { + const readableChain = maybeShortenedWithEllipsis( + iconPath.slice(segment.chainStart, segment.chainEnd), + ); + errorMessage += ` in chain "${readableChain}"`; + } + + errorMessage += ` at index ${ + segment.start + getPathDIndex(ast.source) + }`; + + reporter.error(`${errorMessage} (${resolutionTip})`); + } + } + }, + (reporter, $, ast, {filepath}) => { + reporter.name = 'collinear-segments'; + + /** + * Extracts collinear coordinates from SVG path straight lines + * (does not extracts collinear coordinates from curves). + **/ + 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][0] : null; + + switch (cmd) { + // Next switch cases have been ordered by frequency + // of occurrence in the SVG paths of the icons + case 'M': { + currentAbsCoord[0] = parms[1]; + 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.chained || seg.chainStart === seg.start) { + startPoint = undefined; + } + + break; + } + + case 'm': { + currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1]; + currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2]; + if (!seg.chained || seg.chainStart === seg.start) { + startPoint = undefined; + } + + break; + } + + case 'H': { + currentAbsCoord[0] = parms[1]; + break; + } + + case 'h': { + currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1]; + break; + } + + case 'V': { + currentAbsCoord[1] = parms[1]; + break; + } + + case 'v': { + currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[1]; + break; + } + + case 'L': { + currentAbsCoord[0] = parms[1]; + currentAbsCoord[1] = parms[2]; + break; + } + + case 'l': { + currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1]; + currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2]; + break; + } + + case 'Z': + case 'z': { + // TODO: Overlapping in Z should be handled in another rule + currentAbsCoord = [startPoint[0], startPoint[1]]; + _resetStartPoint = true; + break; + } + + case 'C': { + currentAbsCoord[0] = parms[5]; + currentAbsCoord[1] = parms[6]; + break; + } + + case 'c': { + currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[5]; + currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[6]; + break; + } + + case 'A': { + currentAbsCoord[0] = parms[6]; + currentAbsCoord[1] = parms[7]; + break; + } + + case 'a': { + currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[6]; + currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[7]; + break; + } + + case 's': { + currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1]; + currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2]; + break; + } + + case 'S': { + currentAbsCoord[0] = parms[1]; + currentAbsCoord[1] = parms[2]; + break; + } + + case 't': { + currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1]; + currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2]; + break; + } + + case 'T': { + currentAbsCoord[0] = parms[1]; + currentAbsCoord[1] = parms[2]; + break; + } + + case 'Q': { + currentAbsCoord[0] = parms[3]; + currentAbsCoord[1] = parms[4]; + break; + } + + case 'q': { + currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[3]; + 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; + } + + _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( + 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($, filepath); + 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.chained) { + const readableChain = maybeShortenedWithEllipsis( + iconPath.slice(segment.chainStart, segment.chainEnd), + ); + 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, {filepath}) => { + reporter.name = 'negative-zeros'; + + const iconPath = getIconPath($, filepath); + + // 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, $, ast, {filepath}) => { + reporter.name = 'icon-centered'; + + const iconPath = getIconPath($, filepath); + 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, {filepath}) => { + reporter.name = 'path-format'; + + const iconPath = getIconPath($, filepath); + + 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; diff --git a/svgo.config.mjs b/svgo.config.mjs index 21f32595e..f3e99960e 100644 --- a/svgo.config.mjs +++ b/svgo.config.mjs @@ -1,4 +1,4 @@ -export default { +const config = { multipass: true, eol: 'lf', plugins: [ @@ -62,7 +62,7 @@ export default { // Convert basic shapes (such as <circle>) to <path> name: 'convertShapeToPath', params: { - // including <arc> + // Including <arc> convertArcs: true, }, }, @@ -71,7 +71,7 @@ export default { // Sort the attributes on the <svg> tag name: 'sortAttrs', params: { - order: ['role', 'viewBox'], + order: ['role', 'viewBox', 'xmlns'], xmlnsOrder: 'end', }, }, @@ -80,7 +80,11 @@ export default { { name: 'removeAttrs', params: { - attrs: ['svg:(?!(role|viewBox|xmlns))', 'path:(?!d)', 'title:*'], + attrs: [ + 'svg:.*(?<!((role)|(viewBox)|(xmlns)))', + 'path:(?!d)', + 'title:*', + ], }, }, 'removeElementsByAttr', @@ -89,7 +93,7 @@ export default { // to the <svg> tag if it's not there already name: 'addAttributesToSVGElement', params: { - attributes: [{ role: 'img', xmlns: 'http://www.w3.org/2000/svg' }], + attributes: [{role: 'img', xmlns: 'http://www.w3.org/2000/svg'}], }, }, 'removeOffCanvasPaths', @@ -98,3 +102,5 @@ export default { 'reusePaths', ], }; + +export default config; diff --git a/tests/docs.test.js b/tests/docs.test.js index 1c3fea604..9d79ed91f 100644 --- a/tests/docs.test.js +++ b/tests/docs.test.js @@ -1,85 +1,19 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { describe, test } from 'mocha'; -import { strict as assert } from 'node:assert'; -import { getThirdPartyExtensions, getDirnameFromImportMeta } from '../sdk.mjs'; - -const __dirname = getDirnameFromImportMeta(import.meta.url); -const root = path.dirname(__dirname); - -describe('README icons assets must be consistent with Github themes', () => { - const blackIconsPath = path.join(root, 'icons'); - const whiteIconsPath = path.join(root, 'assets', 'readme'); - const whiteIconsFileNames = fs.readdirSync(whiteIconsPath); - - for (let whiteIconFileName of whiteIconsFileNames) { - const whiteIconPath = path.join(whiteIconsPath, whiteIconFileName); - const blackIconPath = path.join( - blackIconsPath, - whiteIconFileName.replace(/-white\.svg$/, '.svg'), - ); - const whiteIconRelPath = path.relative(root, whiteIconPath); - const blackIconRelPath = path.relative(root, blackIconPath); - - test(`'${whiteIconRelPath}' content must be equivalent to '${blackIconRelPath}' content`, () => { - assert.ok( - whiteIconFileName.endsWith('-white.svg'), - `README icon assets file name '${whiteIconFileName}'` + - " must ends with '-white.svg'.", - ); - - assert.ok( - fs.existsSync(blackIconPath), - `Corresponding icon '${blackIconRelPath}' for README asset '${whiteIconRelPath}'` + - ` not found in '${path.dirname(blackIconRelPath)}' directory.`, - ); - - const whiteIconContent = fs.readFileSync(whiteIconPath, 'utf8'); - const blackIconContent = fs.readFileSync(blackIconPath, 'utf8'); - assert.equal( - whiteIconContent, - blackIconContent.replace('<svg', '<svg fill="white"'), - ); - }); - } -}); +import {strict as assert} from 'node:assert'; +import {test} from 'mocha'; +import {getThirdPartyExtensions} from '../sdk.mjs'; test('README third party extensions must be alphabetically sorted', async () => { - const readmePath = path.join(root, 'README.md'); - const thirdPartyExtensions = await getThirdPartyExtensions(readmePath); + const thirdPartyExtensions = await getThirdPartyExtensions(); assert.ok(thirdPartyExtensions.length > 0); const thirdPartyExtensionsNames = thirdPartyExtensions.map( - (ext) => ext.module.name, + (extension) => extension.module.name, ); - const expectedOrder = thirdPartyExtensionsNames.slice().sort(); + const expectedOrder = [...thirdPartyExtensionsNames].sort(); assert.deepEqual( thirdPartyExtensionsNames, expectedOrder, 'Wrong alphabetical order of third party extensions in README.', ); }); - -test('Only allow HTTPS links in documentation pages', async () => { - const ignoreHttpLinks = ['http://www.w3.org/2000/svg']; - - const docsFiles = fs - .readdirSync(root) - .filter((fname) => fname.endsWith('.md')); - - const linksGetter = new RegExp('http://[^\\s"\']+', 'g'); - for (let docsFile of docsFiles) { - const docsFilePath = path.join(root, docsFile); - const docsFileContent = fs.readFileSync(docsFilePath, 'utf8'); - - Array.from(docsFileContent.matchAll(linksGetter)).forEach((match) => { - const link = match[0]; - assert.ok( - ignoreHttpLinks.includes(link) || link.startsWith('https://'), - `Link '${link}' in '${docsFile}' (at index ${match.index})` + - ` must use the HTTPS protocol.`, - ); - }); - } -}); diff --git a/tests/index.test.js b/tests/index.test.js index a8f7c413d..2b10b96f8 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -1,15 +1,11 @@ -import { getIconsData, getIconSlug, slugToVariableName } from '../sdk.mjs'; import * as simpleIcons from '../index.mjs'; -import { testIcon } from './test-icon.js'; +import {getIconSlug, getIconsData, slugToVariableName} from '../sdk.mjs'; +import {testIcon} from './test-icon.js'; -(async () => { - const icons = await getIconsData(); +for (const icon of await getIconsData()) { + const slug = getIconSlug(icon); + const variableName = slugToVariableName(slug); + const subject = simpleIcons[variableName]; - icons.map((icon) => { - const slug = getIconSlug(icon); - const variableName = slugToVariableName(slug); - const subject = simpleIcons[variableName]; - - testIcon(icon, subject, slug); - }); -})(); + testIcon(icon, subject, slug); +} diff --git a/tests/min-reporter.cjs b/tests/min-reporter.cjs index 72b840f8c..11f841097 100644 --- a/tests/min-reporter.cjs +++ b/tests/min-reporter.cjs @@ -1,6 +1,6 @@ -const { reporters, Runner } = require('mocha'); +const {reporters, Runner} = require('mocha'); -const { EVENT_RUN_END } = Runner.constants; +const {EVENT_RUN_END} = Runner.constants; class EvenMoreMin extends reporters.Base { constructor(runner) { diff --git a/tests/test-icon.js b/tests/test-icon.js index 87861984f..1097e6b36 100644 --- a/tests/test-icon.js +++ b/tests/test-icon.js @@ -1,19 +1,32 @@ -import fs from 'node:fs'; +import {strict as assert} from 'node:assert'; +import fs from 'node:fs/promises'; import path from 'node:path'; -import { strict as assert } from 'node:assert'; -import { describe, it } from 'mocha'; -import { URL_REGEX, titleToSlug } from '../sdk.mjs'; +import {describe, it} from 'mocha'; +import { + SVG_PATH_REGEX, + URL_REGEX, + getDirnameFromImportMeta, + titleToSlug, +} from '../sdk.mjs'; -const iconsDir = path.resolve(process.cwd(), 'icons'); +const iconsDirectory = path.resolve( + getDirnameFromImportMeta(import.meta.url), + '..', + 'icons', +); + +/** + * @typedef {import('..').SimpleIcon} SimpleIcon + */ /** * Checks if icon data matches a subject icon. - * @param {import('..').SimpleIcon} icon Icon data - * @param {import('..').SimpleIcon} subject Icon to check against icon data + * @param {SimpleIcon} icon Icon data + * @param {SimpleIcon} subject Icon to check against icon data * @param {String} slug Icon data slug */ export const testIcon = (icon, subject, slug) => { - const svgPath = path.resolve(iconsDir, `${slug}.svg`); + const svgPath = path.resolve(iconsDirectory, `${slug}.svg`); describe(icon.title, () => { it('has the correct "title"', () => { @@ -38,7 +51,7 @@ export const testIcon = (icon, subject, slug) => { }); it('has a valid "path" value', () => { - assert.match(subject.path, /^[MmZzLlHhVvCcSsQqTtAaEe0-9-,.\s]+$/g); + assert.match(subject.path, SVG_PATH_REGEX); }); it(`has ${icon.guidelines ? 'the correct' : 'no'} "guidelines"`, () => { @@ -62,13 +75,13 @@ export const testIcon = (icon, subject, slug) => { } }); - it('has a valid svg value', () => { - const svgFileContents = fs.readFileSync(svgPath, 'utf8'); + it('has a valid svg value', async () => { + const svgFileContents = await fs.readFile(svgPath, 'utf8'); assert.equal(subject.svg, svgFileContents); }); if (icon.slug) { - // if an icon data has a slug, it must be different to the + // If an icon data has a slug, it must be different to the // slug inferred from the title, which prevents adding // unnecessary slugs to icons data it(`'${icon.title}' slug must be necessary`, () => { diff --git a/types.d.ts b/types.d.ts index 8ce48287b..ab2ea35ac 100644 --- a/types.d.ts +++ b/types.d.ts @@ -5,12 +5,13 @@ */ export type License = SPDXLicense | CustomLicense; -type SPDXLicense = { +// eslint-disable-next-line @typescript-eslint/naming-convention +export type SPDXLicense = { type: string; - url?: string; + url: string; }; -type CustomLicense = { +export type CustomLicense = { type: 'custom'; url: string; }; @@ -18,7 +19,7 @@ type CustomLicense = { /** * The data for a Simple Icon as is exported by the npm package. */ -export interface SimpleIcon { +export type SimpleIcon = { title: string; slug: string; svg: string; @@ -27,4 +28,4 @@ export interface SimpleIcon { hex: string; guidelines?: string; license?: License; -} +};