mirror of
				https://github.com/Mibew/simple-icons.git
				synced 2025-11-04 12:25:08 +03:00 
			
		
		
		
	Refactor tests and scripts (#9237)
Co-authored-by: LitoMore <LitoMore@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									8abcd9c8b9
								
							
						
					
					
						commit
						17ea889273
					
				@ -1,6 +1,8 @@
 | 
				
			|||||||
import fs from 'node:fs';
 | 
					import fs from 'node:fs/promises';
 | 
				
			||||||
import path from 'node:path';
 | 
					import path from 'node:path';
 | 
				
			||||||
 | 
					import process from 'node:process';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  SVG_PATH_REGEX,
 | 
				
			||||||
  getDirnameFromImportMeta,
 | 
					  getDirnameFromImportMeta,
 | 
				
			||||||
  htmlFriendlyToTitle,
 | 
					  htmlFriendlyToTitle,
 | 
				
			||||||
  collator,
 | 
					  collator,
 | 
				
			||||||
@ -19,16 +21,17 @@ const htmlNamedEntitiesFile = path.join(
 | 
				
			|||||||
);
 | 
					);
 | 
				
			||||||
const svglintIgnoredFile = path.join(__dirname, '.svglint-ignored.json');
 | 
					const svglintIgnoredFile = path.join(__dirname, '.svglint-ignored.json');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const data = JSON.parse(fs.readFileSync(dataFile, 'utf8'));
 | 
					const data = JSON.parse(await fs.readFile(dataFile, 'utf8'));
 | 
				
			||||||
const htmlNamedEntities = JSON.parse(
 | 
					const htmlNamedEntities = JSON.parse(
 | 
				
			||||||
  fs.readFileSync(htmlNamedEntitiesFile, 'utf8'),
 | 
					  await fs.readFile(htmlNamedEntitiesFile, 'utf8'),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					const svglintIgnores = JSON.parse(
 | 
				
			||||||
 | 
					  await fs.readFile(svglintIgnoredFile, 'utf8'),
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
const svglintIgnores = JSON.parse(fs.readFileSync(svglintIgnoredFile, 'utf8'));
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const svgRegexp =
 | 
					const svgRegexp =
 | 
				
			||||||
  /^<svg( [^\s]*=".*"){3}><title>.*<\/title><path d=".*"\/><\/svg>$/;
 | 
					  /^<svg( [^\s]*=".*"){3}><title>.*<\/title><path d=".*"\/><\/svg>$/;
 | 
				
			||||||
const negativeZerosRegexp = /-0(?=[^\.]|[\s\d\w]|$)/g;
 | 
					const negativeZerosRegexp = /-0(?=[^\.]|[\s\d\w]|$)/g;
 | 
				
			||||||
const svgPathRegexp = /^[Mm][MmZzLlHhVvCcSsQqTtAaEe0-9\-,. ]+$/;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const iconSize = 24;
 | 
					const iconSize = 24;
 | 
				
			||||||
const iconTargetCenter = iconSize / 2;
 | 
					const iconTargetCenter = iconSize / 2;
 | 
				
			||||||
@ -140,14 +143,14 @@ const getIconPathSegments = memoize((iconPath) => parsePath(iconPath));
 | 
				
			|||||||
const getIconPathBbox = memoize((iconPath) => svgPathBbox(iconPath));
 | 
					const getIconPathBbox = memoize((iconPath) => svgPathBbox(iconPath));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if (updateIgnoreFile) {
 | 
					if (updateIgnoreFile) {
 | 
				
			||||||
  process.on('exit', () => {
 | 
					  process.on('exit', async () => {
 | 
				
			||||||
    // ensure object output order is consistent due to async svglint processing
 | 
					    // ensure object output order is consistent due to async svglint processing
 | 
				
			||||||
    const sorted = sortObjectByKey(iconIgnored);
 | 
					    const sorted = sortObjectByKey(iconIgnored);
 | 
				
			||||||
    for (const linterName in sorted) {
 | 
					    for (const linterName in sorted) {
 | 
				
			||||||
      sorted[linterName] = sortObjectByValue(sorted[linterName]);
 | 
					      sorted[linterName] = sortObjectByValue(sorted[linterName]);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    fs.writeFileSync(ignoreFile, JSON.stringify(sorted, null, 2) + '\n', {
 | 
					    await fs.writeFile(ignoreFile, JSON.stringify(sorted, null, 2) + '\n', {
 | 
				
			||||||
      flag: 'w',
 | 
					      flag: 'w',
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
@ -197,7 +200,7 @@ export default {
 | 
				
			|||||||
      {
 | 
					      {
 | 
				
			||||||
        // ensure that the path element only has the 'd' attribute
 | 
					        // ensure that the path element only has the 'd' attribute
 | 
				
			||||||
        // (no style, opacity, etc.)
 | 
					        // (no style, opacity, etc.)
 | 
				
			||||||
        d: svgPathRegexp,
 | 
					        d: SVG_PATH_REGEX,
 | 
				
			||||||
        'rule::selector': 'svg > path',
 | 
					        'rule::selector': 'svg > path',
 | 
				
			||||||
        'rule::whitelist': true,
 | 
					        'rule::whitelist': true,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
@ -908,7 +911,7 @@ export default {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        const iconPath = getIconPath($, filepath);
 | 
					        const iconPath = getIconPath($, filepath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!svgPathRegexp.test(iconPath)) {
 | 
					        if (!SVG_PATH_REGEX.test(iconPath)) {
 | 
				
			||||||
          let errorMsg = 'Invalid path format',
 | 
					          let errorMsg = 'Invalid path format',
 | 
				
			||||||
            reason;
 | 
					            reason;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -920,7 +923,7 @@ export default {
 | 
				
			|||||||
            reporter.error(`${errorMsg}: ${reason}`);
 | 
					            reporter.error(`${errorMsg}: ${reason}`);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          const validPathCharacters = svgPathRegexp.source.replace(
 | 
					          const validPathCharacters = SVG_PATH_REGEX.source.replace(
 | 
				
			||||||
              /[\[\]+^$]/g,
 | 
					              /[\[\]+^$]/g,
 | 
				
			||||||
              '',
 | 
					              '',
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
 | 
				
			|||||||
@ -11461,7 +11461,7 @@
 | 
				
			|||||||
        {
 | 
					        {
 | 
				
			||||||
            "title": "SmugMug",
 | 
					            "title": "SmugMug",
 | 
				
			||||||
            "hex": "6DB944",
 | 
					            "hex": "6DB944",
 | 
				
			||||||
            "source": "https://help.smugmug.com/using-smugmug's-logo-HJulJePkEBf"
 | 
					            "source": "https://www.smugmughelp.com/articles/409-smugmug-s-logo-and-usage"
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "title": "Snapchat",
 | 
					            "title": "Snapchat",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,4 @@
 | 
				
			|||||||
 | 
					import process from 'node:process';
 | 
				
			||||||
import chalk from 'chalk';
 | 
					import chalk from 'chalk';
 | 
				
			||||||
import { input, confirm, checkbox } from '@inquirer/prompts';
 | 
					import { input, confirm, checkbox } from '@inquirer/prompts';
 | 
				
			||||||
import getRelativeLuminance from 'get-relative-luminance';
 | 
					import getRelativeLuminance from 'get-relative-luminance';
 | 
				
			||||||
@ -27,10 +28,10 @@ const titleValidator = (text) => {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const hexValidator = (text) =>
 | 
					const hexValidator = (text) =>
 | 
				
			||||||
  hexPattern.test(text) ? true : 'This should be a valid hex code';
 | 
					  hexPattern.test(text) || 'This should be a valid hex code';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const sourceValidator = (text) =>
 | 
					const sourceValidator = (text) =>
 | 
				
			||||||
  URL_REGEX.test(text) ? true : 'This should be a secure URL';
 | 
					  URL_REGEX.test(text) || 'This should be a secure URL';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const hexTransformer = (text) => {
 | 
					const hexTransformer = (text) => {
 | 
				
			||||||
  const color = normalizeColor(text);
 | 
					  const color = normalizeColor(text);
 | 
				
			||||||
 | 
				
			|||||||
@ -40,9 +40,6 @@ const build = async () => {
 | 
				
			|||||||
  const escape = (value) => {
 | 
					  const escape = (value) => {
 | 
				
			||||||
    return value.replace(/(?<!\\)'/g, "\\'");
 | 
					    return value.replace(/(?<!\\)'/g, "\\'");
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
  const iconToKeyValue = (icon) => {
 | 
					 | 
				
			||||||
    return `'${icon.slug}':${iconToObject(icon)}`;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
  const licenseToObject = (license) => {
 | 
					  const licenseToObject = (license) => {
 | 
				
			||||||
    if (license === undefined) {
 | 
					    if (license === undefined) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
@ -82,7 +79,7 @@ const build = async () => {
 | 
				
			|||||||
    icons.map(async (icon) => {
 | 
					    icons.map(async (icon) => {
 | 
				
			||||||
      const filename = getIconSlug(icon);
 | 
					      const filename = getIconSlug(icon);
 | 
				
			||||||
      const svgFilepath = path.resolve(iconsDir, `${filename}.svg`);
 | 
					      const svgFilepath = path.resolve(iconsDir, `${filename}.svg`);
 | 
				
			||||||
      icon.svg = (await fs.readFile(svgFilepath, UTF8)).replace(/\r?\n/, '');
 | 
					      icon.svg = await fs.readFile(svgFilepath, UTF8);
 | 
				
			||||||
      icon.path = svgToPath(icon.svg);
 | 
					      icon.path = svgToPath(icon.svg);
 | 
				
			||||||
      icon.slug = filename;
 | 
					      icon.slug = filename;
 | 
				
			||||||
      const iconObject = iconToObject(icon);
 | 
					      const iconObject = iconToObject(icon);
 | 
				
			||||||
@ -96,11 +93,11 @@ const build = async () => {
 | 
				
			|||||||
  const iconsBarrelMjs = [];
 | 
					  const iconsBarrelMjs = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  buildIcons.sort((a, b) => collator.compare(a.icon.title, b.icon.title));
 | 
					  buildIcons.sort((a, b) => collator.compare(a.icon.title, b.icon.title));
 | 
				
			||||||
  buildIcons.forEach(({ iconObject, iconExportName }) => {
 | 
					  for (const { iconObject, iconExportName } of buildIcons) {
 | 
				
			||||||
    iconsBarrelDts.push(`export const ${iconExportName}:I;`);
 | 
					    iconsBarrelDts.push(`export const ${iconExportName}:I;`);
 | 
				
			||||||
    iconsBarrelJs.push(`${iconExportName}:${iconObject},`);
 | 
					    iconsBarrelJs.push(`${iconExportName}:${iconObject},`);
 | 
				
			||||||
    iconsBarrelMjs.push(`export const ${iconExportName}=${iconObject}`);
 | 
					    iconsBarrelMjs.push(`export const ${iconExportName}=${iconObject}`);
 | 
				
			||||||
  });
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // constants used in templates to reduce package size
 | 
					  // 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>';`;
 | 
					  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>';`;
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,7 @@
 | 
				
			|||||||
 * icon SVG filename to standard output.
 | 
					 * icon SVG filename to standard output.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import process from 'node:process';
 | 
				
			||||||
import { titleToSlug } from '../sdk.mjs';
 | 
					import { titleToSlug } from '../sdk.mjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if (process.argv.length < 3) {
 | 
					if (process.argv.length < 3) {
 | 
				
			||||||
 | 
				
			|||||||
@ -3,22 +3,18 @@
 | 
				
			|||||||
 * CLI tool to run jsonschema on the simple-icons.json data file.
 | 
					 * CLI tool to run jsonschema on the simple-icons.json data file.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import path from 'node:path';
 | 
					import process from 'node:process';
 | 
				
			||||||
import { Validator } from 'jsonschema';
 | 
					import { Validator } from 'jsonschema';
 | 
				
			||||||
import { getDirnameFromImportMeta, getIconsData } from '../../sdk.mjs';
 | 
					import { getIconsData } from '../../sdk.mjs';
 | 
				
			||||||
import { getJsonSchemaData } from '../utils.js';
 | 
					import { getJsonSchemaData } from '../utils.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const icons = await getIconsData();
 | 
					const icons = await getIconsData();
 | 
				
			||||||
const __dirname = getDirnameFromImportMeta(import.meta.url);
 | 
					const schema = await getJsonSchemaData();
 | 
				
			||||||
const schema = await getJsonSchemaData(path.resolve(__dirname, '..', '..'));
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const validator = new Validator();
 | 
					const validator = new Validator();
 | 
				
			||||||
const result = validator.validate({ icons }, schema);
 | 
					const result = validator.validate({ icons }, schema);
 | 
				
			||||||
if (result.errors.length > 0) {
 | 
					if (result.errors.length > 0) {
 | 
				
			||||||
  result.errors.forEach((error) => {
 | 
					  result.errors.forEach((error) => console.error(error));
 | 
				
			||||||
    console.error(error);
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  console.error(`Found ${result.errors.length} error(s) in simple-icons.json`);
 | 
					  console.error(`Found ${result.errors.length} error(s) in simple-icons.json`);
 | 
				
			||||||
  process.exit(1);
 | 
					  process.exit(1);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,7 @@
 | 
				
			|||||||
 * linters (e.g. jsonlint/svglint).
 | 
					 * linters (e.g. jsonlint/svglint).
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import process from 'node:process';
 | 
				
			||||||
import { URL } from 'node:url';
 | 
					import { URL } from 'node:url';
 | 
				
			||||||
import fakeDiff from 'fake-diff';
 | 
					import fakeDiff from 'fake-diff';
 | 
				
			||||||
import { getIconsDataString, normalizeNewlines, collator } from '../../sdk.mjs';
 | 
					import { getIconsDataString, normalizeNewlines, collator } from '../../sdk.mjs';
 | 
				
			||||||
@ -46,7 +47,7 @@ const TESTS = {
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /* Check the formatting of the data file */
 | 
					  /* Check the formatting of the data file */
 | 
				
			||||||
  prettified: async (data, dataString) => {
 | 
					  prettified: (data, dataString) => {
 | 
				
			||||||
    const normalizedDataString = normalizeNewlines(dataString);
 | 
					    const normalizedDataString = normalizeNewlines(dataString);
 | 
				
			||||||
    const dataPretty = `${JSON.stringify(data, null, 4)}\n`;
 | 
					    const dataPretty = `${JSON.stringify(data, null, 4)}\n`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -66,8 +67,7 @@ const TESTS = {
 | 
				
			|||||||
    const allUrlFields = [
 | 
					    const allUrlFields = [
 | 
				
			||||||
      ...new Set(
 | 
					      ...new Set(
 | 
				
			||||||
        data.icons
 | 
					        data.icons
 | 
				
			||||||
          .map((icon) => [icon.source, icon.guidelines, icon.license?.url])
 | 
					          .flatMap((icon) => [icon.source, icon.guidelines, icon.license?.url])
 | 
				
			||||||
          .flat()
 | 
					 | 
				
			||||||
          .filter(Boolean),
 | 
					          .filter(Boolean),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
@ -84,19 +84,14 @@ const TESTS = {
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// execute all tests and log all errors
 | 
					const dataString = await getIconsDataString();
 | 
				
			||||||
(async () => {
 | 
					const data = JSON.parse(dataString);
 | 
				
			||||||
  const dataString = await getIconsDataString();
 | 
					 | 
				
			||||||
  const data = JSON.parse(dataString);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const errors = (
 | 
					const errors = (
 | 
				
			||||||
    await Promise.all(
 | 
					  await Promise.all(Object.values(TESTS).map((test) => test(data, dataString)))
 | 
				
			||||||
      Object.keys(TESTS).map((test) => TESTS[test](data, dataString)),
 | 
					).filter(Boolean);
 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  ).filter(Boolean);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (errors.length > 0) {
 | 
					if (errors.length > 0) {
 | 
				
			||||||
  errors.forEach((error) => console.error(`\u001b[31m${error}\u001b[0m`));
 | 
					  errors.forEach((error) => console.error(`\u001b[31m${error}\u001b[0m`));
 | 
				
			||||||
  process.exit(1);
 | 
					  process.exit(1);
 | 
				
			||||||
  }
 | 
					}
 | 
				
			||||||
})();
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,8 @@
 | 
				
			|||||||
 * NPM package manifest. Does nothing if the README.md is already up-to-date.
 | 
					 * NPM package manifest. Does nothing if the README.md is already up-to-date.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import fs from 'node:fs';
 | 
					import process from 'node:process';
 | 
				
			||||||
 | 
					import fs from 'node:fs/promises';
 | 
				
			||||||
import path from 'node:path';
 | 
					import path from 'node:path';
 | 
				
			||||||
import { getDirnameFromImportMeta } from '../../sdk.mjs';
 | 
					import { getDirnameFromImportMeta } from '../../sdk.mjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -19,31 +20,31 @@ const getMajorVersion = (semVerVersion) => {
 | 
				
			|||||||
  return parseInt(majorVersionAsString);
 | 
					  return parseInt(majorVersionAsString);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const getManifest = () => {
 | 
					const getManifest = async () => {
 | 
				
			||||||
  const manifestRaw = fs.readFileSync(packageJsonFile, 'utf-8');
 | 
					  const manifestRaw = await fs.readFile(packageJsonFile, 'utf-8');
 | 
				
			||||||
  return JSON.parse(manifestRaw);
 | 
					  return JSON.parse(manifestRaw);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const updateVersionInReadmeIfNecessary = (majorVersion) => {
 | 
					const updateVersionInReadmeIfNecessary = async (majorVersion) => {
 | 
				
			||||||
  let content = fs.readFileSync(readmeFile).toString();
 | 
					  let content = await fs.readFile(readmeFile, 'utf8');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  content = content.replace(
 | 
					  content = content.replace(
 | 
				
			||||||
    /simple-icons@v[0-9]+/g,
 | 
					    /simple-icons@v[0-9]+/g,
 | 
				
			||||||
    `simple-icons@v${majorVersion}`,
 | 
					    `simple-icons@v${majorVersion}`,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  fs.writeFileSync(readmeFile, content);
 | 
					  await fs.writeFile(readmeFile, content);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const main = () => {
 | 
					const main = async () => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const manifest = getManifest();
 | 
					    const manifest = await getManifest();
 | 
				
			||||||
    const majorVersion = getMajorVersion(manifest.version);
 | 
					    const majorVersion = getMajorVersion(manifest.version);
 | 
				
			||||||
    updateVersionInReadmeIfNecessary(majorVersion);
 | 
					    await updateVersionInReadmeIfNecessary(majorVersion);
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error('Failed to update CDN version number:', error);
 | 
					    console.error('Failed to update CDN version number:', error);
 | 
				
			||||||
    process.exit(1);
 | 
					    process.exit(1);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
main();
 | 
					await main();
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,7 @@
 | 
				
			|||||||
 * to match the current definitions of functions of sdk.mjs.
 | 
					 * to match the current definitions of functions of sdk.mjs.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import fsSync from 'node:fs';
 | 
					import process from 'node:process';
 | 
				
			||||||
import fs from 'node:fs/promises';
 | 
					import fs from 'node:fs/promises';
 | 
				
			||||||
import path from 'node:path';
 | 
					import path from 'node:path';
 | 
				
			||||||
import { execSync } from 'node:child_process';
 | 
					import { execSync } from 'node:child_process';
 | 
				
			||||||
@ -45,7 +45,11 @@ const generateSdkMts = async () => {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const generateSdkTs = async () => {
 | 
					const generateSdkTs = async () => {
 | 
				
			||||||
  fsSync.existsSync(sdkMts) && (await fs.unlink(sdkMts));
 | 
					  const fileExists = await fs
 | 
				
			||||||
 | 
					    .access(sdkMts)
 | 
				
			||||||
 | 
					    .then(() => true)
 | 
				
			||||||
 | 
					    .catch(() => false);
 | 
				
			||||||
 | 
					  fileExists && (await fs.unlink(sdkMts));
 | 
				
			||||||
  await generateSdkMts();
 | 
					  await generateSdkMts();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const autogeneratedMsg = '/* The next code is autogenerated from sdk.mjs */';
 | 
					  const autogeneratedMsg = '/* The next code is autogenerated from sdk.mjs */';
 | 
				
			||||||
 | 
				
			|||||||
@ -25,14 +25,10 @@ update the script at '${path.relative(rootDir, __filename)}'.
 | 
				
			|||||||
| :--- | :--- |
 | 
					| :--- | :--- |
 | 
				
			||||||
`;
 | 
					`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(async () => {
 | 
					const icons = await getIconsData();
 | 
				
			||||||
  const icons = await getIconsData();
 | 
					for (const icon of icons) {
 | 
				
			||||||
 | 
					 | 
				
			||||||
  icons.forEach((icon) => {
 | 
					 | 
				
			||||||
  const brandName = icon.title;
 | 
					  const brandName = icon.title;
 | 
				
			||||||
  const brandSlug = getIconSlug(icon);
 | 
					  const brandSlug = getIconSlug(icon);
 | 
				
			||||||
  content += `| \`${brandName}\` | \`${brandSlug}\` |\n`;
 | 
					  content += `| \`${brandName}\` | \`${brandSlug}\` |\n`;
 | 
				
			||||||
  });
 | 
					}
 | 
				
			||||||
 | 
					await fs.writeFile(slugsFile, content);
 | 
				
			||||||
  await fs.writeFile(slugsFile, content);
 | 
					 | 
				
			||||||
})();
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,9 @@
 | 
				
			|||||||
 * at README every time the number of current icons is more than `updateRange`
 | 
					 * at README every time the number of current icons is more than `updateRange`
 | 
				
			||||||
 * more than the previous milestone.
 | 
					 * more than the previous milestone.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
import { promises as fs } from 'node:fs';
 | 
					
 | 
				
			||||||
 | 
					import process from 'node:process';
 | 
				
			||||||
 | 
					import fs from 'node:fs/promises';
 | 
				
			||||||
import path from 'node:path';
 | 
					import path from 'node:path';
 | 
				
			||||||
import { getDirnameFromImportMeta, getIconsData } from '../../sdk.mjs';
 | 
					import { getDirnameFromImportMeta, getIconsData } from '../../sdk.mjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -12,31 +14,26 @@ const regexMatcher = /Over\s(\d+)\s/;
 | 
				
			|||||||
const updateRange = 100;
 | 
					const updateRange = 100;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const __dirname = getDirnameFromImportMeta(import.meta.url);
 | 
					const __dirname = getDirnameFromImportMeta(import.meta.url);
 | 
				
			||||||
 | 
					 | 
				
			||||||
const rootDir = path.resolve(__dirname, '..', '..');
 | 
					const rootDir = path.resolve(__dirname, '..', '..');
 | 
				
			||||||
const readmeFile = path.resolve(rootDir, 'README.md');
 | 
					const readmeFile = path.resolve(rootDir, 'README.md');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(async () => {
 | 
					const readmeContent = await fs.readFile(readmeFile, 'utf-8');
 | 
				
			||||||
  const readmeContent = await fs.readFile(readmeFile, 'utf-8');
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let overNIconsInReadme;
 | 
					let overNIconsInReadme;
 | 
				
			||||||
  try {
 | 
					try {
 | 
				
			||||||
  overNIconsInReadme = parseInt(regexMatcher.exec(readmeContent)[1]);
 | 
					  overNIconsInReadme = parseInt(regexMatcher.exec(readmeContent)[1]);
 | 
				
			||||||
  } catch (err) {
 | 
					} catch (err) {
 | 
				
			||||||
  console.error(
 | 
					  console.error(
 | 
				
			||||||
    'Failed to obtain number of SVG icons of current milestone in README:',
 | 
					    'Failed to obtain number of SVG icons of current milestone in README:',
 | 
				
			||||||
    err,
 | 
					    err,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
  process.exit(1);
 | 
					  process.exit(1);
 | 
				
			||||||
  }
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const nIcons = (await getIconsData()).length;
 | 
					const nIcons = (await getIconsData()).length;
 | 
				
			||||||
  const newNIcons = overNIconsInReadme + updateRange;
 | 
					const newNIcons = overNIconsInReadme + updateRange;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (nIcons <= newNIcons) {
 | 
					 | 
				
			||||||
    process.exit(0);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if (nIcons > newNIcons) {
 | 
				
			||||||
  const newContent = readmeContent.replace(regexMatcher, `Over ${newNIcons} `);
 | 
					  const newContent = readmeContent.replace(regexMatcher, `Over ${newNIcons} `);
 | 
				
			||||||
  await fs.writeFile(readmeFile, newContent);
 | 
					  await fs.writeFile(readmeFile, newContent);
 | 
				
			||||||
})();
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -6,7 +6,7 @@ const __dirname = getDirnameFromImportMeta(import.meta.url);
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Get JSON schema data.
 | 
					 * Get JSON schema data.
 | 
				
			||||||
 * @param {String|undefined} rootDir Path to the root directory of the project.
 | 
					 * @param {String} rootDir Path to the root directory of the project.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const getJsonSchemaData = async (
 | 
					export const getJsonSchemaData = async (
 | 
				
			||||||
  rootDir = path.resolve(__dirname, '..'),
 | 
					  rootDir = path.resolve(__dirname, '..'),
 | 
				
			||||||
@ -19,13 +19,13 @@ export const getJsonSchemaData = async (
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * Write icons data to _data/simple-icons.json.
 | 
					 * Write icons data to _data/simple-icons.json.
 | 
				
			||||||
 * @param {Object} iconsData Icons data object.
 | 
					 * @param {Object} iconsData Icons data object.
 | 
				
			||||||
 * @param {String|undefined} rootDir Path to the root directory of the project.
 | 
					 * @param {String} rootDir Path to the root directory of the project.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const writeIconsData = async (
 | 
					export const writeIconsData = async (
 | 
				
			||||||
  iconsData,
 | 
					  iconsData,
 | 
				
			||||||
  rootDir = path.resolve(__dirname, '..'),
 | 
					  rootDir = path.resolve(__dirname, '..'),
 | 
				
			||||||
) => {
 | 
					) => {
 | 
				
			||||||
  return fs.writeFile(
 | 
					  await fs.writeFile(
 | 
				
			||||||
    getIconDataPath(rootDir),
 | 
					    getIconDataPath(rootDir),
 | 
				
			||||||
    `${JSON.stringify(iconsData, null, 4)}\n`,
 | 
					    `${JSON.stringify(iconsData, null, 4)}\n`,
 | 
				
			||||||
    'utf8',
 | 
					    'utf8',
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										1
									
								
								sdk.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								sdk.d.ts
									
									
									
									
										vendored
									
									
								
							@ -62,6 +62,7 @@ export type IconData = {
 | 
				
			|||||||
/* The next code is autogenerated from sdk.mjs */
 | 
					/* The next code is autogenerated from sdk.mjs */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const URL_REGEX: RegExp;
 | 
					export const URL_REGEX: RegExp;
 | 
				
			||||||
 | 
					export const SVG_PATH_REGEX: RegExp;
 | 
				
			||||||
export function getDirnameFromImportMeta(importMetaUrl: string): string;
 | 
					export function getDirnameFromImportMeta(importMetaUrl: string): string;
 | 
				
			||||||
export function getIconSlug(icon: IconData): string;
 | 
					export function getIconSlug(icon: IconData): string;
 | 
				
			||||||
export function svgToPath(svg: string): string;
 | 
					export function svgToPath(svg: string): string;
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										16
									
								
								sdk.mjs
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								sdk.mjs
									
									
									
									
									
								
							@ -36,7 +36,12 @@ const TITLE_TO_SLUG_RANGE_REGEX = /[^a-z0-9]/g;
 | 
				
			|||||||
/**
 | 
					/**
 | 
				
			||||||
 * Regex to validate HTTPs URLs.
 | 
					 * Regex to validate HTTPs URLs.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const URL_REGEX = /^https:\/\/[^\s]+$/;
 | 
					export const URL_REGEX = /^https:\/\/[^\s"']+$/;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Regex to validate SVG paths.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export const SVG_PATH_REGEX = /^m[-mzlhvcsqtae0-9,. ]+$/i;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Get the directory name where this file is located from `import.meta.url`,
 | 
					 * Get the directory name where this file is located from `import.meta.url`,
 | 
				
			||||||
@ -59,7 +64,7 @@ export const getIconSlug = (icon) => icon.slug || titleToSlug(icon.title);
 | 
				
			|||||||
 * @param {String} svg The icon SVG content
 | 
					 * @param {String} svg The icon SVG content
 | 
				
			||||||
 * @returns {String} The path from the icon SVG content
 | 
					 * @returns {String} The path from the icon SVG content
 | 
				
			||||||
 **/
 | 
					 **/
 | 
				
			||||||
export const svgToPath = (svg) => svg.match(/<path\s+d="([^"]*)/)[1];
 | 
					export const svgToPath = (svg) => svg.split('"', 8)[7];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Converts a brand title into a slug/filename.
 | 
					 * Converts a brand title into a slug/filename.
 | 
				
			||||||
@ -83,8 +88,7 @@ export const titleToSlug = (title) =>
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
export const slugToVariableName = (slug) => {
 | 
					export const slugToVariableName = (slug) => {
 | 
				
			||||||
  const slugFirstLetter = slug[0].toUpperCase();
 | 
					  const slugFirstLetter = slug[0].toUpperCase();
 | 
				
			||||||
  const slugRest = slug.slice(1);
 | 
					  return `si${slugFirstLetter}${slug.slice(1)}`;
 | 
				
			||||||
  return `si${slugFirstLetter}${slugRest}`;
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
@ -189,13 +193,11 @@ export const getThirdPartyExtensions = async (
 | 
				
			|||||||
) =>
 | 
					) =>
 | 
				
			||||||
  normalizeNewlines(await fs.readFile(readmePath, 'utf8'))
 | 
					  normalizeNewlines(await fs.readFile(readmePath, 'utf8'))
 | 
				
			||||||
    .split('## Third-Party Extensions\n\n')[1]
 | 
					    .split('## Third-Party Extensions\n\n')[1]
 | 
				
			||||||
    .split('\n\n')[0]
 | 
					    .split('\n\n', 1)[0]
 | 
				
			||||||
    .split('\n')
 | 
					    .split('\n')
 | 
				
			||||||
    .slice(2)
 | 
					    .slice(2)
 | 
				
			||||||
    .map((line) => {
 | 
					    .map((line) => {
 | 
				
			||||||
      let [module, author] = line.split(' | ');
 | 
					      let [module, author] = line.split(' | ');
 | 
				
			||||||
 | 
					 | 
				
			||||||
      // README shipped with package has not Github theme image links
 | 
					 | 
				
			||||||
      module = module.split('<img src="')[0];
 | 
					      module = module.split('<img src="')[0];
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        module: {
 | 
					        module: {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +1,22 @@
 | 
				
			|||||||
import fs from 'node:fs';
 | 
					import fs from 'node:fs/promises';
 | 
				
			||||||
import path from 'node:path';
 | 
					import path from 'node:path';
 | 
				
			||||||
import { describe, test } from 'mocha';
 | 
					import { test } from 'mocha';
 | 
				
			||||||
import { strict as assert } from 'node:assert';
 | 
					import { strict as assert } from 'node:assert';
 | 
				
			||||||
import { getThirdPartyExtensions, getDirnameFromImportMeta } from '../sdk.mjs';
 | 
					import {
 | 
				
			||||||
 | 
					  getThirdPartyExtensions,
 | 
				
			||||||
 | 
					  getDirnameFromImportMeta,
 | 
				
			||||||
 | 
					  URL_REGEX,
 | 
				
			||||||
 | 
					} from '../sdk.mjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const __dirname = getDirnameFromImportMeta(import.meta.url);
 | 
					const __dirname = getDirnameFromImportMeta(import.meta.url);
 | 
				
			||||||
const root = path.dirname(__dirname);
 | 
					const root = path.dirname(__dirname);
 | 
				
			||||||
 | 
					const getLinksRegex = new RegExp(
 | 
				
			||||||
 | 
					  URL_REGEX.source.replace('^https', 'https?'),
 | 
				
			||||||
 | 
					  'gm',
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('README third party extensions must be alphabetically sorted', async () => {
 | 
					test('README third party extensions must be alphabetically sorted', async () => {
 | 
				
			||||||
  const readmePath = path.join(root, 'README.md');
 | 
					  const thirdPartyExtensions = await getThirdPartyExtensions();
 | 
				
			||||||
  const thirdPartyExtensions = await getThirdPartyExtensions(readmePath);
 | 
					 | 
				
			||||||
  assert.ok(thirdPartyExtensions.length > 0);
 | 
					  assert.ok(thirdPartyExtensions.length > 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const thirdPartyExtensionsNames = thirdPartyExtensions.map(
 | 
					  const thirdPartyExtensionsNames = thirdPartyExtensions.map(
 | 
				
			||||||
@ -27,22 +34,21 @@ test('README third party extensions must be alphabetically sorted', async () =>
 | 
				
			|||||||
test('Only allow HTTPS links in documentation pages', async () => {
 | 
					test('Only allow HTTPS links in documentation pages', async () => {
 | 
				
			||||||
  const ignoreHttpLinks = ['http://www.w3.org/2000/svg'];
 | 
					  const ignoreHttpLinks = ['http://www.w3.org/2000/svg'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const docsFiles = fs
 | 
					  const docsFiles = (await fs.readdir(root)).filter((fname) =>
 | 
				
			||||||
    .readdirSync(root)
 | 
					    fname.endsWith('.md'),
 | 
				
			||||||
    .filter((fname) => fname.endsWith('.md'));
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const linksGetter = new RegExp('http://[^\\s"\']+', 'g');
 | 
					  for (const docsFile of docsFiles) {
 | 
				
			||||||
  for (let docsFile of docsFiles) {
 | 
					 | 
				
			||||||
    const docsFilePath = path.join(root, docsFile);
 | 
					    const docsFilePath = path.join(root, docsFile);
 | 
				
			||||||
    const docsFileContent = fs.readFileSync(docsFilePath, 'utf8');
 | 
					    const docsFileContent = await fs.readFile(docsFilePath, 'utf8');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Array.from(docsFileContent.matchAll(linksGetter)).forEach((match) => {
 | 
					    for (const match of docsFileContent.matchAll(getLinksRegex)) {
 | 
				
			||||||
      const link = match[0];
 | 
					      const link = match[0];
 | 
				
			||||||
      assert.ok(
 | 
					      assert.ok(
 | 
				
			||||||
        ignoreHttpLinks.includes(link) || link.startsWith('https://'),
 | 
					        ignoreHttpLinks.includes(link) || link.startsWith('https://'),
 | 
				
			||||||
        `Link '${link}' in '${docsFile}' (at index ${match.index})` +
 | 
					        `Link '${link}' in '${docsFile}' (at index ${match.index})` +
 | 
				
			||||||
          ` must use the HTTPS protocol.`,
 | 
					          ` must use the HTTPS protocol.`,
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    });
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
@ -2,14 +2,10 @@ import { getIconsData, getIconSlug, slugToVariableName } from '../sdk.mjs';
 | 
				
			|||||||
import * as simpleIcons from '../index.mjs';
 | 
					import * as simpleIcons from '../index.mjs';
 | 
				
			||||||
import { testIcon } from './test-icon.js';
 | 
					import { testIcon } from './test-icon.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(async () => {
 | 
					for (const icon of await getIconsData()) {
 | 
				
			||||||
  const icons = await getIconsData();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  icons.map((icon) => {
 | 
					 | 
				
			||||||
  const slug = getIconSlug(icon);
 | 
					  const slug = getIconSlug(icon);
 | 
				
			||||||
  const variableName = slugToVariableName(slug);
 | 
					  const variableName = slugToVariableName(slug);
 | 
				
			||||||
  const subject = simpleIcons[variableName];
 | 
					  const subject = simpleIcons[variableName];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  testIcon(icon, subject, slug);
 | 
					  testIcon(icon, subject, slug);
 | 
				
			||||||
  });
 | 
					}
 | 
				
			||||||
})();
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +1,28 @@
 | 
				
			|||||||
import fs from 'node:fs';
 | 
					import fs from 'node:fs/promises';
 | 
				
			||||||
import path from 'node:path';
 | 
					import path from 'node:path';
 | 
				
			||||||
import { strict as assert } from 'node:assert';
 | 
					import { strict as assert } from 'node:assert';
 | 
				
			||||||
import { describe, it } from 'mocha';
 | 
					import { describe, it } from 'mocha';
 | 
				
			||||||
import { URL_REGEX, titleToSlug } from '../sdk.mjs';
 | 
					import {
 | 
				
			||||||
 | 
					  SVG_PATH_REGEX,
 | 
				
			||||||
 | 
					  URL_REGEX,
 | 
				
			||||||
 | 
					  getDirnameFromImportMeta,
 | 
				
			||||||
 | 
					  titleToSlug,
 | 
				
			||||||
 | 
					} from '../sdk.mjs';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const iconsDir = path.resolve(process.cwd(), 'icons');
 | 
					const iconsDir = path.resolve(
 | 
				
			||||||
 | 
					  getDirnameFromImportMeta(import.meta.url),
 | 
				
			||||||
 | 
					  '..',
 | 
				
			||||||
 | 
					  'icons',
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * @typedef {import('..').SimpleIcon} SimpleIcon
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Checks if icon data matches a subject icon.
 | 
					 * Checks if icon data matches a subject icon.
 | 
				
			||||||
 * @param {import('..').SimpleIcon} icon Icon data
 | 
					 * @param {SimpleIcon} icon Icon data
 | 
				
			||||||
 * @param {import('..').SimpleIcon} subject Icon to check against icon data
 | 
					 * @param {SimpleIcon} subject Icon to check against icon data
 | 
				
			||||||
 * @param {String} slug Icon data slug
 | 
					 * @param {String} slug Icon data slug
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const testIcon = (icon, subject, slug) => {
 | 
					export const testIcon = (icon, subject, slug) => {
 | 
				
			||||||
@ -38,7 +51,7 @@ export const testIcon = (icon, subject, slug) => {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('has a valid "path" value', () => {
 | 
					    it('has a valid "path" value', () => {
 | 
				
			||||||
      assert.match(subject.path, /^[MmZzLlHhVvCcSsQqTtAaEe0-9-,.\s]+$/g);
 | 
					      assert.match(subject.path, SVG_PATH_REGEX);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it(`has ${icon.guidelines ? 'the correct' : 'no'} "guidelines"`, () => {
 | 
					    it(`has ${icon.guidelines ? 'the correct' : 'no'} "guidelines"`, () => {
 | 
				
			||||||
@ -62,8 +75,8 @@ export const testIcon = (icon, subject, slug) => {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('has a valid svg value', () => {
 | 
					    it('has a valid svg value', async () => {
 | 
				
			||||||
      const svgFileContents = fs.readFileSync(svgPath, 'utf8');
 | 
					      const svgFileContents = await fs.readFile(svgPath, 'utf8');
 | 
				
			||||||
      assert.equal(subject.svg, svgFileContents);
 | 
					      assert.equal(subject.svg, svgFileContents);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user