Add types to source code (#10637)

This commit is contained in:
Álvaro Mondéjar Rubio 2024-06-06 14:40:35 +02:00 committed by GitHub
parent 1224e341d7
commit 236f5fc715
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 619 additions and 205 deletions

View File

@ -16,3 +16,4 @@
!sdk.js
!sdk.d.ts
!.jsonschema.json
!jsconfig.json

View File

@ -2,6 +2,7 @@
"prettier": true,
"space": 2,
"plugins": ["import"],
"extends": ["plugin:jsdoc/recommended"],
"rules": {
"sort-imports": [
"error",
@ -27,7 +28,8 @@
"newlines-between": "never"
}
],
"no-console": ["error", {"allow": ["warn", "error"]}]
"no-console": ["error", {"allow": ["warn", "error"]}],
"jsdoc/require-file-overview": "error"
},
"overrides": [
{
@ -46,6 +48,12 @@
"svgo.config.mjs"
],
"nodeVersion": ">=18"
},
{
"files": ["svglint.config.mjs"],
"rules": {
"max-depth": "off"
}
}
]
}

13
jsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2022",
"module": "node16",
"moduleResolution": "node16",
"checkJs": true,
"skipLibCheck": false,
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"forceConsistentCasingInFileNames": true
}
}

View File

@ -86,10 +86,12 @@
"devDependencies": {
"@inquirer/core": "8.1.0",
"@inquirer/prompts": "5.0.2",
"@types/node": "20.14.2",
"chalk": "5.3.0",
"editorconfig-checker": "5.1.5",
"esbuild": "0.20.2",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsdoc": "48.2.8",
"fake-diff": "1.0.0",
"fast-fuzzy": "1.12.0",
"get-relative-luminance": "1.0.0",
@ -99,8 +101,8 @@
"markdown-link-check": "3.12.1",
"mocha": "10.4.0",
"named-html-entities-json": "1.0.0",
"svg-path-bbox": "1.2.6",
"svg-path-segments": "2.0.0",
"svg-path-bbox": "2.0.0",
"svg-path-segments": "2.0.1",
"svglint": "2.7.1",
"svgo": "3.2.0",
"svgpath": "2.6.0",

View File

@ -1,4 +1,12 @@
#!/usr/bin/env node
/**
* @file
* Script to add data for a new icon to the simple-icons dataset.
*/
/**
* @typedef {import("../sdk.js").IconData} IconData
*/
import process from 'node:process';
import {ExitPromptError} from '@inquirer/core';
import {checkbox, confirm, input} from '@inquirer/prompts';
@ -15,6 +23,7 @@ import {
} from '../sdk.mjs';
import {getJsonSchemaData, writeIconsData} from './utils.js';
/** @type {{icons: import('../sdk.js').IconData[]}} */
const iconsData = JSON.parse(await getIconsDataString());
const jsonSchema = await getJsonSchemaData();
@ -25,25 +34,42 @@ const aliasTypes = ['aka', 'old'].map((key) => ({
value: key,
}));
/** @type {{name: string, value: string}[]} */
const licenseTypes =
jsonSchema.definitions.brand.properties.license.oneOf[0].properties.type.enum.map(
(license) => ({name: license, value: license}),
(/** @type {string} */ license) => ({name: license, value: license}),
);
/**
* @param {string} input URL input
* @returns {Promise<boolean|string>} Whether the input is a valid URL
*/
const isValidURL = async (input) => {
const regex = await urlRegex();
return regex.test(input) || 'Must be a valid and secure (https://) URL.';
};
/**
* @param {string} input Hex color
* @returns {boolean|string} Whether the input is a valid hex color
*/
const isValidHexColor = (input) =>
HEX_REGEX.test(input) || 'Must be a valid hex code.';
/**
* @param {string} input New icon input
* @returns {boolean} Whether the icon is new
*/
const isNewIcon = (input) =>
!iconsData.icons.some(
(icon) =>
icon.title === input || titleToSlug(icon.title) === titleToSlug(input),
) || 'This icon title or slug already exists.';
);
/**
* @param {string} input Color input
* @returns {string} Preview of the color
*/
const previewHexColor = (input) => {
const color = normalizeColor(input);
const luminance = HEX_REGEX.test(input)
@ -60,7 +86,9 @@ try {
title: await input({
message: 'What is the title of this icon?',
validate: (input) =>
input.trim().length > 0 ? isNewIcon(input) : 'This field is required.',
input.trim().length > 0
? isNewIcon(input) || 'This icon title or slug already exists.'
: 'This field is required.',
}),
hex: normalizeColor(
await input({
@ -111,6 +139,7 @@ try {
}).then(async (aliases) => {
const result = {};
for (const alias of aliases) {
// @ts-ignore
// eslint-disable-next-line no-await-in-loop
result[alias] = await input({
message: `What ${alias} aliases would you like to add? (separate with commas)`,

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Clean files built by the build process.
*/

View File

@ -1,9 +1,14 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Simple Icons package build script.
*/
/**
* @typedef {import('../../types.js').License} License
* @typedef {import('esbuild').TransformOptions} EsBuildTransformOptions
*/
import {promises as fs} from 'node:fs';
import path from 'node:path';
import util from 'node:util';
@ -36,62 +41,78 @@ const iconObjectTemplateFile = path.resolve(
'icon-object.js.template',
);
const icons = await getIconsData();
const iconObjectTemplate = await fs.readFile(iconObjectTemplateFile, UTF8);
/**
* @param {string} value The value to escape
* @returns {string} The escaped value
*/
const escape = (value) => {
return value.replaceAll(/(?<!\\)'/g, "\\'");
};
/**
* @param {License} license The license object or URL
* @returns {License} The license object with a URL
*/
const licenseToObject = (license) => {
if (license.url === undefined) {
license.url = `https://spdx.org/licenses/${license.type}`;
}
return license;
};
// TODO: Find a way to type this object without decreasing performance
// @ts-ignore
const iconToJsObject = (icon) => {
return util.format(
iconObjectTemplate,
escape(icon.title),
escape(icon.slug),
escape(titleToHtmlFriendly(icon.title)),
escape(icon.path),
escape(icon.source),
escape(icon.hex),
icon.guidelines ? `\n guidelines: '${escape(icon.guidelines)}',` : '',
icon.license === undefined
? ''
: `\n license: ${JSON.stringify(licenseToObject(icon.license))},`,
);
};
/**
* @param {string} filepath The path to the file to write
* @param {string} rawJavaScript The raw JavaScript content to write to the file
* @param {EsBuildTransformOptions | null} options The options to pass to esbuild
*/
const writeJs = async (filepath, rawJavaScript, options = null) => {
options = options === null ? {minify: true} : options;
const {code} = await esbuildTransform(rawJavaScript, options);
await fs.writeFile(filepath, code);
};
/**
* @param {string} filepath The path to the file to write
* @param {string} rawTypeScript The raw TypeScript content to write to the file
*/
const writeTs = async (filepath, rawTypeScript) => {
await fs.writeFile(filepath, rawTypeScript);
};
const build = async () => {
const icons = await getIconsData();
const iconObjectTemplate = await fs.readFile(iconObjectTemplateFile, UTF8);
// Local helper functions
const escape = (value) => {
return value.replaceAll(/(?<!\\)'/g, "\\'");
};
const licenseToObject = (license) => {
if (license === undefined) {
return;
}
if (license.url === undefined) {
license.url = `https://spdx.org/licenses/${license.type}`;
}
return license;
};
const iconToObject = (icon) => {
return util.format(
iconObjectTemplate,
escape(icon.title),
escape(icon.slug),
escape(titleToHtmlFriendly(icon.title)),
escape(icon.path),
escape(icon.source),
escape(icon.hex),
icon.guidelines ? `\n guidelines: '${escape(icon.guidelines)}',` : '',
licenseToObject(icon.license)
? `\n license: ${JSON.stringify(licenseToObject(icon.license))},`
: '',
);
};
const writeJs = async (filepath, rawJavaScript, options = null) => {
options = options === null ? {minify: true} : options;
const {code} = await esbuildTransform(rawJavaScript, options);
await fs.writeFile(filepath, code);
};
const writeTs = async (filepath, rawTypeScript) => {
await fs.writeFile(filepath, rawTypeScript);
};
// 'main'
const buildIcons = await Promise.all(
icons.map(async (icon) => {
const filename = getIconSlug(icon);
const svgFilepath = path.resolve(iconsDirectory, `${filename}.svg`);
// TODO: Find a way to type these objects without decreasing performance
// @ts-ignore
icon.svg = await fs.readFile(svgFilepath, UTF8);
// @ts-ignore
icon.path = svgToPath(icon.svg);
icon.slug = filename;
const iconObject = iconToObject(icon);
const iconObject = iconToJsObject(icon);
const iconExportName = slugToVariableName(icon.slug);
return {icon, iconObject, iconExportName};
}),

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Script that takes a brand name as argument and outputs the corresponding
* icon SVG filename to standard output.
*/

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* CLI tool to run jsonschema on the simple-icons.json data file.
*/

View File

@ -1,21 +1,39 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Linters for the package that can't easily be implemented in the existing
* linters (e.g. jsonlint/svglint).
*/
/**
* @typedef {import("../../sdk.mjs").IconData} IconData
* @typedef {import("../../types.js").CustomLicense} CustomLicense
* @typedef {IconData[]} IconsData
*/
import process from 'node:process';
import fakeDiff from 'fake-diff';
import {collator, getIconsDataString, normalizeNewlines} from '../../sdk.mjs';
/**
* Contains our tests so they can be isolated from each other.
* @type {{[k:string]: () => (string|undefined)}}
* @type {{[k: string]: (arg0: {icons: IconsData}, arg1: string) => string | undefined}}
*/
const TESTS = {
/* Tests whether our icons are in alphabetical order */
/**
* Tests whether our icons are in alphabetical order
* @param {{icons: IconsData}} data Icons data
* @returns {string|undefined} Error message or undefined
*/
alphabetical(data) {
/**
* Collects invalid alphabet ordered icons
* @param {IconData[]} invalidEntries Invalid icons reference
* @param {IconData} icon Icon to check
* @param {number} index Index of the icon
* @param {IconData[]} array Array of icons
* @returns {IconData[]} Invalid icons
*/
const collector = (invalidEntries, icon, index, array) => {
if (index > 0) {
const previous = array[index - 1];
@ -34,6 +52,11 @@ const TESTS = {
return invalidEntries;
};
/**
* Format an icon for display in the error message
* @param {IconData} icon Icon to format
* @returns {string} Formatted icon
*/
const format = (icon) => {
if (icon.slug) {
return `${icon.title} (${icon.slug})`;
@ -63,6 +86,11 @@ const TESTS = {
/* Check redundant trailing slash in URL */
checkUrl(data) {
/**
* Check if an URL has a redundant trailing slash.
* @param {string} url URL to check
* @returns {boolean} Whether the URL has a redundant trailing slash
*/
const hasRedundantTrailingSlash = (url) => {
const {origin} = new global.URL(url);
return /^\/+$/.test(url.replace(origin, ''));
@ -71,7 +99,17 @@ const TESTS = {
const allUrlFields = [
...new Set(
data.icons
.flatMap((icon) => [icon.source, icon.guidelines, icon.license?.url])
.flatMap((icon) => {
// TODO: `Omit` is not working smoothly here
const license =
// @ts-ignore
icon.license && icon.license.url
? // @ts-ignore
[icon.license.url]
: [];
return [icon.source, icon.guidelines, ...license];
})
.filter(Boolean),
),
];
@ -88,11 +126,13 @@ const TESTS = {
},
};
const dataString = await getIconsDataString();
const data = JSON.parse(dataString);
const iconsDataString = await getIconsDataString();
const iconsData = JSON.parse(iconsDataString);
const errors = (
await Promise.all(Object.values(TESTS).map((test) => test(data, dataString)))
await Promise.all(
Object.values(TESTS).map((test) => test(iconsData, iconsDataString)),
)
)
// eslint-disable-next-line unicorn/no-await-expression-member
.filter(Boolean);

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Rewrite some Markdown files.
*/
@ -17,6 +17,10 @@ const rootDirectory = path.resolve(__dirname, '..', '..');
const readmeFile = path.resolve(rootDirectory, 'README.md');
const disclaimerFile = path.resolve(rootDirectory, 'DISCLAIMER.md');
/**
* Reformat a file.
* @param {string} filePath Path to the file
*/
const reformat = async (filePath) => {
const fileContent = await readFile(filePath, 'utf8');
await writeFile(

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Updates the CDN URLs in the README.md to match the major version in the
* NPM package manifest. Does nothing if the README.md is already up-to-date.
*/
@ -16,16 +16,27 @@ const rootDirectory = path.resolve(__dirname, '..', '..');
const packageJsonFile = path.resolve(rootDirectory, 'package.json');
const readmeFile = path.resolve(rootDirectory, 'README.md');
/**
* @param {string} semVersion A semantic version string.
* @returns {number} The major version number.
*/
const getMajorVersion = (semVersion) => {
const majorVersionAsString = semVersion.split('.')[0];
return Number.parseInt(majorVersionAsString, 10);
};
/**
* Get the package.json manifest.
* @returns {Promise<{version: string}>} The package.json manifest.
*/
const getManifest = async () => {
const manifestRaw = await fs.readFile(packageJsonFile, 'utf8');
return JSON.parse(manifestRaw);
};
/**
* @param {number} majorVersion The major version number.
*/
const updateVersionInReadmeIfNecessary = async (majorVersion) => {
let content = await fs.readFile(readmeFile, 'utf8');

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Updates the SDK Typescript definitions located in the file sdk.d.ts
* to match the current definitions of functions of sdk.mjs.
*/
@ -34,19 +34,53 @@ const generateSdkMts = async () => {
'npx tsc sdk.mjs' +
' --declaration --emitDeclarationOnly --allowJs --removeComments',
);
} catch (error) {
} catch (/** @type {unknown} */ error) {
await fs.writeFile(sdkMjs, originalSdkMjsContent);
let errorMessage = error;
if (error instanceof Error) {
// The `execSync` function throws a generic Node.js Error
errorMessage = error.message;
}
process.stdout.write(
`Error ${error.status} generating Typescript` +
` definitions: '${error.message}'` +
'\n',
`Error generating Typescript definitions: '${errorMessage}'\n`,
);
process.exit(1);
}
await fs.writeFile(sdkMjs, originalSdkMjsContent);
};
/**
* We must remove the duplicated export types that tsc generates from
* JSDoc `typedef` comments.
* See {@link https://github.com/microsoft/TypeScript/issues/46011}
* @param {string} content Content of the file
* @returns {string} The content without duplicated export types
*/
const removeDuplicatedExportTypes = (content) => {
const newContent = [];
const lines = content.split('\n');
/** @type {string[]} */
const exportTypesFound = [];
for (const line of lines) {
if (line.startsWith('export type ')) {
const type = line.split(' ')[2];
if (!exportTypesFound.includes(type)) {
newContent.push(line);
exportTypesFound.push(type);
}
} else {
newContent.push(line);
}
}
return newContent.join('\n');
};
const generateSdkTs = async () => {
const fileExists = await fs
.access(sdkMts)
@ -62,15 +96,21 @@ const generateSdkTs = async () => {
(await fs.readFile(sdkTs, 'utf8')).split(autogeneratedMessage)[0] +
`${autogeneratedMessage}\n\n${await fs.readFile(sdkMts, 'utf8')}`;
await fs.writeFile(sdkTs, newSdkTsContent);
await fs.writeFile(sdkTs, removeDuplicatedExportTypes(newSdkTsContent));
await fs.unlink(sdkMts);
try {
execSync('npx prettier -w sdk.d.ts');
} catch (error) {
let errorMessage = error;
if (error instanceof Error) {
// The `execSync` function throws a generic Node.js Error
errorMessage = error.message;
}
process.stdout.write(
`Error ${error.status} executing Prettier` +
` to prettify SDK TS definitions: '${error.message}'` +
'Error executing Prettier to prettify' +
` SDK TS definitions: '${errorMessage}'` +
'\n',
);
process.exit(1);

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Generates a MarkDown file that lists every brand name and their slug.
*/

View File

@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* @fileoverview
* @file
* Replaces the SVG count milestone "Over <NUMBER> Free SVG icons..." located
* at README every time the number of current icons is more than `updateRange`
* more than the previous milestone.
@ -20,22 +20,32 @@ const readmeFile = path.resolve(rootDirectory, 'README.md');
const readmeContent = await fs.readFile(readmeFile, 'utf8');
let overNIconsInReadme;
try {
overNIconsInReadme = Number.parseInt(regexMatcher.exec(readmeContent)[1], 10);
const match = regexMatcher.exec(readmeContent);
if (match === null) {
console.error(
'Failed to obtain number of SVG icons of current milestone in README:',
'No match found',
);
process.exit(1);
} else {
const overNIconsInReadme = Number.parseInt(match[1], 10);
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);
}
}
} catch (error) {
console.error(
'Failed to obtain number of SVG icons of current milestone in README:',
'Failed to update number of SVG icons of current milestone in README:',
error,
);
process.exit(1);
}
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);
}

View File

@ -1,12 +1,24 @@
/**
* @file Internal utilities.
*
* Here resides all the functionality that does not qualifies to reside
* in the SDK because is not publicly exposed.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import {getDirnameFromImportMeta, getIconDataPath} from '../sdk.mjs';
const __dirname = getDirnameFromImportMeta(import.meta.url);
/**
* @typedef {import("../sdk.js").IconData} IconData
*/
/**
* Get JSON schema data.
* @param {String} rootDirectory Path to the root directory of the project.
* @param {string} rootDirectory Path to the root directory of the project.
* @returns {Promise<any>} JSON schema data.
*/
export const getJsonSchemaData = async (
rootDirectory = path.resolve(__dirname, '..'),
@ -18,8 +30,8 @@ export const getJsonSchemaData = async (
/**
* Write icons data to _data/simple-icons.json.
* @param {Object} iconsData Icons data object.
* @param {String} rootDirectory Path to the root directory of the project.
* @param {{icons: IconData[]}} iconsData Icons data object.
* @param {string} rootDirectory Path to the root directory of the project.
*/
export const writeIconsData = async (
iconsData,

9
sdk.d.ts vendored
View File

@ -1,5 +1,5 @@
/**
* @fileoverview
* @file
* Types for Simple Icons SDK.
*/
@ -10,7 +10,6 @@ import type {CustomLicense, SPDXLicense} from './types';
*
* Includes the module and author of the extension,
* both including a name and URL.
*
* @see {@link https://github.com/simple-icons/simple-icons#third-party-extensions Third-Party Extensions}
*/
export type ThirdPartyExtension = {
@ -27,7 +26,6 @@ type ThirdPartyExtensionSubject = {
* The aliases for a Simple Icon.
*
* Corresponds to the `aliases` property in the *_data/simple-icons.json* file.
*
* @see {@link https://github.com/simple-icons/simple-icons/blob/develop/CONTRIBUTING.md#aliases Aliases}
*/
export type Aliases = {
@ -47,7 +45,6 @@ type DuplicateAlias = {
* The data for a Simple Icon.
*
* Corresponds to the data stored for each icon in the *_data/simple-icons.json* file.
*
* @see {@link https://github.com/mondeja/simple-icons/blob/utils-entrypoint/CONTRIBUTING.md#7-update-the-json-data-for-simpleiconsorg Update the JSON Data for SimpleIcons.org}
*/
export type IconData = {
@ -73,8 +70,8 @@ export function slugToVariableName(slug: string): string;
export function titleToHtmlFriendly(brandTitle: string): string;
export function htmlFriendlyToTitle(htmlFriendlyTitle: string): string;
export function getIconDataPath(rootDirectory?: string): string;
export function getIconsDataString(rootDirectory?: string): string;
export function getIconsData(rootDirectory?: string): IconData[];
export function getIconsDataString(rootDirectory?: string): Promise<string>;
export function getIconsData(rootDirectory?: string): Promise<IconData[]>;
export function normalizeNewlines(text: string): string;
export function normalizeColor(text: string): string;
export function getThirdPartyExtensions(

133
sdk.mjs
View File

@ -1,5 +1,5 @@
/**
* @fileoverview
* @file
* Simple Icons SDK.
*/
@ -8,10 +8,11 @@ import path from 'node:path';
import {fileURLToPath} from 'node:url';
/**
* @typedef {import("./sdk").ThirdPartyExtension} ThirdPartyExtension
* @typedef {import("./sdk").IconData} IconData
* @typedef {import("./sdk.d.ts").ThirdPartyExtension} ThirdPartyExtension
* @typedef {import("./sdk.d.ts").IconData} IconData
*/
/** @type {{ [key: string]: string }} */
const TITLE_TO_SLUG_REPLACEMENTS = {
'+': 'plus',
'.': 'dot',
@ -41,15 +42,15 @@ export const SVG_PATH_REGEX = /^m[-mzlhvcsqtae\d,. ]+$/i;
/**
* Get the directory name where this file is located from `import.meta.url`,
* equivalent to the `__dirname` global variable in CommonJS.
* @param {String} importMetaUrl import.meta.url
* @returns {String} Directory name in which this file is located
* @param {string} importMetaUrl import.meta.url
* @returns {string} Directory name in which this file is located
*/
export const getDirnameFromImportMeta = (importMetaUrl) =>
path.dirname(fileURLToPath(importMetaUrl));
/**
* Build a regex to validate HTTPs URLs.
* @param {String} jsonschemaPath Path to the *.jsonschema.json* file
* @param {string} jsonschemaPath Path to the *.jsonschema.json* file
* @returns {Promise<RegExp>} Regex to validate HTTPs URLs
*/
export const urlRegex = async (
@ -68,21 +69,21 @@ export const urlRegex = async (
/**
* Get the slug/filename for an icon.
* @param {IconData} icon The icon data as it appears in *_data/simple-icons.json*
* @returns {String} The slug/filename for the icon
* @returns {string} The slug/filename for the icon
*/
export const getIconSlug = (icon) => icon.slug || titleToSlug(icon.title);
/**
* Extract the path from an icon SVG content.
* @param {String} svg The icon SVG content
* @returns {String} The path from the icon SVG content
**/
* @param {string} svg The icon SVG content
* @returns {string} The path from the icon SVG content
*/
export const svgToPath = (svg) => svg.split('"', 8)[7];
/**
* Converts a brand title into a slug/filename.
* @param {String} title The title to convert
* @returns {String} The slug/filename for the title
* @param {string} title The title to convert
* @returns {string} The slug/filename for the title
*/
export const titleToSlug = (title) =>
title
@ -96,8 +97,8 @@ export const titleToSlug = (title) =>
/**
* Converts a slug into a variable name that can be exported.
* @param {String} slug The slug to convert
* @returns {String} The variable name for the slug
* @param {string} slug The slug to convert
* @returns {string} The variable name for the slug
*/
export const slugToVariableName = (slug) => {
const slugFirstLetter = slug[0].toUpperCase();
@ -107,8 +108,8 @@ export const slugToVariableName = (slug) => {
/**
* Converts a brand title as defined in *_data/simple-icons.json* into a brand
* title in HTML/SVG friendly format.
* @param {String} brandTitle The title to convert
* @returns {String} The brand title in HTML/SVG friendly format
* @param {string} brandTitle The title to convert
* @returns {string} The brand title in HTML/SVG friendly format
*/
export const titleToHtmlFriendly = (brandTitle) =>
brandTitle
@ -117,6 +118,8 @@ export const titleToHtmlFriendly = (brandTitle) =>
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll(/./g, (char) => {
/** @type {number} */
// @ts-ignore
const charCode = char.codePointAt(0);
return charCode > 127 ? `&#${charCode};` : char;
});
@ -124,8 +127,8 @@ export const titleToHtmlFriendly = (brandTitle) =>
/**
* Converts a brand title in HTML/SVG friendly format into a brand title (as
* it is seen in *_data/simple-icons.json*)
* @param {String} htmlFriendlyTitle The title to convert
* @returns {String} The brand title in HTML/SVG friendly format
* @param {string} htmlFriendlyTitle The title to convert
* @returns {string} The brand title in HTML/SVG friendly format
*/
export const htmlFriendlyToTitle = (htmlFriendlyTitle) =>
htmlFriendlyTitle
@ -134,13 +137,18 @@ export const htmlFriendlyToTitle = (htmlFriendlyTitle) =>
)
.replaceAll(
/&(quot|amp|lt|gt);/g,
/**
* @param {string} _ Full match
* @param {'quot' | 'amp' | 'lt' | 'gt'} reference Reference to replace
* @returns {string} Replacement for the reference
*/
(_, reference) => ({quot: '"', amp: '&', lt: '<', gt: '>'})[reference],
);
/**
* 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*
* @param {string} rootDirectory Path to the root directory of the project
* @returns {string} Path of *_data/simple-icons.json*
*/
export const getIconDataPath = (
rootDirectory = getDirnameFromImportMeta(import.meta.url),
@ -150,8 +158,8 @@ export const getIconDataPath = (
/**
* Get contents of *_data/simple-icons.json*.
* @param {String} rootDirectory Path to the root directory of the project
* @returns {String} Content of *_data/simple-icons.json*
* @param {string} rootDirectory Path to the root directory of the project
* @returns {Promise<string>} Content of *_data/simple-icons.json*
*/
export const getIconsDataString = (
rootDirectory = getDirnameFromImportMeta(import.meta.url),
@ -161,8 +169,8 @@ export const getIconsDataString = (
/**
* Get icons data as object from *_data/simple-icons.json*.
* @param {String} rootDirectory Path to the root directory of the project
* @returns {IconData[]} Icons data as array from *_data/simple-icons.json*
* @param {string} rootDirectory Path to the root directory of the project
* @returns {Promise<IconData[]>} Icons data as array from *_data/simple-icons.json*
*/
export const getIconsData = async (
rootDirectory = getDirnameFromImportMeta(import.meta.url),
@ -173,8 +181,8 @@ export const getIconsData = async (
/**
* Replace Windows newline characters by Unix ones.
* @param {String} text The text to replace
* @returns {String} The text with Windows newline characters replaced by Unix ones
* @param {string} text The text to replace
* @returns {string} The text with Windows newline characters replaced by Unix ones
*/
export const normalizeNewlines = (text) => {
return text.replaceAll('\r\n', '\n');
@ -182,8 +190,8 @@ export const normalizeNewlines = (text) => {
/**
* Convert non-6-digit hex color to 6-digit with the character `#` stripped.
* @param {String} text The color text
* @returns {String} The color text in 6-digit hex format
* @param {string} text The color text
* @returns {string} The color text in 6-digit hex format
*/
export const normalizeColor = (text) => {
let color = text.replace('#', '').toUpperCase();
@ -199,7 +207,7 @@ export const normalizeColor = (text) => {
/**
* Get information about third party extensions from the README table.
* @param {String} readmePath Path to the README file
* @param {string} readmePath Path to the README file
* @returns {Promise<ThirdPartyExtension[]>} Information about third party extensions
*/
export const getThirdPartyExtensions = async (
@ -214,23 +222,43 @@ export const getThirdPartyExtensions = async (
.split('|\n|')
.slice(2)
.map((line) => {
let [module, author] = line.split(' | ');
module = module.split('<img src="')[0];
const [module_, author] = line.split(' | ');
const module = module_.split('<img src="')[0];
const moduleName = /\[(.+)]/.exec(module)?.[1];
if (moduleName === undefined) {
throw new Error(`Module name improperly parsed from line: ${line}`);
}
const moduleUrl = /\((.+)\)/.exec(module)?.[1];
if (moduleUrl === undefined) {
throw new Error(`Module URL improperly parsed from line: ${line}`);
}
const authorName = /\[(.+)]/.exec(author)?.[1];
if (authorName === undefined) {
throw new Error(`Author improperly parsed from line: ${line}`);
}
const authorUrl = /\((.+)\)/.exec(author)?.[1];
if (authorUrl === undefined) {
throw new Error(`Author URL improperly parsed from line: ${line}`);
}
return {
module: {
name: /\[(.+)]/.exec(module)[1],
url: /\((.+)\)/.exec(module)[1],
name: moduleName,
url: moduleUrl,
},
author: {
name: /\[(.+)]/.exec(author)[1],
url: /\((.+)\)/.exec(author)[1],
name: authorName,
url: authorUrl,
},
};
});
/**
* Get information about third party libraries from the README table.
* @param {String} readmePath Path to the README file
* @param {string} readmePath Path to the README file
* @returns {Promise<ThirdPartyExtension[]>} Information about third party libraries
*/
export const getThirdPartyLibraries = async (
@ -247,23 +275,42 @@ export const getThirdPartyLibraries = async (
.map((line) => {
let [module, author] = line.split(' | ');
module = module.split('<img src="')[0];
const moduleName = /\[(.+)]/.exec(module)?.[1];
if (moduleName === undefined) {
throw new Error(`Module name improperly parsed from line: ${line}`);
}
const moduleUrl = /\((.+)\)/.exec(module)?.[1];
if (moduleUrl === undefined) {
throw new Error(`Module URL improperly parsed from line: ${line}`);
}
const authorName = /\[(.+)]/.exec(author)?.[1];
if (authorName === undefined) {
throw new Error(`Author improperly parsed from line: ${line}`);
}
const authorUrl = /\((.+)\)/.exec(author)?.[1];
if (authorUrl === undefined) {
throw new Error(`Author URL improperly parsed from line: ${line}`);
}
return {
module: {
name: /\[(.+)]/.exec(module)[1],
url: /\((.+)\)/.exec(module)[1],
name: moduleName,
url: moduleUrl,
},
author: {
name: /\[(.+)]/.exec(author)[1],
url: /\((.+)\)/.exec(author)[1],
name: authorName,
url: authorUrl,
},
};
});
/**
* `Intl.Collator` object ready to be used for icon titles sorting.
* @type {Intl.Collator}
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator Intl.Collator}
**/
* @see {@link https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator Intl.Collator}
*/
export const collator = new Intl.Collator('en', {
usage: 'search',
caseFirst: 'upper',

View File

@ -1,8 +1,12 @@
/* eslint complexity: off, max-depth: off */
/**
* @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 {svgPathBbox} from 'svg-path-bbox';
import parsePath from 'svg-path-segments';
import svgpath from 'svgpath';
import {
@ -26,6 +30,7 @@ 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'),
);
@ -45,6 +50,10 @@ const updateIgnoreFile = process.env.SI_UPDATE_IGNORE === 'true';
const ignoreFile = './.svglint-ignored.json';
const iconIgnored = updateIgnoreFile ? {} : svglintIgnores;
/**
* @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)
@ -53,6 +62,10 @@ const sortObjectByKey = (object) => {
);
};
/**
* @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)
@ -61,15 +74,27 @@ const sortObjectByValue = (object) => {
);
};
const removeLeadingZeros = (number) => {
/**
* 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 number.toString().replace(/^(-?)(0)(\.?.+)/, '$1$3');
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;
@ -77,7 +102,8 @@ const collinear = (x1, y1, x2, y2, x3, y3) => {
/**
* Returns the number of digits after the decimal point.
* @param num The number of interest.
* @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) {
@ -94,7 +120,8 @@ const countDecimals = (number_) => {
/**
* Get the index at which the first path value of an SVG starts.
* @param svgFileContent The raw SVG as text.
* @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="';
@ -103,8 +130,9 @@ const getPathDIndex = (svgFileContent) => {
/**
* Get the index at which the text of the first `<title></title>` tag starts.
* @param svgFileContent The raw SVG as text.
**/
* @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;
@ -112,8 +140,9 @@ const getTitleTextIndex = (svgFileContent) => {
/**
* Convert a hexadecimal number passed as string to decimal number as integer.
* @param hex The hexadecimal number representation to convert.
**/
* @param {string} hex The hexadecimal number representation to convert.
* @returns {number} The decimal number representation.
*/
const hexadecimalToDecimal = (hex) => {
let result = 0;
let digitValue;
@ -125,6 +154,11 @@ const hexadecimalToDecimal = (hex) => {
return result;
};
/**
* 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_;
};
@ -132,21 +166,32 @@ const maybeShortenedWithEllipsis = (string_) => {
/**
* 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 = {};
return (argument, defaultKey = null) => {
const key = defaultKey || argument;
/**
* Memoized function.
* @param {any} argument The argument to memoize.
* @returns {any} The result of the memoized function.
*/
return (argument) => {
results[argument] ||= function_(argument);
results[key] ||= function_(argument);
return results[key];
return results[argument];
};
};
const getIconPath = memoize(($icon, _filepath) => $icon.find('path').attr('d'));
/** @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) {
@ -165,21 +210,34 @@ if (updateIgnoreFile) {
});
}
const isIgnored = (linterName, path) => {
/**
* 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[linterName] && Object.hasOwn(iconIgnored[linterName], path)
iconIgnored[linterRule] && Object.hasOwn(iconIgnored[linterRule], path)
);
};
const ignoreIcon = (linterName, path, $) => {
iconIgnored[linterName] ||= {};
/**
* 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[linterName][path] = iconName;
iconIgnored[linterRule][path] = iconName;
};
/** @type {import('svglint').Config} */
const config = {
rules: {
elm: {
@ -213,6 +271,7 @@ const config = {
},
],
custom: [
// eslint-disable-next-line complexity
(reporter, $, ast) => {
reporter.name = 'icon-title';
@ -307,6 +366,8 @@ const config = {
encodedBuf.unshift(iconTitleText[i]);
} else {
// Encode all non ascii characters plus "'&<> (XML named entities)
/** @type {number} */
// @ts-ignore Coerce to number
const charDecimalCode = iconTitleText.codePointAt(i);
if (charDecimalCode > 127) {
@ -337,8 +398,12 @@ const config = {
// 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]))) {
for (const match of encodingMatches.filter((m) => {
// TODO: this fails using `Number.isNaN`, investigate
// @ts-ignore
// eslint-disable-next-line unicorn/prefer-number-properties
return !isNaN(m[2]);
})) {
const decimalNumber = Number.parseInt(match[2], 10);
if (decimalNumber > 127) {
continue;
@ -378,10 +443,10 @@ const config = {
}
}
},
(reporter, $, ast, {filepath}) => {
(reporter, $) => {
reporter.name = 'icon-size';
const iconPath = getIconPath($, filepath);
const iconPath = getIconPath($);
if (!updateIgnoreFile && isIgnored(reporter.name, iconPath)) {
return;
}
@ -407,16 +472,19 @@ const config = {
}
}
},
(reporter, $, ast, {filepath}) => {
(reporter, $, ast) => {
reporter.name = 'icon-precision';
const iconPath = getIconPath($, filepath);
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
...segment.params.slice(1).map(countDecimals),
...numberParameters.map(countDecimals),
);
if (precisionMax > iconMaxFloatPrecision) {
let errorMessage =
@ -439,11 +507,16 @@ const config = {
}
}
},
(reporter, $, ast, {filepath}) => {
(reporter, $, ast) => {
reporter.name = 'ineffective-segments';
const iconPath = getIconPath($, filepath);
const iconPath = getIconPath($);
const segments = getIconPathSegments(iconPath);
/** @type {import('svg-path-segments').Segment[]} */
// TODO: svgpath does not includes the segment property on the interface,
// see https://github.com/fontello/svgpath/pull/67/files
// @ts-ignore
const absSegments = svgpath(iconPath).abs().unshort().segments;
const lowerMovementCommands = ['m', 'l'];
@ -476,11 +549,16 @@ const config = {
...curveCommands,
]);
const isInvalidSegment = (
[command, x1Coord, y1Coord, ...rest],
index,
previousSegmentIsZ,
) => {
/**
* 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) {
@ -534,6 +612,7 @@ const config = {
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) {
@ -609,6 +688,8 @@ const config = {
);
}
}
return false;
};
for (let index = 0; index < segments.length; index++) {
@ -616,7 +697,7 @@ const config = {
const previousSegmentIsZ =
index > 0 && segments[index - 1].params[0].toLowerCase() === 'z';
if (isInvalidSegment(segment.params, index, previousSegmentIsZ)) {
if (isInvalidSegment(segment, index, previousSegmentIsZ)) {
const [command, _x1, _y1, ...rest] = segment.params;
let errorMessage = `Ineffective segment "${iconPath.slice(
@ -671,13 +752,15 @@ const config = {
}
}
},
(reporter, $, ast, {filepath}) => {
(reporter, $, ast) => {
reporter.name = 'collinear-segments';
/**
* Extracts collinear coordinates from SVG path straight lines
* (does not extracts collinear coordinates from curves).
**/
* (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 = [];
@ -694,13 +777,18 @@ const config = {
const seg = segments[s];
const parms = seg.params;
const cmd = parms[0];
const nextCmd = s + 1 < segments.length ? segments[s + 1][0] : null;
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': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = parms[1];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = parms[2];
// SVG 1.1:
// If a moveto is followed by multiple pairs of coordinates,
@ -713,7 +801,11 @@ const config = {
}
case 'm': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2];
if (seg.chain === undefined || seg.chain.start === seg.start) {
startPoint = undefined;
@ -723,33 +815,49 @@ const config = {
}
case 'H': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = parms[1];
break;
}
case 'h': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
break;
}
case 'V': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = parms[1];
break;
}
case 'v': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[1];
break;
}
case 'L': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = parms[1];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = parms[2];
break;
}
case 'l': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2];
break;
}
@ -763,61 +871,101 @@ const config = {
}
case 'C': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = parms[5];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = parms[6];
break;
}
case 'c': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[5];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[6];
break;
}
case 'A': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = parms[6];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = parms[7];
break;
}
case 'a': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[6];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[7];
break;
}
case 's': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2];
break;
}
case 'S': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = parms[1];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = parms[2];
break;
}
case 't': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[1];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[2];
break;
}
case 'T': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = parms[1];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = parms[2];
break;
}
case 'Q': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = parms[3];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = parms[4];
break;
}
case 'q': {
/** @type {number} */
// @ts-ignore
currentAbsCoord[0] = (currentAbsCoord[0] || 0) + parms[3];
/** @type {number} */
// @ts-ignore
currentAbsCoord[1] = (currentAbsCoord[1] || 0) + parms[4];
break;
}
@ -872,7 +1020,7 @@ const config = {
return collinearSegments;
};
const iconPath = getIconPath($, filepath);
const iconPath = getIconPath($);
const collinearSegments = getCollinearSegments(iconPath);
if (collinearSegments.length === 0) {
return;
@ -913,10 +1061,10 @@ const config = {
}
}
},
(reporter, $, ast, {filepath}) => {
(reporter, $, ast) => {
reporter.name = 'negative-zeros';
const iconPath = getIconPath($, filepath);
const iconPath = getIconPath($);
// Find negative zeros inside path
const negativeZeroMatches = [...iconPath.matchAll(negativeZerosRegexp)];
@ -937,10 +1085,10 @@ const config = {
}
}
},
(reporter, $, ast, {filepath}) => {
(reporter, $) => {
reporter.name = 'icon-centered';
const iconPath = getIconPath($, filepath);
const iconPath = getIconPath($);
if (!updateIgnoreFile && isIgnored(reporter.name, iconPath)) {
return;
}
@ -964,15 +1112,17 @@ const config = {
}
}
},
(reporter, $, ast, {filepath}) => {
(reporter, $, ast) => {
reporter.name = 'final-closepath';
const iconPath = getIconPath($, filepath);
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.at(0));
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);
@ -985,10 +1135,10 @@ const config = {
reporter.error(errorMessage);
}
},
(reporter, $, ast, {filepath}) => {
(reporter, $, ast) => {
reporter.name = 'path-format';
const iconPath = getIconPath($, filepath);
const iconPath = getIconPath($);
if (!SVG_PATH_REGEX.test(iconPath)) {
const errorMessage = 'Invalid path format';

View File

@ -1,6 +1,10 @@
/**
* @file SVGO configuration for Simple Icons.
*/
/** @type {import("svgo").Config} */
const config = {
multipass: true,
eol: 'lf',
plugins: [
'cleanupAttrs',
'inlineStyles',
@ -72,7 +76,6 @@ const config = {
name: 'sortAttrs',
params: {
order: ['role', 'viewBox', 'xmlns'],
xmlnsOrder: 'end',
},
},
'sortDefsChildren',
@ -87,7 +90,6 @@ const config = {
],
},
},
'removeElementsByAttr',
{
// Keep the role="img" attribute and automatically add it
// to the <svg> tag if it's not there already

View File

@ -1,3 +1,7 @@
/**
* @file Tests for the documentation.
*/
import {strict as assert} from 'node:assert';
import {test} from 'mocha';
import {getThirdPartyExtensions, getThirdPartyLibraries} from '../sdk.mjs';

View File

@ -1,3 +1,9 @@
/**
* @file Tests for the index file of npm package.
*/
// The index.mjs file is generated on build before running tests
// @ts-ignore
import * as simpleIcons from '../index.mjs';
import {getIconSlug, getIconsData, slugToVariableName} from '../sdk.mjs';
import {testIcon} from './test-icon.js';
@ -5,6 +11,8 @@ import {testIcon} from './test-icon.js';
for (const icon of await getIconsData()) {
const slug = getIconSlug(icon);
const variableName = slugToVariableName(slug);
/** @type {import('../types.d.ts').SimpleIcon} */
// @ts-ignore
const subject = simpleIcons[variableName];
testIcon(icon, subject, slug);

View File

@ -1,8 +1,18 @@
/**
* @file Custom mocha reporter.
*
* Serves to clear the console after the test run is finished.
* See {@link https://github.com/mochajs/mocha/issues/2312}
*/
const {reporters, Runner} = require('mocha');
const {EVENT_RUN_END} = Runner.constants;
class EvenMoreMin extends reporters.Base {
/**
* @param {import('mocha').Runner} runner Mocha test runner
*/
constructor(runner) {
super(runner);
runner.once(EVENT_RUN_END, () => this.epilogue());

View File

@ -1,3 +1,7 @@
/**
* @file Icon tester.
*/
import {strict as assert} from 'node:assert';
import fs from 'node:fs/promises';
import path from 'node:path';
@ -14,15 +18,11 @@ const iconsDirectory = path.resolve(
'icons',
);
/**
* @typedef {import('..').SimpleIcon} SimpleIcon
*/
/**
* Checks if icon data matches a subject icon.
* @param {SimpleIcon} icon Icon data
* @param {SimpleIcon} subject Icon to check against icon data
* @param {String} slug Icon data slug
* @param {import('../sdk.d.ts').IconData} icon Icon data
* @param {import('../types.d.ts').SimpleIcon} subject Icon object to check against icon data
* @param {string} slug Icon data slug
*/
export const testIcon = (icon, subject, slug) => {
const svgPath = path.resolve(iconsDirectory, `${slug}.svg`);
@ -62,8 +62,10 @@ export const testIcon = (icon, subject, slug) => {
it(`has ${icon.license ? 'the correct' : 'no'} "license"`, () => {
if (icon.license) {
assert.equal(subject.license.type, icon.license.type);
assert.equal(subject.license?.type, icon.license.type);
if (icon.license.type === 'custom') {
// TODO: `Omit` not working smoothly here
// @ts-ignore
assert.equal(subject.license.url, icon.license.url);
}
} else {

5
types.d.ts vendored
View File

@ -1,6 +1,9 @@
/**
* @file Types for Simple Icons package.
*/
/**
* The license for a Simple Icon.
*
* @see {@link https://github.com/simple-icons/simple-icons/blob/develop/CONTRIBUTING.md#optional-data Optional Data}
*/
export type License = SPDXLicense | CustomLicense;