Skip to content

Commit

Permalink
feat: Custom getIconID option
Browse files Browse the repository at this point in the history
- Configurable `getIconId` option
  • Loading branch information
tancredi committed May 15, 2021
2 parents 2793c78 + 4f128b0 commit 87f8157
Show file tree
Hide file tree
Showing 18 changed files with 258 additions and 55 deletions.
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ fantasticon my-icons -o icon-dist

### Command-line

**Note:** Not all options can be specified through the command line - for `formatOptions`, `pathOptions` and `templates` use a [configuration file](#configuration-file) or the JavaScript API.
**Note:** Not all options can be specified through the command line - for `formatOptions`, `pathOptions`, `getIconId` and `templates` use a [configuration file](#configuration-file) or the JavaScript [API](#api).

```
Usage: fantasticon [options] [input-dir]
Expand All @@ -62,7 +62,7 @@ Options:

### Configuration file

Some options (specifically, `formatOptions` and `pathOptions`) cannot be passed to the CLI directly.
Some options (specifically, `formatOptions`, `pathOptions` and `getIconId`) cannot be passed to the CLI directly.

To have more control and better readability, you can create a simple configuration file.

Expand Down Expand Up @@ -113,7 +113,15 @@ module.exports = {
'chevron-right': 57345,
'thumbs-up': 57358,
'thumbs-down': 57359
}
},
// Customize generated icon IDs (unavailable with `.json` config file)
getIconId: ({
basename, // `string` - Example: 'foo';
relativeDirPath, // `string` - Example: 'sub/dir/foo.svg'
absoluteFilePath, // `string` - Example: '/var/icons/sub/dir/foo.svg'
relativeFilePath, // `string` - Example: 'foo.svg'
index // `number` - Example: `0`
}) => [index, basename].join('_') // '0_foo'
};
```

Expand Down Expand Up @@ -186,6 +194,8 @@ And the generated icon IDs would be:
| `symbol-chevron-left` | `.icon.icon-chevron-left` |
| `symbol-chevron-right` | `.icon.icon-chevron-right` |

You can provide a `getIconId` function via the configuration file to customize how the icon IDs / CSS selectors are derived from the filepath. The function will receive relative paths to the icon and the input directory as arguments, and must return a unique string to be used as the ID.

### Contribute

PRs are always welcome. If you need help questions, want to bounce ideas or just say hi, [join the Discord channel](https://discord.gg/BXAY3Kc3mp).
Expand Down
10 changes: 5 additions & 5 deletions src/__mocks__/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const resolve = (...paths: string[]) => {
let path = '';

for (const cur of paths) {
path = !path ? normalise(cur) : _path.join(path, normalise(cur));
path = !path ? normalize(cur) : _path.join(path, normalize(cur));
}

if (path.startsWith(projectDir)) {
Expand All @@ -22,11 +22,11 @@ const resolve = (...paths: string[]) => {
}
}

return normalise(path);
return normalize(path);
};

const relative = (a: string, b: string) =>
normalise(_relative(_path.normalize(a), _path.normalize(b)));
normalize(_relative(_path.normalize(a), _path.normalize(b)));

const join = (...segments: string[]): string => {
const trimmed: string[] = [];
Expand All @@ -49,8 +49,8 @@ const join = (...segments: string[]): string => {
return trimmed.join('/');
};

const normalise = (path: string) => path.replace(/\\/g, '/');
const normalize = (path: string) => path.replace(/\\/g, '/');

const isAbsolute = (path: string) => path.startsWith('/root');

module.exports = { resolve, relative, join, isAbsolute };
module.exports = { resolve, relative, join, isAbsolute, normalize };
4 changes: 3 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolve } from 'path';
import { RunnerOptions } from './types/runner';
import { FontAssetType, OtherAssetType } from './types/misc';
import { getIconId } from './utils/icon-id';

export const TEMPLATES_DIR = resolve(__dirname, '../templates');

Expand All @@ -24,7 +25,8 @@ export const DEFAULT_OPTIONS: Omit<RunnerOptions, 'inputDir' | 'outputDir'> = {
selector: null,
tag: 'i',
prefix: 'icon',
fontsUrl: undefined
fontsUrl: undefined,
getIconId: getIconId
};

export const DEFAULT_START_CODEPOINT = 0xf101;
5 changes: 4 additions & 1 deletion src/core/__tests__/config-parser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parseConfig } from '../config-parser';
import { checkPath } from '../../utils/fs-async';
import { DEFAULT_OPTIONS } from '../../constants';

const checkPathMock = (checkPath as any) as jest.Mock;

Expand Down Expand Up @@ -32,7 +33,8 @@ const mockConfig = {
sass: 'sass',
scss: 'scss',
html: 'html'
}
},
getIconId: DEFAULT_OPTIONS.getIconId
};

const testError = async (options: object, key: string, message: string) =>
Expand Down Expand Up @@ -87,6 +89,7 @@ describe('Config parser', () => {
'normalize',
'must be a boolean value'
);
await testError({ getIconId: true }, 'getIconId', 'true is not a function');
});

test('correctly validates existance of input and output paths', async () => {
Expand Down
6 changes: 5 additions & 1 deletion src/core/__tests__/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ describe('Runner', () => {
await generateFonts(optionsIn);

expect(loadAssetsMock).toHaveBeenCalledTimes(1);
expect(loadAssetsMock).toHaveBeenCalledWith(inputDir);
expect(loadAssetsMock).toHaveBeenCalledWith({
...DEFAULT_OPTIONS,
...optionsIn,
parsed: true
});
});

test('`generateFonts` calls `getGeneratorOptions` correctly', async () => {
Expand Down
4 changes: 3 additions & 1 deletion src/core/config-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseDir,
parseString,
parseBoolean,
parseFunction,
listMembersParser,
parseNumeric,
optional,
Expand All @@ -30,7 +31,8 @@ const CONFIG_VALIDATORS: {
selector: [nullable(parseString)],
tag: [parseString],
prefix: [parseString],
fontsUrl: [optional(parseString)]
fontsUrl: [optional(parseString)],
getIconId: [optional(parseFunction)]
};

export const parseConfig = async (input: object = {}) => {
Expand Down
2 changes: 1 addition & 1 deletion src/core/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const generateFonts = async (
throw new Error('You must specify an output directory');
}

const assetsIn = await loadAssets(options.inputDir);
const assetsIn = await loadAssets(options);
const generatorOptions = getGeneratorOptions(options, assetsIn);
const assetsOut = await generateAssets(generatorOptions);
const writeResults = outputDir ? await writeAssets(assetsOut, options) : [];
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ export {
FontAssetType,
OtherAssetType,
ASSET_TYPES,
AssetType
AssetType,
GetIconIdFn,
GetIconIdOptions
} from './types/misc';

export { RunnerOptions } from './types/runner';
Expand Down
10 changes: 10 additions & 0 deletions src/types/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,13 @@ export const ASSET_TYPES_WITH_TEMPLATE = [
export const ASSET_TYPES = { ...FontAssetType, ...OtherAssetType };

export type AssetType = FontAssetType | OtherAssetType;

export interface GetIconIdOptions {
basename: string;
relativeDirPath: string;
absoluteFilePath: string;
relativeFilePath: string;
index: number;
}

export type GetIconIdFn = (options: GetIconIdOptions) => string;
3 changes: 2 additions & 1 deletion src/types/runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CodepointsMap } from '../utils/codepoints';
import { FontAssetType, OtherAssetType, AssetType } from './misc';
import { FontAssetType, OtherAssetType, AssetType, GetIconIdFn } from './misc';
import { FormatOptions } from './format';

export interface RunnerMandatoryOptions {
Expand All @@ -23,6 +23,7 @@ export type RunnerOptionalOptions = {
templates: { [key in OtherAssetType]?: string };
prefix: string;
fontsUrl: string;
getIconId: GetIconIdFn;
};

export type RunnerOptionsInput = RunnerMandatoryOptions &
Expand Down
84 changes: 83 additions & 1 deletion src/utils/__tests__/assets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { loadPaths, loadAssets, writeAssets } from '../assets';
import { GetIconIdFn } from '../../types/misc';
import { DEFAULT_OPTIONS } from '../../constants';
import { writeFile } from '../fs-async';

const writeFileMock = (writeFile as any) as jest.Mock;
Expand Down Expand Up @@ -49,7 +51,13 @@ describe('Assets utilities', () => {
});

test('`loadAssets` resolves a key - value map of assets with expected properties', async () => {
expect(await loadAssets('./valid')).toEqual({
expect(
await loadAssets({
...DEFAULT_OPTIONS,
inputDir: './valid',
outputDir: './output'
})
).toEqual({
foo: {
relativePath: 'foo.svg',
absolutePath: '/root/project/valid/foo.svg',
Expand All @@ -73,6 +81,80 @@ describe('Assets utilities', () => {
});
});

test('`loadAssets` with a custom `getIconId` implementation resolves a key - value map of assets with expected properties', async () => {
const getIconId: GetIconIdFn = jest.fn(({ relativeFilePath, index }) => {
return `${index}_${relativeFilePath
.replace('.svg', '')
.split('/')
.join('_')}`;
});

expect(
await loadAssets({
...DEFAULT_OPTIONS,
inputDir: './valid',
outputDir: './output',
getIconId: getIconId
})
).toEqual({
'0_foo': {
relativePath: 'foo.svg',
absolutePath: '/root/project/valid/foo.svg',
id: '0_foo'
},
'1_bar': {
relativePath: 'bar.svg',
absolutePath: '/root/project/valid/bar.svg',
id: '1_bar'
},
'2_sub_nested': {
relativePath: 'sub/nested.svg',
absolutePath: '/root/project/valid/sub/nested.svg',
id: '2_sub_nested'
},
'3_sub_sub_nested': {
relativePath: 'sub/sub/nested.svg',
absolutePath: '/root/project/valid/sub/sub/nested.svg',
id: '3_sub_sub_nested'
}
});

expect(getIconId).toHaveBeenCalledTimes(4);

expect(getIconId).toHaveBeenNthCalledWith(1, {
basename: 'foo',
relativeDirPath: '',
absoluteFilePath: '/root/project/valid/foo.svg',
relativeFilePath: 'foo.svg',
index: 0
});

expect(getIconId).toHaveBeenNthCalledWith(4, {
basename: 'nested',
relativeDirPath: 'sub/sub',
absoluteFilePath: '/root/project/valid/sub/sub/nested.svg',
relativeFilePath: 'sub/sub/nested.svg',
index: 3
});
});

test('`loadAssets` will throw expected error if `getIconId` resolves the same `key` while processing different assets', async () => {
const getIconId: GetIconIdFn = () => 'xxx';

await expect(() =>
loadAssets({
...DEFAULT_OPTIONS,
inputDir: './valid',
outputDir: './output',
getIconId: getIconId
})
).rejects.toEqual(
new Error(
"Conflicting result from 'getIconId': 'xxx' - conflicting input files:\n - foo.svg\n - bar.svg"
)
);
});

test('`writeAssets` calls writeFile for each given asset with correctly formed filepath and content', async () => {
await writeAssets(
{ svg: '::svg-content::', foo: '::foo-content::' } as any,
Expand Down
36 changes: 25 additions & 11 deletions src/utils/__tests__/icon-id.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
import { getIconId } from '../icon-id';
import { GetIconIdOptions } from '../../types/misc';

const EMPTY_OPTIONS: GetIconIdOptions = {
basename: '',
relativeFilePath: '',
absoluteFilePath: '',
relativeDirPath: '',
index: 0
};

describe('Icon ID utilities', () => {
test('`getIconId` produces correcty icon IDs from given filepaths', () => {
expect(getIconId('./foo/bar/icon.svg', 'foo/bar')).toBe('icon');
expect(getIconId('foo/bar/icon.svg', './foo')).toBe('bar-icon');
expect(getIconId('foo/icon.SVG', 'foo')).toBe('icon');
expect(getIconId('foo/icon-test_1_a', 'foo')).toBe('icon-test_1_a');
expect(getIconId('foo/ICON-test', 'foo')).toBe('ICON-test');
expect(getIconId('foo/test/icon-test.foo.Sv g', 'foo')).toBe(
'test-icon-test-foo'
);
});
test.each([
['./foo/bar/icon.svg', 'icon'],
['foo/bar/icon.svg', 'bar-icon'],
['foo/icon.SVG', 'icon'],
['foo/icon-test_1_a', 'icon-test_1_a'],
['foo/ICON-test', 'ICON-test'],
['foo/test/icon-test.foo.Sv g', 'test-icon-test-foo']
])(
'`getIconId` produces correcty icon IDs from given filepaths - relative file path: %s, result: %s',
relativeFilePath => {
expect(getIconId({ ...EMPTY_OPTIONS, relativeFilePath }));
}
);

test('`getIconId` support backslashes as well as forward slashes', () => {
expect(getIconId('./foo/bar\\icon.svg', 'foo')).toBe('bar-icon');
expect(
getIconId({ ...EMPTY_OPTIONS, relativeFilePath: './foo\\bar\\icon.svg' })
).toBe('foo-bar-icon');
});
});
34 changes: 26 additions & 8 deletions src/utils/__tests__/path.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { removeExtension } from '../path';
import { removeExtension, splitSegments } from '../path';

describe('URL utilities', () => {
test('`removeExtension` works as expected', () => {
expect(removeExtension('foo.bar')).toBe('foo');
expect(removeExtension('foo.bar.test')).toBe('foo.bar');
expect(removeExtension('foo.bar.test.foo')).toBe('foo.bar.test');
expect(removeExtension('foo')).toBe('foo');
expect(removeExtension('')).toBe('');
});
test.each([
['foo.bar', 'foo'],
['foo.bar.test', 'foo.bar'],
['foo.bar.test.foo', 'foo.bar.test'],
['foo', 'foo'],
['', '']
])(
'`removeExtension` works as expected - input: %s, output: %s',
(input, output) => {
expect(removeExtension(input)).toBe(output);
}
);

test.each([
['foo/bar', ['foo', 'bar']],
['foo', ['foo']],
['', []],
['foo/////\\\\bar', ['foo', 'bar']],
['foo///// \\\\bar', ['foo', ' ', 'bar']]
])(
'`splitSegments` works as expected - input: %s, output: %s',
(input, output) => {
expect(splitSegments(input)).toEqual(output);
}
);
});

0 comments on commit 87f8157

Please sign in to comment.