diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e0ebd075832..e5b6682ae939 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -265,6 +265,8 @@ Here is the object of a fictional brand as an example: } ``` +You can use `npm run add-icon-data` to add metadata via a CLI prompt. + Make sure the icon is added in alphabetical order. If you're in doubt, you can always run `npm run our-lint` - this will tell you if any of the JSON data is in the wrong order. #### Optional Data diff --git a/package.json b/package.json index b8529399b9b0..69b4d23f52c9 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,13 @@ "url": "https://opencollective.com/simple-icons" }, "devDependencies": { + "chalk": "^5.0.1", "editorconfig-checker": "4.0.2", "esbuild": "0.15.6", "fake-diff": "1.0.0", + "get-relative-luminance": "^1.0.0", "husky": "8.0.1", + "inquirer": "^9.1.2", "is-ci": "3.0.1", "jsonschema": "1.4.1", "mocha": "10.0.0", @@ -71,7 +74,8 @@ "pretest": "npm run prepublishOnly", "posttest": "npm run postpublish", "svgo": "svgo --config svgo.config.js", - "get-filename": "node scripts/get-filename.js" + "get-filename": "node scripts/get-filename.js", + "add-icon-data": "node scripts/add-icon-data.js" }, "engines": { "node": ">=0.12.18" diff --git a/scripts/add-icon-data.js b/scripts/add-icon-data.js new file mode 100644 index 000000000000..1f116b823dda --- /dev/null +++ b/scripts/add-icon-data.js @@ -0,0 +1,137 @@ +import fs from 'node:fs/promises'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; +import getRelativeLuminance from 'get-relative-luminance'; +import { + URL_REGEX, + collator, + getIconsDataString, + getIconDataPath, + writeIconsData, + titleToSlug, + normalizeColor, +} from './utils.js'; + +const hexPattern = /^#?[a-f0-9]{3,8}$/i; + +const iconsData = JSON.parse(await getIconsDataString()); + +const titleValidator = (text) => { + if (!text) return 'This field is required'; + if ( + iconsData.icons.find( + (x) => x.title === text || titleToSlug(x.title) === titleToSlug(text), + ) + ) + return 'This icon title or slug already exist'; + return true; +}; + +const hexValidator = (text) => + hexPattern.test(text) ? true : 'This should be a valid hex code'; + +const sourceValidator = (text) => + URL_REGEX.test(text) ? true : 'This should be a secure URL'; + +const hexTransformer = (text) => { + const color = normalizeColor(text); + const luminance = hexPattern.test(text) + ? getRelativeLuminance.default(`#${color}`) + : -1; + if (luminance === -1) return text; + return chalk.bgHex(`#${color}`).hex(luminance < 0.4 ? '#fff' : '#000')(text); +}; + +const getIconDataFromAnswers = (answers) => ({ + title: answers.title, + hex: answers.hex, + source: answers.source, + ...(answers.hasGuidelines ? { guidelines: answers.guidelines } : {}), + ...(answers.hasLicense + ? { + license: { + type: answers.licenseType, + ...(answers.licenseUrl ? { url: answers.licenseUrl } : {}), + }, + } + : {}), +}); + +const dataPrompt = [ + { + type: 'input', + name: 'title', + message: 'Title', + validate: titleValidator, + }, + { + type: 'input', + name: 'hex', + message: 'Hex', + validate: hexValidator, + filter: (text) => normalizeColor(text), + transformer: hexTransformer, + }, + { + type: 'input', + name: 'source', + message: 'Source', + validate: sourceValidator, + }, + { + type: 'confirm', + name: 'hasGuidelines', + message: 'The icon has brand guidelines?', + }, + { + type: 'input', + name: 'guidelines', + message: 'Guidelines', + validate: sourceValidator, + when: ({ hasGuidelines }) => hasGuidelines, + }, + { + type: 'confirm', + name: 'hasLicense', + message: 'The icon has brand license?', + }, + { + type: 'input', + name: 'licenseType', + message: 'License type', + validate: (text) => Boolean(text), + when: ({ hasLicense }) => hasLicense, + }, + { + type: 'input', + name: 'licenseUrl', + message: 'License URL', + suffix: ' (optional)', + validate: (text) => !Boolean(text) || sourceValidator(text), + when: ({ hasLicense }) => hasLicense, + }, + { + type: 'confirm', + name: 'confirm', + message: (answers) => { + const icon = getIconDataFromAnswers(answers); + return [ + 'About to write to simple-icons.json', + chalk.reset(JSON.stringify(icon, null, 4)), + chalk.reset('Is this OK?'), + ].join('\n\n'); + }, + }, +]; + +const answers = await inquirer.prompt(dataPrompt); +const icon = getIconDataFromAnswers(answers); + +if (answers.confirm) { + iconsData.icons.push(icon); + iconsData.icons.sort((a, b) => collator.compare(a.title, b.title)); + await writeIconsData(iconsData); +} else { + console.log('Aborted.'); + process.exit(1); +} diff --git a/scripts/lint/ourlint.js b/scripts/lint/ourlint.js index 39d48ea78d06..3512a55b92fa 100644 --- a/scripts/lint/ourlint.js +++ b/scripts/lint/ourlint.js @@ -48,7 +48,7 @@ const TESTS = { /* Check the formatting of the data file */ prettified: async (data, dataString) => { const normalizedDataString = normalizeNewlines(dataString); - const dataPretty = `${JSON.stringify(data, null, ' ')}\n`; + const dataPretty = `${JSON.stringify(data, null, 4)}\n`; if (normalizedDataString !== dataPretty) { const dataDiff = fakeDiff(normalizedDataString, dataPretty); diff --git a/scripts/utils.js b/scripts/utils.js index 72fd30dc09cd..8cf6cee36653 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -4,7 +4,7 @@ */ import path from 'node:path'; -import { promises as fs } from 'node:fs'; +import fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; const TITLE_TO_SLUG_REPLACEMENTS = { @@ -96,15 +96,22 @@ export const htmlFriendlyToTitle = (htmlFriendlyTitle) => ); /** - * Get contents of _data/simple-icons.json. + * Get path of _data/simpe-icons.json. * @param {String|undefined} rootDir Path to the root directory of the project. */ -export const getIconsDataString = (rootDir) => { +export const getIconDataPath = (rootDir) => { if (rootDir === undefined) { rootDir = path.resolve(getDirnameFromImportMeta(import.meta.url), '..'); } - const iconDataPath = path.resolve(rootDir, '_data', 'simple-icons.json'); - return fs.readFile(iconDataPath, 'utf8'); + return path.resolve(rootDir, '_data', 'simple-icons.json'); +}; + +/** + * Get contents of _data/simple-icons.json. + * @param {String|undefined} rootDir Path to the root directory of the project. + */ +export const getIconsDataString = (rootDir) => { + return fs.readFile(getIconDataPath(rootDir), 'utf8'); }; /** @@ -116,6 +123,19 @@ export const getIconsData = async (rootDir) => { return JSON.parse(fileContents).icons; }; +/** + * Write icons data to _data/simple-icons.json. + * @param {Object} iconsData Icons data object. + * @param {String|undefined} rootDir Path to the root directory of the project. + */ +export const writeIconsData = async (iconsData, rootDir) => { + return fs.writeFile( + getIconDataPath(rootDir), + `${JSON.stringify(iconsData, null, 4)}\n`, + 'utf8', + ); +}; + /** * Get the directory name where this file is located from `import.meta.url`, * equivalent to the `__dirname` global variable in CommonJS. @@ -132,6 +152,20 @@ export const normalizeNewlines = (text) => { return text.replace(/\r\n/g, '\n'); }; +/** + * Convert non-6-digit hex color to 6-digit. + * @param {String} text The color text + */ +export const normalizeColor = (text) => { + let color = text.replace('#', '').toUpperCase(); + if (color.length < 6) { + color = [...color.slice(0, 3)].map((x) => x.repeat(2)).join(''); + } else if (color.length > 6) { + color = color.slice(0, 6); + } + return color; +}; + /** * Get information about third party extensions. * @param {String} readmePath Path to the README file