Convert simple-icons manual backup from submodule to tracked files

This commit is contained in:
MayaChat
2025-11-24 14:51:23 -05:00
parent 30bbe7a0a4
commit f363db5e47
3467 changed files with 42987 additions and 1 deletions

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Script to add data for a new icon to the simple-icons dataset.
*/
/**
* @typedef {import("../sdk.js").IconData} IconData
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {checkbox, confirm, input, search} from '@inquirer/prompts';
import chalk from 'chalk';
import {search as fuzzySearch} from 'fast-fuzzy';
import getRelativeLuminance from 'get-relative-luminance';
import {getIconsDataString, normalizeColor, titleToSlug} from '../sdk.mjs';
import {
formatIconData,
getJsonSchemaData,
getSpdxLicenseIds,
sortIconsCompare,
writeIconsData,
} from './utils.js';
process.exitCode = 1;
process.on('uncaughtException', (error) => {
if (error instanceof Error && error.name === 'ExitPromptError') {
process.stdout.write('\nAborted\n');
process.exit(1);
} else {
throw error;
}
});
/** @type {import('../types.d.ts').IconData[]} */
const iconsData = JSON.parse(await getIconsDataString());
const jsonSchema = await getJsonSchemaData();
const HEX_REGEX = /^#?[a-f\d]{3,8}$/i;
const aliasTypes = ['aka', 'old'].map((key) => ({
name: `${key} (${jsonSchema.definitions.brand.properties.aliases.properties[key].description})`,
value: key,
}));
const spdxLicenseIds = await getSpdxLicenseIds();
const licenseTypes = [
{name: 'Custom', value: 'custom'},
...spdxLicenseIds.map((id) => ({name: id, value: id})),
];
/**
* Build a regex to validate HTTPs URLs.
* @returns {Promise<RegExp>} Regex to validate HTTPs URLs.
*/
const urlRegex = async () =>
new RegExp(
JSON.parse(
await fs.readFile(
path.resolve(import.meta.dirname, '..', '.jsonschema.json'),
'utf8',
),
).definitions.url.pattern,
);
/**
* Whether an input is a valid URL.
* @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.';
};
/**
* Whether an input is a valid hex color.
* @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.';
/**
* Whether an icon is not already in the dataset.
* @param {string} input New icon input.
* @returns {boolean} Whether the icon is new.
*/
const isNewIcon = (input) =>
!iconsData.some(
(icon) =>
icon.title === input || titleToSlug(icon.title) === titleToSlug(input),
);
/**
* Compute a preview of a color to use in prompt background.
* @param {string} input Color input.
* @returns {string} Preview of the color.
*/
const previewHexColor = (input) => {
const color = normalizeColor(input);
const luminance = HEX_REGEX.test(input)
? getRelativeLuminance.default(`#${color}`)
: -1;
if (luminance === -1) return input.toUpperCase();
return chalk.bgHex(`#${color}`).hex(luminance < 0.4 ? '#fff' : '#000')(
input.toUpperCase(),
);
};
/** @type {IconData} */
// @ts-expect-error: `slug` is not required in our source simple-icons.json file.
const answers = {
title: '',
hex: '',
source: '',
};
answers.title = await input({
message: 'What is the title of this icon?',
validate: (input) =>
input.trim().length > 0
? isNewIcon(input) || 'This icon title or slug already exists.'
: 'This field is required.',
});
answers.hex = normalizeColor(
await input({
message: 'What is the brand color of this icon?',
validate: isValidHexColor,
transformer: previewHexColor,
}),
);
answers.source = await input({
message: 'What is the source URL of the icon?',
validate: isValidURL,
});
if (
await confirm({
message: 'Does this icon have brand guidelines?',
})
) {
answers.guidelines = await input({
message: 'What is the URL for the brand guidelines?',
validate: isValidURL,
});
}
if (
await confirm({
message: 'Does this icon have a license?',
})
) {
answers.license = {
type: await search({
message: "What is the icon's license?",
async source(input) {
input = (input || '').trim();
return input
? fuzzySearch(input, licenseTypes, {
keySelector: (x) => x.value,
})
: licenseTypes;
},
}),
};
if (answers.license.type === 'custom') {
// @ts-expect-error
answers.license.url = await input({
message: `What is the URL for the license? (optional)`,
validate: (input) => input.length === 0 || isValidURL(input),
});
}
}
if (
await confirm({
message: 'Does this icon have brand aliases?',
default: false,
})
) {
answers.aliases = await checkbox({
message: 'What types of aliases do you want to add?',
choices: aliasTypes,
})
// eslint-disable-next-line promise/prefer-await-to-then
.then(async (aliases) => {
/** @type {{[_: string]: string[]}} */
const result = {};
for (const alias of aliases) {
// eslint-disable-next-line no-await-in-loop
result[alias] = await input({
message: `What ${alias} aliases would you like to add? (separate with commas)`,
})
// eslint-disable-next-line promise/prefer-await-to-then
.then((aliases) => aliases.split(',').map((alias) => alias.trim()));
}
return aliases.length > 0 ? result : undefined;
});
}
process.stdout.write(
'About to write the following to simple-icons.json:\n' +
JSON.stringify(answers, null, '\t') +
'\n',
);
if (
await confirm({
message: 'Is this OK?',
})
) {
iconsData.push(answers);
iconsData.sort(sortIconsCompare);
await writeIconsData(formatIconData(iconsData));
process.stdout.write(chalk.green('\nData written successfully.\n'));
process.exit(0);
} else {
process.stdout.write(chalk.red('\nAborted.\n'));
process.exit(1);
}

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env node
// @ts-check
/* eslint jsdoc/reject-any-type: off */
/**
* @file Auto-close script for closing won't add icons.
*/
import path from 'node:path';
import process from 'node:process';
/**
* @typedef {object} Rule
* @property {RegExp[]} patterns - The pattern to match against the issue title.
* @property {string} reason - The issue numbers to include in the reason text.
*/
/**
* @typedef {Rule[]} Config
*/
/**
* @typedef {object} Issue
* @property {{name: string}[]} labels - Issue labels.
* @property {string} state - Issue state, possible values are 'open' and 'closed'.
* @property {string} title - Issue title.
* @property {string} body - Issue body.
*/
/** @type {Config} */
const rules = await import(
path.join(import.meta.dirname, 'autoclose.rules.js')
).then((module) => module.default);
const {GITHUB_TOKEN, GITHUB_REPOSITORY, ISSUE_NUMBER} = process.env;
if ([GITHUB_TOKEN, GITHUB_REPOSITORY, ISSUE_NUMBER].some((v) => !v)) {
console.error(
`${Object.entries({GITHUB_TOKEN, GITHUB_REPOSITORY, ISSUE_NUMBER})
.filter(([, v]) => !v)
.map(([k]) => k)
.join(', ')} environment variable(s) must be set.`,
);
process.exit(1);
}
/**
* Fetch data from GitHub API.
* @param {string} url The URL to fetch.
* @param {globalThis.RequestInit} options The options to pass to the fetch function.
* @returns {Promise<any>} - The response data.
*/
const githubFetch = async (url, options) => {
const response = await globalThis.fetch(new URL(url), {
...options,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${GITHUB_TOKEN}`,
'X-GitHub-Api-Version': '2022-11-28',
...options?.headers,
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch ${url}: ${response.status} (${response.statusText}).`,
);
}
return response;
};
/**
* Check if the issue is a won't add icon issue.
* GitHub REST API: https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#get-an-issue.
* @returns {Promise<string | undefined>} - Returns reason if the issue is a won't add icon issue, undefined otherwise.
*/
const checkIfCanBeClosed = async () => {
const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}`;
const response = await githubFetch(url, {method: 'GET'});
/** @type {Issue} */
const json = await response.json();
const {labels, state, title, body} = json;
const labelNames = new Set(labels.map((label) => label.name));
if (state === 'closed') return undefined;
if (labelNames.has('meta')) return undefined;
if (labelNames.has('in discussion')) return undefined;
if (!labelNames.has('new icon')) return undefined;
const matched = rules.find((rule) =>
rule.patterns.some((pattern) => {
const brandNamePattern = new RegExp(
`### Brand Name\n*${pattern.source.replaceAll('$', '')}\n*###`,
'i',
);
return pattern.test(title) || brandNamePattern.test(body);
}),
);
if (!matched) return undefined;
return matched.reason;
};
/**
* Close the issue as not planned.
* GitHub REST API: https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#update-an-issue.
*/
const closeAsNotPlanned = async () => {
const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}`;
await githubFetch(url, {
method: 'PATCH',
body: JSON.stringify({
state: 'closed',
state_reason: 'not_planned', // eslint-disable-line camelcase
}),
});
};
/**
* Add labels to the issue.
* GitHub REST API: https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#add-labels-to-an-issue.
*/
const addLabels = async () => {
const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}/labels`;
await githubFetch(url, {
method: 'POST',
body: JSON.stringify({
labels: ['duplicate', "won't add"],
}),
});
};
/**
* Post a comment on the issue.
* GitHub REST API: https://docs.github.com/en/rest/issues/comments?apiVersion=2022-11-28#create-an-issue-comment.
* @param {string} reason The reason for closing the issue.
*/
const commentWithReason = async (reason) => {
const url = `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}/comments`;
await githubFetch(url, {
method: 'POST',
body: JSON.stringify({
body: reason,
}),
});
};
try {
const reason = await checkIfCanBeClosed();
if (reason) {
await closeAsNotPlanned();
await addLabels();
await commentWithReason(reason);
}
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
} else {
console.error(String(error));
}
process.exit(1);
}

View File

@@ -0,0 +1,56 @@
// @ts-check
/**
* @file Auto-close rules for closing won't add icons.
*/
const autocloseTerm = 'This issue was automatically closed. Please refer to ';
/** @type {import('./autoclose.app.js').Config} */
const rules = [
{
patterns: [/matlab/i],
reason: autocloseTerm + '#1233.',
},
{
patterns: [/disney\s*(?:plus|\+)?/i],
reason: autocloseTerm + '#2309.',
},
{
patterns: [/british\s*petroleum/i],
reason: autocloseTerm + '#5849.',
},
{
patterns: [/mattel/i, /barbie/i, /hot\s*wheels/i, /fisher[-\s]*price/i],
reason: autocloseTerm + '#6656.',
},
{
patterns: [/oracle/i, /java(?:\s|$)/i],
reason: autocloseTerm + '#7374.',
},
{
patterns: [/microchip/i],
reason: autocloseTerm + '#9373.',
},
{
patterns: [/adobe/i, /photoshop/i],
reason: autocloseTerm + '#10018.',
},
{
patterns: [/microsoft/i, /vs\s*code/, /visual\s*studio/i, /windows/i],
reason: autocloseTerm + '#11236.',
},
{
patterns: [/linked\s*in/i],
reason: autocloseTerm + '#11236 #11372.',
},
{
patterns: [/amazon/i, /aws/i],
reason: autocloseTerm + '#13056.',
},
{
patterns: [/yahoo/i, /engadget/i, /aol/i],
reason: autocloseTerm + '#9861.',
},
];
export default rules;

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Clean files built by the build process.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {fileExists} from '../utils.js';
const files = [
'index.js',
'index-icons.js',
'index.mjs',
'index-icons.mjs',
'index.d.ts',
'sdk.js',
];
try {
Promise.all(
files.map(async (file) => {
const filepath = path.resolve(import.meta.dirname, '..', '..', file);
if (!(await fileExists(filepath))) {
console.error(`File ${file} does not exist, skipping...`);
return;
}
return fs.unlink(filepath);
}),
);
} catch (error) {
console.error('Error cleaning files:', error);
process.exit(1);
}

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Simple Icons package build script.
*/
/**
* @typedef {import('../../types.js').License} License
*/
import {promises as fs} from 'node:fs';
import path from 'node:path';
import {format} from 'node:util';
import {transform as esbuildTransform} from 'esbuild';
import {
getIconSlug,
getIconsData,
slugToVariableName,
svgToPath,
titleToHtmlFriendly,
} from '../../sdk.mjs';
import {sortIconsCompare} from '../utils.js';
const UTF8 = 'utf8';
const rootDirectory = path.resolve(import.meta.dirname, '..', '..');
const iconsDirectory = path.resolve(rootDirectory, 'icons');
const indexJsFile = path.resolve(rootDirectory, 'index.js');
const indexMjsFile = path.resolve(rootDirectory, 'index.mjs');
const sdkJsFile = path.resolve(rootDirectory, 'sdk.js');
const sdkMjsFile = path.resolve(rootDirectory, 'sdk.mjs');
const indexDtsFile = path.resolve(rootDirectory, 'index.d.ts');
const templatesDirectory = path.resolve(import.meta.dirname, 'templates');
const iconObjectTemplateFile = path.resolve(
templatesDirectory,
'icon-object.js.template',
);
/**
* Merged type from icon data and icon JS object needed to build by reference
* to not decrease performance in the build process.
* @typedef {import('../../types.js').SimpleIcon & import('../../types.d.ts').IconData} IconDataAndObject
*/
const icons = await getIconsData();
const iconObjectTemplate = await fs.readFile(iconObjectTemplateFile, UTF8);
/**
* Escape a string for use in a JavaScript string.
* @param {string} value The value to escape.
* @returns {string} The escaped value.
*/
const escape = (value) => value.replaceAll(/(?<!\\)'/g, String.raw`\'`);
/**
* Converts a license object to a URL if the URL is not defined.
* @param {License} license The license object or URL.
* @returns {License} The license object with a URL.
*/
const licenseToString = (license) => {
if (license.url === undefined) {
license.url = `https://spdx.org/licenses/${license.type}`;
}
return license;
};
/**
* Converts an icon object to a JavaScript object.
* @param {IconDataAndObject} icon The icon object.
* @returns {string} The JavaScript object.
*/
const iconDataAndObjectToJsRepr = (icon) =>
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(licenseToString(icon.license))},`,
);
/**
* Write JavaScript content to a file.
*
* ESBuild by default uses `ascii` encoding for JavaScript files, so the titles of icons
* are encoded using escape sequences (eg. "Aerom\xE9xico" instead of "Aeroméxico").
* See {@link https://esbuild.github.io/api/#charset}.
* Although this adds a minimal size overhead, it is needed to ensure that our distributed
* JavaScript files are compatible with all JavaScript environments. Especially, browsers
* that are not using `<meta charset="utf-8">` in their HTML. As we support browsers
* without meta charset in SVG `<title>` elements, we need to ensure the same for scripts.
* @param {string} filepath The path to the file to write.
* @param {string} rawJavaScript The raw JavaScript content to write to the file.
* @param {'cjs'} [format] The format of the resulting JavaScript file.
*/
const writeJs = async (filepath, rawJavaScript, format = undefined) => {
/** @type {import('esbuild').TransformOptions} */
const options = {minify: true, charset: 'ascii', format};
const {code} = await esbuildTransform(rawJavaScript, options);
// ESBuild adds a trailing newline to the end of the file
await fs.writeFile(filepath, code.trimEnd());
};
/**
* Write TypeScript content to a file.
* @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);
};
/**
* Build icons intermediate instances.
* @returns {Promise<{
* icon: IconDataAndObject,
* iconObjectRepr: string,
* iconExportName: string
* }[]>} Merged icon data and object instances.
*/
const buildIcons = async () =>
Promise.all(
icons.map(async (iconData) => {
const slug = getIconSlug(iconData);
const svgFilepath = path.resolve(iconsDirectory, `${slug}.svg`);
const svg = await fs.readFile(svgFilepath, UTF8);
/** @type {IconDataAndObject} */
const icon = {};
Object.assign(icon, iconData);
icon.svg = svg;
icon.path = svgToPath(svg);
icon.slug = slug;
const iconObjectRepr = iconDataAndObjectToJsRepr(icon);
const iconExportName = slugToVariableName(slug);
return {icon, iconObjectRepr, iconExportName};
}),
);
const build = async () => {
const builtIcons = await buildIcons();
const iconsBarrelDts = [];
const iconsBarrelJs = [];
const iconsBarrelMjs = [];
builtIcons.sort((a, b) => sortIconsCompare(a.icon, b.icon));
for (const {iconObjectRepr, iconExportName} of builtIcons) {
iconsBarrelDts.push(`export const ${iconExportName}:I;`);
iconsBarrelJs.push(`${iconExportName}:${iconObjectRepr},`);
iconsBarrelMjs.push(`export const ${iconExportName}=${iconObjectRepr}`);
}
// Constants used in templates to reduce package size
const constantsString = `const a='<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>',b='</title><path d="',c='"/></svg>';`;
// Write our file containing the exports of all icons in CommonJS ...
const rawIndexJs = `${constantsString}module.exports={${iconsBarrelJs.join(
'',
)}};`;
await writeJs(indexJsFile, rawIndexJs);
// ... and ESM
const rawIndexMjs = constantsString + iconsBarrelMjs.join('');
await writeJs(indexMjsFile, rawIndexMjs);
// ... and create a type declaration file
const rawIndexDts = `import {SimpleIcon} from "./types";export {SimpleIcon};type I=SimpleIcon;${iconsBarrelDts.join(
'',
)}`;
await writeTs(indexDtsFile, rawIndexDts);
// Create a CommonJS SDK file
await writeJs(sdkJsFile, await fs.readFile(sdkMjsFile, UTF8), 'cjs');
// Build deprecated `simple-icons/icons` entrypoint.
// TODO: This must be removed at v17.
const deprecatedMessage =
`⚠️ The entrypoint 'simple-icons/icons' is deprecated and` +
` will be removed in version 17.0.0`;
const jsDeprecationMessage =
`${deprecatedMessage}. Please, import icons from 'simple-icons'` +
` using \`require('simple-icons')\` instead of \`require('simple-icons/icons')\`.`;
const iconsIndexJs =
`console.warn("${jsDeprecationMessage}");` +
`module.exports=require('./index.js');`;
const iconsIndexJsFile = path.resolve(rootDirectory, 'index-icons.js');
await writeJs(iconsIndexJsFile, iconsIndexJs);
const mjsDeprecationMessage =
`${deprecatedMessage}. Please, import icons from 'simple-icons'` +
` using \`import ... from 'simple-icons'\` instead of \`import ... from 'simple-icons/icons'\`.`;
const iconsIndexMjs =
`console.warn("${mjsDeprecationMessage}");` +
`export * from './index.mjs';`;
const iconsIndexMjsFile = path.resolve(rootDirectory, 'index-icons.mjs');
await writeJs(iconsIndexMjsFile, iconsIndexMjs);
};
await build();

View File

@@ -0,0 +1,10 @@
{
title: '%s',
slug: '%s',
get svg() {
return a + '%s' + b + this.path + c;
},
path: '%s',
source: '%s',
hex: '%s',%s%s
}

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Format data/simple-icons.json.
*/
import {getIconsData} from '../sdk.mjs';
import {formatIconData, writeIconsData} from './utils.js';
const icons = await getIconsData();
writeIconsData(formatIconData(icons));

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Script that takes a brand name as argument and outputs the corresponding
* icon SVG filename to standard output.
*/
import process from 'node:process';
import {titleToSlug} from '../sdk.mjs';
if (process.argv.length < 3) {
console.error('Provide a brand name as argument');
process.exit(1);
} else {
const brandName = process.argv[2];
const filename = titleToSlug(brandName);
process.stdout.write(
`For '${brandName}' use the file 'icons/${filename}.svg'\n`,
);
}

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* CLI tool to run jsonschema on the simple-icons.json data file.
*/
import process from 'node:process';
import {Validator} from 'jsonschema';
import {getIconsData} from '../../sdk.mjs';
import {getJsonSchemaData} from '../utils.js';
const icons = await getIconsData();
const schema = await getJsonSchemaData();
const validator = new Validator();
const result = validator.validate(icons, schema);
if (result.errors.length > 0) {
for (const error of result.errors) console.error(error);
console.error(`Found ${result.errors.length} error(s) in simple-icons.json`);
process.exit(1);
}

View File

@@ -0,0 +1,460 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Linters for the package that can't easily be implemented in the existing ones.
*/
/**
* @typedef {import("../../types.js").IconData} IconData
* @typedef {import("../../types.js").CustomLicense} CustomLicense
*/
import path from 'node:path';
import process from 'node:process';
import fakeDiff from 'fake-diff';
import {
collator,
getIconSlug,
getIconsDataString,
normalizeNewlines,
titleToSlug,
} from '../../sdk.mjs';
import {
fileExists,
formatIconData,
getSpdxLicenseIds,
sortIconsCompare,
} from '../utils.js';
const iconsDirectory = path.resolve(import.meta.dirname, '..', '..', 'icons');
/**
* Contains our tests so they can be isolated from each other.
* @type {{[k: string]: (data: IconData[], dataString: string) => Promise<string | undefined> | string | undefined}}
*/
const TESTS = {
/**
* Tests whether our icons are in alphabetical order.
* @param {IconData[]} icons Icons data.
* @returns {string|undefined} Error message or undefined.
*/
alphabetical(icons) {
/**
* 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];
const comparison = collator.compare(icon.title, previous.title);
if (comparison < 0) {
invalidEntries.push(icon);
} else if (
comparison === 0 &&
previous.slug &&
(!icon.slug || collator.compare(icon.slug, previous.slug) < 0)
) {
invalidEntries.push(icon);
}
}
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})`;
}
return icon.title;
};
/**
* Find the expected position of an icon.
* @param {IconData[]} expectedOrder Expected order of the icons.
* @param {IconData} targetIcon Icon to find.
* @returns {string} Expected position of the icon.
*/
const findPositon = (expectedOrder, targetIcon) => {
const foundIndex = expectedOrder.findIndex(
(icon) =>
targetIcon.title === icon.title && targetIcon.slug === icon.slug,
);
const before = expectedOrder[foundIndex - 1];
const after = expectedOrder[foundIndex + 1];
if (before) return `should be after ${format(before)}`;
if (after) return `should be before ${format(after)}`;
return 'not found';
};
// eslint-disable-next-line unicorn/no-array-reduce, unicorn/no-array-callback-reference
const invalids = icons.reduce(collector, []);
if (invalids.length > 0) {
const expectedOrder = [...icons].sort(sortIconsCompare);
return `Some icons aren't in alphabetical order:
${invalids.map((icon) => `${format(icon)} ${findPositon(expectedOrder, icon)}`).join('\n')}`;
}
},
/* Check the formatting of the data file */
prettified(data, dataString) {
const normalizedDataString = normalizeNewlines(dataString);
const dataPretty = `${JSON.stringify(data, null, '\t')}\n`;
if (normalizedDataString !== dataPretty) {
const dataDiff = fakeDiff(normalizedDataString, dataPretty);
return `Data file is formatted incorrectly:\n\n${dataDiff}`;
}
},
/* Check redundant trailing slash in URL */
checkUrls(icons) {
/**
* Check if an URL has a redundant trailing slash.
* @param {URL} $url URL instance.
* @param {string} url Original URL string.
* @returns {boolean} Whether the URL has a redundant trailing slash.
*/
const hasRedundantTrailingSlash = ($url, url) => url === $url.origin + '/';
/**
* Check if an URL is static wikimedia asset URL.
* @param {URL} $url URL instance.
* @returns {boolean} Whether the URL is static wikimedia asset URL.
*/
const isStaticWikimediaAssetUrl = ($url) =>
$url.hostname === 'upload.wikimedia.org';
/**
* Check if an URL is raw GitHub asset URL.
* @param {URL} $url URL instance.
* @returns {boolean} Whether the URL is raw GitHub asset URL.
*/
const isRawGithubAssetUrl = ($url) =>
$url.hostname === 'raw.githubusercontent.com';
/**
* Check if URl is user attachment URL.
* @param {URL} $url URL instance.
* @returns {boolean} Whether the URL is user attachment URL.
*/
const isGitHubUserAttachmentUrl = ($url) =>
$url.hostname === 'github.com' &&
$url.pathname.startsWith('/user-attachments/assets');
/**
* Check if an URL is a GitHub URL.
* @param {URL} $url URL instance.
* @returns {boolean} Whether the URL is a GitHub URL.
*/
const isGitHubUrl = ($url) => $url.hostname === 'github.com';
/**
* Regex to match a permalink GitHub URL for a file.
*/
const permalinkGitHubRegex =
/^https:\/\/github\.com\/[^/]+\/[^/]+\/(blob\/[a-f\d]{40}\/\S+)|(tree\/[a-f\d]{40}(\/\S+)?)|(((issues)|(pull)|(discussions))\/\d+#((issue)|(issuecomment)|(discussioncomment))-\d+)|(wiki\/\S+\/[a-f\d]{40})$/;
/**
* URLs excluded from the GitHub URL check as are used by GitHub brands.
*/
const gitHubExcludedUrls = new Set([
'https://github.com/logos',
'https://github.com/features/actions',
'https://github.com/sponsors',
]);
/**
* Check if an URL is a permanent GitHub URL for a file.
* @param {string} url URL string.
* @returns {boolean} Whether the URL is a GitHub URL for a file.
*/
const isPermalinkGitHubFileUrl = (url) => permalinkGitHubRegex.test(url);
/**
* Url fields with a boolean indicating if is an icon source URL.
* @type {[boolean, string][]}
*/
const allUrlFields = [];
for (const icon of icons) {
allUrlFields.push([true, icon.source]);
if (icon.guidelines) {
allUrlFields.push([false, icon.guidelines]);
}
if (icon.license !== undefined && Object.hasOwn(icon.license, 'url')) {
allUrlFields.push([
false,
// TODO: `hasOwn` is not currently supported by TS.
// See https://github.com/microsoft/TypeScript/issues/44253
/** @type {string} */
// @ts-expect-error
icon.license.url,
]);
}
}
const invalidUrls = [];
for (const [isSourceUrl, url] of allUrlFields) {
const $url = new globalThis.URL(url);
if (hasRedundantTrailingSlash($url, url)) {
invalidUrls.push(fakeDiff(url, $url.origin));
}
if (isGitHubUserAttachmentUrl($url)) continue;
if (isStaticWikimediaAssetUrl($url)) {
const expectedUrl = `https://commons.wikimedia.org/wiki/File:${path.basename($url.pathname)}`;
invalidUrls.push(fakeDiff(url, expectedUrl));
}
if (isRawGithubAssetUrl($url)) {
const [, owner, repo, hash, ...directory] = $url.pathname.split('/');
const expectedUrl = `https://github.com/${owner}/${repo}/blob/${hash}/${directory.join('/')}`;
invalidUrls.push(fakeDiff(url, expectedUrl));
}
if (
isSourceUrl &&
isGitHubUrl($url) &&
!isPermalinkGitHubFileUrl(url) &&
!gitHubExcludedUrls.has(url)
) {
invalidUrls.push(
`'${url}' must be a permalink GitHub URL. Expecting something like` +
" 'https://github.com/<owner>/<repo>/blob/<hash>/<file/path.ext>'.",
);
}
}
if (invalidUrls.length > 0) {
return `Invalid URLs:\n\n${invalidUrls.join('\n\n')}`;
}
},
/* Check if all licenses are valid SPDX identifiers */
async checkLicense(icons) {
const spdxLicenseIds = new Set(await getSpdxLicenseIds());
const badLicenses = [];
for (const icon of icons) {
if (
icon.license &&
icon.license.type !== 'custom' &&
!spdxLicenseIds.has(icon.license.type)
) {
badLicenses.push(
`${icon.title} (${getIconSlug(icon)}) has not a valid SPDX license.`,
);
}
}
if (badLicenses.length > 0) {
return `Bad licenses:\n\n${badLicenses.join('\n')}\n\nSee the valid license indentifiers at https://spdx.org/licenses`;
}
},
/* Ensure that fields are sorted in the same way for all icons */
fieldsSorted(icons) {
const formatted = formatIconData(icons);
const previous = JSON.stringify(icons, null, '\t');
const sorted = JSON.stringify(formatted, null, '\t');
if (previous !== sorted) {
const diff = fakeDiff(previous, sorted);
return `Fields are not sorted in the same way for all icons:\n\n${diff}`;
}
},
/* Ensure that aliases constraints are enforced. */
checkAliases(icons) {
const errors = [];
for (const icon of icons) {
// Old aliases must be different from the title
const oldAliases = icon.aliases?.old || [];
for (const oldAlias of oldAliases) {
if (oldAlias === icon.title) {
errors.push(
`Icon "${icon.title}" has an alias "old" that is the same as its title.` +
' Please remove the alias or change the title.',
);
}
}
// AKA aliases must be different from the title
const akaAliases = icon.aliases?.aka || [];
for (const akaAlias of akaAliases) {
if (akaAlias === icon.title) {
errors.push(
`Icon "${icon.title}" has an alias "aka" that is the same as its title.` +
' Please remove the alias or change the title.',
);
}
}
// Duplicate aliases titles must be different from the title
const duplicateAliases = icon.aliases?.dup || [];
for (const {title: duplicateAliasTitle} of duplicateAliases) {
if (duplicateAliasTitle === icon.title) {
errors.push(
`Icon "${icon.title}" has a duplicate alias "${duplicateAliasTitle}" that is the same as its title.` +
' Please remove the alias or change the title.',
);
}
}
// Duplicate aliases must be different from each other
// based on the title of each one.
const duplicateAliasesTitles = duplicateAliases.map(
(duplicateAlias) => duplicateAlias.title,
);
const uniqueDuplicateAliasesTitles = new Set(duplicateAliasesTitles);
if (uniqueDuplicateAliasesTitles.size !== duplicateAliasesTitles.length) {
errors.push(
`Icon "${icon.title}" has duplicate aliases with the same title.` +
' Please ensure that all duplicate aliases have unique titles.',
);
}
// Localized aliases must be different from the title
const locAliases = icon.aliases?.loc || {};
for (const [lang, locAlias] of Object.entries(locAliases)) {
if (locAlias === icon.title) {
errors.push(
`Icon "${icon.title}" has a localized alias "${lang}" that is the same as its title.` +
' Please remove the alias or change the title.',
);
}
}
}
return errors.join('\n') || undefined;
},
/* Ensure that titles constraints are enforced. */
checkTitles(icons) {
const titles = new Set();
const duplicateTitles = [];
for (const icon of icons) {
const {title, slug} = icon;
// Titles of icons that do not have slug must be unique.
if (slug === undefined) {
if (titles.has(title)) {
duplicateTitles.push(title);
} else {
titles.add(title);
}
}
}
const errors = [];
if (duplicateTitles.length > 0) {
const message =
`Found duplicate title for icons that do not have slug: ${duplicateTitles.join(', ')}.` +
`\nPlease, ensure that all titles are unique or these icons have proper slugs.`;
errors.push(message);
}
return errors.join('\n') || undefined;
},
/* Ensure that slugs constraints are enforced. */
async checkSlugs(icons) {
const errors = [];
for (const icon of icons) {
const {slug, title} = icon;
if (slug === undefined) {
continue;
}
// Custom slugs must be necessary. If a title is converted to a slug
// and it is the same as the slug, then it is not necessary.
if (titleToSlug(title) === slug) {
errors.push(
`Icon "${title}" has a slug "${slug}" that is the same as the slug inferred from its title.` +
' Please, remove the slug.',
);
continue;
}
// Custom slugs must be normalized with almost the same rules that
// are used for slugs inferred from titles. We allow underscores in
// custom slugs to ensure that they are unique.
const normalizedSlug = titleToSlug(slug);
const slugWithoutUnderscores = slug.replaceAll('_', '');
if (normalizedSlug !== slugWithoutUnderscores) {
errors.push(
`Icon "${title}" has a slug "${slug}" that is not normalized according to the rules used for titles.` +
` Please, rewrite as "${normalizedSlug}" or use another slug.`,
);
continue;
}
// Icons with custom slugs must have the corresponding icon file.
const iconFilePath = path.resolve(iconsDirectory, `${slug}.svg`);
// eslint-disable-next-line no-await-in-loop
const iconFilePathExists = await fileExists(iconFilePath);
if (!iconFilePathExists) {
errors.push(
`Icon "${title}" has a slug "${slug}" but the corresponding icon file "icons/${slug}.svg" does not exist.` +
' Please, create the icon file or remove the slug.',
);
continue;
}
for (const otherIcon of icons) {
if (otherIcon.title === title) {
continue;
}
// Custom slugs must be unique.
if (otherIcon.slug === slug) {
errors.push(
`Icon "${title}" has a slug "${slug}" that is not unique.` +
` It is already used by "${otherIcon.title}". Please, ensure that all slugs are unique.`,
);
}
// Custom slugs must be different from slugs inferred from titles.
if (slug === titleToSlug(otherIcon.title)) {
errors.push(
`Icon "${title}" has a slug "${slug}" that is the same as the slug inferred from the title of the icon "${otherIcon.title}".` +
' Please, ensure that all slugs are unique.',
);
}
}
}
return errors.join('\n') || undefined;
},
};
const iconsDataString = await getIconsDataString();
const iconsData = JSON.parse(iconsDataString);
const errors = (
await Promise.all(
Object.values(TESTS).map((test) => test(iconsData, iconsDataString)),
)
)
// eslint-disable-next-line unicorn/no-await-expression-member
.filter(Boolean);
if (errors.length > 0) {
for (const error of errors) console.error(`\u001B[31m${error}\u001B[0m`);
process.exit(1);
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Send release message to Discord #releases channel.
*/
import process from 'node:process';
const releaseVersion = process.argv[2];
const discordReleasesRoleId = process.env.DISCORD_RELEASES_ROLE_ID;
const discordReleasesWebhookUrl = process.env.DISCORD_RELEASES_WEBHOOK_URL;
const githubReleaseUrl = `https://github.com/simple-icons/simple-icons/releases/tag/${releaseVersion}`;
if (discordReleasesRoleId === undefined) {
console.error('DISCORD_RELEASES_ROLE_ID environment variable is not set.');
process.exit(1);
}
if (discordReleasesWebhookUrl === undefined) {
console.error(
'DISCORD_RELEASES_WEBHOOK_URL environment variable is not set.',
);
process.exit(1);
}
try {
await globalThis.fetch(discordReleasesWebhookUrl, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: `<@&${discordReleasesRoleId}> ${githubReleaseUrl}`,
}),
});
} catch {
console.error('Failed to send release message to Discord.');
process.exit(1);
}

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Minify data/simple-icons.json file.
*/
import {getIconSlug, getIconsData} from '../../sdk.mjs';
import {formatIconData, writeIconsData} from '../utils.js';
const plainIcons = await getIconsData();
const iconsWithSlugs = plainIcons.map((icon) =>
icon.slug ? icon : {...icon, slug: getIconSlug(icon)},
);
await writeIconsData(formatIconData(iconsWithSlugs), true);

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Rewrite some Markdown files.
*/
import {readFile, writeFile} from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
const LINKS_BRANCH = process.argv[2] || 'develop';
const rootDirectory = path.resolve(import.meta.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(
filePath,
fileContent
// Replace all CDN links with raw links
.replaceAll(
/https:\/\/cdn.simpleicons.org\/(.+)\/000\/fff/g,
`https://raw.githubusercontent.com/simple-icons/simple-icons/${LINKS_BRANCH}/icons/$1.svg`,
)
// Replace all GitHub blockquotes with regular markdown
// Reference: https://github.com/orgs/community/discussions/16925
.replaceAll(
/\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)](?!\()/g,
(string_, $0) => {
const capital = $0.slice(0, 1);
const body = $0.slice(1).toLowerCase();
return `**${capital + body}**`;
},
),
);
};
await Promise.all([reformat(readmeFile), reformat(disclaimerFile)]);

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
"module": "es2022",
"allowJs": true,
"skipLibCheck": true,
"removeComments": true,
"esModuleInterop": true,
"checkJs": false,
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"forceConsistentCasingInFileNames": true
},
"include": ["../../sdk.mjs"]
}

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
// @ts-check
/**
* @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.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
const rootDirectory = path.resolve(import.meta.dirname, '..', '..');
const packageJsonFile = path.resolve(rootDirectory, 'package.json');
const readmeFile = path.resolve(rootDirectory, 'README.md');
/**
* Get the major version number from a semantic version string.
* @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);
};
/**
* Update the version number in the README.md.
* @param {number} majorVersion The major version number.
*/
const updateVersionInReadmeIfNecessary = async (majorVersion) => {
let content = await fs.readFile(readmeFile, 'utf8');
content = content.replaceAll(
/simple-icons@v\d+/g,
`simple-icons@v${majorVersion}`,
);
await fs.writeFile(readmeFile, content);
};
const main = async () => {
try {
const manifest = await getManifest();
const majorVersion = getMajorVersion(manifest.version);
await updateVersionInReadmeIfNecessary(majorVersion);
} catch (error) {
console.error('Failed to update CDN version number:', error);
process.exit(1);
}
};
await main();

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Updates the SDK Typescript definitions located in the file sdk.d.ts
* to match the current definitions of functions of sdk.mjs.
*/
import {spawnSync} from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {fileExists} from '../utils.js';
const rootDirectory = path.resolve(import.meta.dirname, '..', '..');
const sdkTs = path.resolve(rootDirectory, 'sdk.d.ts');
const sdkMts = path.resolve(rootDirectory, 'sdk.d.mts');
const sdkMjs = path.resolve(rootDirectory, 'sdk.mjs');
const generateSdkMts = async () => {
// Remove temporally type definitions imported with comments
// in sdk.mjs to avoid circular imports
const originalSdkMjsContent = await fs.readFile(sdkMjs, 'utf8');
const temporarySdkMjsContent = originalSdkMjsContent
.split('\n')
.filter((line) => !line.startsWith(' * @typedef {import("./sdk")'))
.join('\n');
await fs.writeFile(sdkMjs, temporarySdkMjsContent);
let cmd;
let error = false;
try {
cmd = spawnSync(
'npx',
[
'tsc',
'-p',
path.join('scripts', 'release', 'sdk-ts-defs-jsconfig.json'),
'--declaration',
'--emitDeclarationOnly',
],
{stdio: 'inherit'},
);
} catch {
await fs.writeFile(sdkMjs, originalSdkMjsContent);
error = true;
}
let errorMessage = "Command 'npx tsc sdk.mjs' failed for an unknown reason";
if (cmd && cmd.stderr) {
errorMessage = cmd.stderr.toString();
}
if (error || cmd === undefined) {
process.stdout.write(
`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 sdkMtsExists = await fileExists(sdkMts);
if (sdkMtsExists) await fs.unlink(sdkMts);
await generateSdkMts();
const autogeneratedMessage =
'/* The next code is autogenerated from sdk.mjs */\n/* eslint-disable */';
const newSdkTsContent =
// eslint-disable-next-line unicorn/no-await-expression-member
(await fs.readFile(sdkTs, 'utf8')).split(autogeneratedMessage)[0] +
`${autogeneratedMessage}\n\n${await fs.readFile(sdkMts, 'utf8')}`;
await fs.writeFile(sdkTs, removeDuplicatedExportTypes(newSdkTsContent));
await fs.unlink(sdkMts);
let cmd;
let error = false;
try {
cmd = spawnSync('npx', ['prettier', '-w', 'sdk.d.ts'], {stdio: 'inherit'});
} catch {
error = true;
}
let errorMessage =
"Command 'npx prettier -w sdk.d.ts' failed for an unknown reason";
if (cmd && cmd.stderr) {
errorMessage = cmd.stderr.toString();
}
if (error || cmd === undefined) {
process.stdout.write(
'Error executing Prettier to prettify' +
` SDK TS definitions: '${errorMessage}'\n`,
);
process.exit(1);
}
};
await generateSdkTs();

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Generates a MarkDown file that lists every brand name and their slug.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import {getIconSlug, getIconsData} from '../../sdk.mjs';
const rootDirectory = path.resolve(import.meta.dirname, '..', '..');
const slugsFile = path.resolve(rootDirectory, 'slugs.md');
let content = `<!--
update the script at '${path.relative(rootDirectory, import.meta.filename)}'.
-->
# Simple Icons slugs
| Brand name | Brand slug |
| :--- | :--- |
`;
const icons = await getIconsData();
for (const icon of icons) {
const brandName = icon.title;
const brandSlug = getIconSlug(icon);
content += `| \`${brandName}\` | \`${brandSlug}\` |\n`;
}
await fs.writeFile(slugsFile, content);

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Replaces the SVG count milestone "Over <NUMBER> SVG icons..." located
* at README every time the number of current icons is more than `updateRange`
* more than the previous milestone.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {getIconsData} from '../../sdk.mjs';
const regexMatcher = /Over\s(\d+)\s/;
const updateRange = 100;
const rootDirectory = path.resolve(import.meta.dirname, '..', '..');
const readmeFile = path.resolve(rootDirectory, 'README.md');
const readmeContent = await fs.readFile(readmeFile, 'utf8');
try {
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 nIconsRounded = Math.floor(nIcons / updateRange) * updateRange;
if (overNIconsInReadme !== nIconsRounded) {
const newContent = readmeContent.replace(
regexMatcher,
`Over ${nIconsRounded} `,
);
await fs.writeFile(readmeFile, newContent);
}
}
} catch (error) {
console.error(
'Failed to update number of SVG icons of current milestone in README:',
error,
);
process.exit(1);
}

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
// @ts-check
/**
* @file
* Script to remove an icon and its data.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {search} from '@inquirer/prompts';
import {search as fuzzySearch} from 'fast-fuzzy';
import {getIconSlug, getIconsData} from '../sdk.mjs';
import {writeIconsData} from './utils.js';
process.exitCode = 1;
process.on('uncaughtException', (error) => {
if (error instanceof Error && error.name === 'ExitPromptError') {
process.stdout.write('\nAborted\n');
process.exit(1);
} else {
throw error;
}
});
const rootDirectory = path.resolve(import.meta.dirname, '..');
const svgFilesDirectory = path.resolve(rootDirectory, 'icons');
const iconsData = await getIconsData();
const icons = iconsData.map((icon, index) => {
const slug = getIconSlug(icon);
return {
name: `${icon.title} (${slug})`,
value: {title: icon.title, slug, index},
};
});
const found = await search({
message: 'Search for an icon to remove:',
async source(input) {
if (!input) return [];
return fuzzySearch(input, icons, {
keySelector: (icon) => [icon.value.title, icon.value.slug],
});
},
});
if (!found) {
console.error('No icon selected.');
process.exit(1);
}
iconsData.splice(found.index, 1);
await writeIconsData(iconsData);
await fs.unlink(path.resolve(svgFilesDirectory, `${found.slug}.svg`));
process.stdout.write(`Icon "${found.title} (${found.slug}.svg)" removed.\n`);
process.exit(0);

View File

@@ -0,0 +1,195 @@
// @ts-check
/* eslint jsdoc/reject-any-type: off */
/**
* @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 {collator, getIconSlug, getIconsDataPath, titleToSlug} from '../sdk.mjs';
/**
* @typedef {import("../types.js").IconData} IconData
* @typedef {import("../types.js").DuplicateAlias} DuplicateAlias
*/
/**
* Get JSON schema data.
* @returns {Promise<any>} JSON schema data.
*/
export const getJsonSchemaData = async () =>
JSON.parse(
await fs.readFile(
path.resolve(import.meta.dirname, '..', '.jsonschema.json'),
'utf8',
),
);
/**
* Write icons data to data/simple-icons.json.
* @param {IconData[]} iconsData Icons data array.
* @param {boolean} [minify] Whether to minify the JSON output.
*/
export const writeIconsData = async (iconsData, minify = false) => {
await fs.writeFile(
getIconsDataPath(),
`${JSON.stringify(iconsData, null, minify ? 0 : '\t')}\n`,
'utf8',
);
};
/**
* Get SPDX license IDs from `spdx-license-ids` package.
* @returns {Promise<string[]>} Set of SPDX license IDs.
*/
export const getSpdxLicenseIds = async () =>
JSON.parse(
await fs.readFile(
path.resolve(
import.meta.dirname,
'..',
'node_modules',
'spdx-license-ids',
'index.json',
),
'utf8',
),
);
/**
* The compare function for sorting icons in *data/simple-icons.json*.
* @param {IconData} a Icon A.
* @param {IconData} b Icon B.
* @returns {number} Comparison result.
*/
export const sortIconsCompare = (a, b) =>
a.title === b.title
? collator.compare(getIconSlug(a), getIconSlug(b))
: collator.compare(a.title, b.title);
/**
* The compare function for sorting icon duplicate aliases in *data/simple-icons.json*.
* @param {DuplicateAlias} a Duplicate alias A.
* @param {DuplicateAlias} b Duplicate alias B.
* @returns {number} Comparison result.
*/
const sortDuplicatesCompare = (a, b) =>
a.title === b.title
? collator.compare(titleToSlug(a.title), titleToSlug(b.title))
: collator.compare(a.title, b.title);
/**
* Sort icon data or duplicate alias object.
* @template {IconData | DuplicateAlias} T Either icon data or duplicate alias.
* @param {T} icon The icon data or duplicate alias as it appears in *data/simple-icons.json*.
* @returns {T} The sorted icon data or duplicate alias.
*/
const sortIconOrDuplicate = (icon) => {
const keyOrder = [
'title',
'slug',
'hex',
'source',
'guidelines',
'license',
'aliases',
// This is not appears in icon data but it's in the alias object.
'loc',
];
/** @type {T} */
const sortedIcon = Object.assign(
Object.fromEntries(
Object.entries(icon).sort(
([key1], [key2]) => keyOrder.indexOf(key1) - keyOrder.indexOf(key2),
),
),
);
return sortedIcon;
};
/**
* Sort license object.
* @param {IconData['license']} license The license object as it appears in *data/simple-icons.json*.
* @returns {IconData['license']} The sorted license object.
*/
const sortLicense = (license) => {
if (!license) return undefined;
const keyOrder = ['type', 'url'];
/** @type {IconData['license']} */
const sortedLicense = Object.assign(
Object.fromEntries(
Object.entries(license).sort(
([key1], [key2]) => keyOrder.indexOf(key1) - keyOrder.indexOf(key2),
),
),
);
return sortedLicense;
};
/**
* Sort object key alphabetically.
* @param {IconData['aliases']} object The aliases object as it appears in *data/simple-icons.json*.
* @returns {{[_: string]: string} | undefined} The sorted aliases object.
*/
const sortAlphabetically = (object) => {
if (!object) return undefined;
const sorted = Object.assign(
Object.fromEntries(
Object.entries(object).sort(([key1], [key2]) => (key1 > key2 ? 1 : -1)),
),
);
return sorted;
};
/**
* Sort icons data.
* @param {IconData[]} iconsData The icons data as it appears in *data/simple-icons.json*.
* @returns {IconData[]} The sorted icons data.
*/
export const formatIconData = (iconsData) => {
const iconsDataCopy = structuredClone(iconsData);
const icons = iconsDataCopy.map((icon) =>
sortIconOrDuplicate({
...icon,
license: sortLicense(icon.license),
aliases: icon.aliases
? sortAlphabetically({
aka: icon.aliases.aka?.sort(collator.compare),
dup: icon.aliases.dup
? icon.aliases.dup.sort(sortDuplicatesCompare).map((d) =>
sortIconOrDuplicate({
...d,
loc: sortAlphabetically(d.loc),
}),
)
: undefined,
loc: sortAlphabetically(icon.aliases.loc),
old: icon.aliases.old?.sort(collator.compare),
})
: undefined,
}),
);
icons.sort(sortIconsCompare);
return icons;
};
/**
* Check if a file exists.
* @param {string} fpath File path to check.
* @returns {Promise<boolean>} True if the file exists, false otherwise.
*/
export const fileExists = async (fpath) => {
try {
await fs.access(fpath);
return true;
} catch {
return false;
}
};