Automate your Vue Icon Library
Tags: javascript, productivity, webdev, vue
Published: 2021-10-20 03:46:46

Implementing a maintainable icon library can be hard, especially when the icon is kept growing so the maintainer needs to pay attention to the package size and always update the documentation for a better developer experience. In this post, I will share how to automate your Vue icon library to improve productivity.

You can see the full code and the documentation here: 

- [Github](https://github.com/akbarnafisa/my-icon/tree/main-backup)
- [Documentation](https://my-icon.vercel.app/)
- [NPM Package](https://www.npmjs.com/package/@myicon/svgs)

# **The problem**

If you as a web developer, it's well known that you use icons in your website, whether it's to add functionality to your page or just make it pretty. If you work with teammates on multiple repositories and multiple projects, managing this icon can be cumbersome, especially if you dealing with undocumented and duplication icons in each repository. 

Well, then let's create an icon library as the main package for all of the projects, but creating an icon library is not enough, the workflow to add or modify the icon should be easy and standardize, the documentation of the icon should be added immediately. Therefore you need to look for a solution to optimize the workflow for this icon library.

## The Solution

Let's start if we have a project with folder structure like this:

```bash
└── icon-library
    ├── assets
    │   ├── icon-circle.svg
    │   └── icon-arrow.svg
    ├── build
    │   ├── generate-icon.js
    │   └── optimize-icon.js
    └── package.json
```

As we all know, adding an icon to a project is a tedious and repetitive task, the normal workflow usually you will put the icon in the `assets` folder then reference it in your Vue project, and you need to update the icon documentation if you don't forget.

But what if you can automate this process, so the only task you need is only adding or removing the icon from the `assets` folder, this process also can be used to generate meta info of the icon that will contain the size of the icon and also the path to the icon that can be used to update documentation the icon.

## Objectives

In this post, we'll show you how to create an icon library that will be easier to maintain:

- Part 1: Setup Project
- Part 2: Setup Icon Library Package
- Part 3: Setup Documentation
- Part 4: Deploy your Package to npm
- Part 5: Integration with Vercel

## Part 1: Setup Project

In this section, we’ll learn how to create a Vue icon library using yarn and monorepo. To get started, make sure you have the following:

```jsx
# setup new npm package
$ yarn init

# create a new Lerna repo
$ npx lerna init
```

Then add some `devDependencies` and `workspaces` to `package.json`

```json
{
  "name": "my-icon-test",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "workspaces": {
    "packages": [
      "packages/*"
    ]
  },
  "devDependencies": {
    "eslint-friendly-formatter": "^4.0.1",
    "eslint-loader": "^2.1.2",
    "eslint-plugin-jest": "^23.17.1",
    "lerna": "^4.0.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^7.22.0",
    "eslint-config-prettier": "^8.1.0",
    "eslint-plugin-prettier": "^3.3.1",
    "eslint-plugin-vue": "^7.7.0"
  },
  "engines": {
    "node": ">= 10"
  }
}
```

Also, update `lerna.json` file

```json
{
  "packages": [
    "packages/*"
  ],
  "command": {
    "version": {
      "allowBranch": "main"
    },
    "publish": {
      "conventionalCommits": true,
      "allowBranch": "main",
      "message": "chore(release): publish"
    }
  },
  "npmClient": "yarn",
  "useWorkspaces": true,
  "registry": "https://registry.npmjs.org/",
  "version": "independent"
}
```

and finally, add `jsconfig.json`to specify the root of the project

```json
{
  "compilerOptions": {
    "baseUrl": ".",
  },
  "exclude": [
    "node_modules"
  ]
}
```

The project structure of the example will look like this:

```jsx
├── packages
├── package.json
├── lerna.json
├── jsconfig.json
```

## Part 2: Setup Icon Library Package
Init your icon library inside `packages` folder then create the folder structure as such

```json
├── jsconfig.json
├── lerna.json
├── package.json
└── packages
    └── svgs
        ├── assets
        │   ├── icon
        ├── build
				├── components
				├── index.js
				├── rollup.config.js
				├── CHANGELOG.md
        └── package.json
```

We will put all of the icons inside the `assets` folder, and all build-related code located in the `build` folder.

Before we go any further, let me explain the main workflow of the build process:

- The contributor put the icon or illustrations inside `assets` folder
- Optimize the assets for `svg` files using `SVGO`
- Compile the `svg` file into `vue` component
- Compile the `vue` file of icons and illustrations into `esm` and `cjs` by using Rollup

### Optimize the Assets

For optimization, we’ll be using the [svgo](https://github.com/svg/svgo). SVG Optimizer is a Node.js-based tool for optimizing SVG vector graphics files.

```bash
$ cd packages/svgs
$ yarn add globby fs-extra svgo chalk -D
```

Next, we add optimization code, let's create main configuration file in `svgs/build/config.js`

```jsx
const path = require('path')
const rootDir = path.resolve(__dirname, '../')
module.exports = {
  rootDir,
  icon: {
		// directory to get all icons
    input: ['assets/icons/**/*.svg'],
		// exclude icons to be build
    exclude: [],
		// output directory 
    output: path.resolve(rootDir, 'components/icons'),
		//  alert if the icon size exceed the value in bytes
    maxSize: 1000,
  },
}
```

then let's add optimiziation code to compress the svg file `svgs/build/optimize-icon.js`

```jsx
const config = require('./config.js')
const globby = require('globby')
const fse = require('fs-extra')
const { optimize } = require('svgo')
const chalk = require('chalk')

console.log(chalk.black.bgGreen.bold('Optimize Assets'))

globby([
  ...config.icon.input,
  ...config.icon.exclude,
  '!assets/**/*.png',
  '!assets/**/*.jpeg',
  '!assets/**/*.jpg',
]).then(icon => {
  icon.forEach(path => {
    const filename = path.match(/([^\/]+)(?=\.\w+$)/)[0]
    console.log(`    ${chalk.green('√')} ${filename}`)

    const result = optimize(fse.readFileSync(path).toString(), {
      path,
    })
    fse.writeFileSync(path, result.data, 'utf-8')
  })
})
```

This code will do this process

- Get all `.svg` files by using globby and also exclude some files that we will not use
- Then for each icon, read the file by using `fs-extra` and optimize it using `svgo`
- Last, override the `.svg` file with the optimized one

```jsx

<template>
  <svg
    viewBox="0 0 24 24"
    :width="width || size"
    :height="height || size"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path
      d="M13 11V6h3l-4-4-4 4h3v5H6V8l-4 4 4 4v-3h5v5H8l4 4 4-4h-3v-5h5v3l4-4-4-4v3h-5z"
      :fill="color"
    />
  </svg>
</template>

<script>
export default {
  name: 'IconMove',
  props: {
    size: {
      type: [String, Number],
      default: 24,
    },
    width: {
      type: [String, Number],
      default: '',
    },
    height: {
      type: [String, Number],
      default: '',
    },
    color: {
      type: String,
      default: '#A4A4A4',
    },
  },
}
</script>
```

### Generate Index and Metafile

After we create the Vue component, we need to add it to `index` files for the icons and also we need to update the `metafile` for the icons. The `index` files will be used to map all of the icons assets when we build the code into `cjs` and `esm` and the `metafile` will be used as a reference file to locate the icon in the build directory, this code will do:

- List all of the icons from `iconsFiles` and sort it alphabetically
- For each icon in `iconsInfo` get the icon name and icon path, and put it in `icons.js`, this file will be used as an entry in rollup to build our code to `cjs` and `esm`
- Lastly, stringify the `iconsInfo` and create `icons.json`, this file is a `metafile` that will be used to generate our documentation

```jsx

...

globby([...config.input, ...config.exclude]).then(icon => {
  try {
    const iconsFiles = []

    ....

    const iconsInfo = {
      total: iconsFiles.length,
      files: iconsFiles.sort((a, b) => {
        if (a.name === b.name) {
          return 0
        }
        return a.name < b.name ? -1 : 1
      }),
    }
		
		// generate icons.js
    const indexIconPath = `${baseConfig.rootDir}/components/icons.js`
    try {
      fse.unlinkSync(indexIconPath)
    } catch (e) {}
    fse.outputFileSync(indexIconPath, '')
    iconsInfo.files.forEach(v => {
      fse.writeFileSync(
        indexIconPath,
        fse.readFileSync(indexIconPath).toString('utf-8') +
          `export { default as ${v.name} } from './${v.path}'\n`,
        'utf-8'
      )
    })

    // generate icons.json
    fse.outputFile(
      `${baseConfig.rootDir}/components/icons.json`,
      JSON.stringify(iconsInfo, null, 2)
    )
  } catch (error) {
    console.log(`    ${chalk.red('X')} Failed`)
    console.log(error)
  }
})
```

it will generate `components/icons.js`

```jsx
export { default as IconMove } from './icons/IconMove'
```

and generate `components/icons.json`

```json
{
  "total": 1,
  "files": [
    {
      "name": "IconMove",
      "path": "icons/IconMove",
      "size": 173
    }
  ]
}
```

### Build Vue Component
The last step is to build `Vue` component into `esm` and `cjs` using [rollup](https://github.com/akbarnafisa/my-icon/blob/main-backup/packages/svgs/rollup.config.js)

```bash
$ cd packages/svgs
$ yarn add -D rollup-plugin-vue @rollup/plugin-commonjs rollup-plugin-terser @rollup/plugin-image @rollup/plugin-node-resolve rollup-plugin-babel @rollup/plugin-alias
```

```jsx
import path from 'path'
import globby from 'globby'
import vue from 'rollup-plugin-vue'
import cjs from '@rollup/plugin-commonjs'
import alias from '@rollup/plugin-alias'
import babel from 'rollup-plugin-babel'
import resolve from '@rollup/plugin-node-resolve'
import pkg from './package.json'
import image from '@rollup/plugin-image'
import { terser } from 'rollup-plugin-terser'

const production = !process.env.ROLLUP_WATCH

const vuePluginConfig = {
  template: {
    isProduction: true,
    compilerOptions: {
      whitespace: 'condense'
    }
  },
  css: false
}

const babelConfig = {
  exclude: 'node_modules/**',
  runtimeHelpers: true,
  babelrc: false,
  presets: [['@babel/preset-env', { modules: false }]],
  extensions: ['.js', '.jsx', '.es6', '.es', '.mjs', '.vue', '.svg'],
}

const external = [
  ...Object.keys(pkg.peerDependencies || {}),
]

const projectRootDir = path.resolve(__dirname)

const plugins = [
  alias({
    entries: [
      {
        find: new RegExp('^@/(.*)$'),
        replacement: path.resolve(projectRootDir, '$1')
      }
    ]
  }),
  resolve({
    extensions: ['.vue', '.js']
  }),
  image(),
  vue(vuePluginConfig),
  babel(babelConfig),
  cjs(),
  production && terser()
]

function generateComponentInput(pathList) {
  return pathList.reduce((acc, curr) => {
    const filename = curr.match(/([^\/]+)(?=\.\w+$)/)[0]
    return {
      ...acc,
      [filename]: curr,
    }
  }, {})
}

export default globby([
  'components/**/*.vue',
])
  .then((pathList) => generateComponentInput(pathList))
  .then((componentInput) => ([
    {
      input: {
        index: './index.js',
        ...componentInput,
      },
      output: {
        dir: 'dist/esm',
        format: 'esm'
      },
      plugins,
      external
    },
    {
      input: {
        index: './index.js',
        ...componentInput,
      },
      output: {
        dir: 'dist/cjs',
        format: 'cjs',
        exports: 'named'
      },
      plugins,
      external
    },
  ]))
```

finally, let's add a script in our `package.json`, you can see the full config [here](https://github.com/akbarnafisa/my-icon/blob/main-backup/packages/svgs/package.json) 

```json
{
"scripts": {
    "build": "rm -rf dist && rollup -c",
    "generate-svgs": "yarn run svgs:icon && yarn run prettier",
		"prettier": "prettier --write 'components/**/*'",
    "svgs:icon": "node build/build-icon.js",
    "svgs:optimize": "node build/optimize-icon.js",
		"prepublish": "yarn run build"
  },
}
```

here is the detail for each script

- **`build:svgs`** - Compile the **`vue`** file of icons and illustration into **`esm`** and **`cjs`**
- **`generate-svgs`** - Compile the **`svg`** file into **`vue`** component
- **`prettier`** - Format the `vue` file after **`generate-svgs`**
- **`svgs:icon`** - Execute the `build-icon` script
- **`svgs:optimize`** - Optimize all of the **`svg`** assets using **`SVGO`**
- **`prepublish`** - Execute build script before publishing the package to

## Part 3: Setup Documentation

For documentation, we will use [Nuxt](https://nuxtjs.org/) as our main framework, to start the Nuxt project you can follow this command:

```bash
$ cd packages
$ yarn create nuxt-app docs
```

In this docs package, we will utilize the `metafile` from the icon, now let's install the icon globally in our documentation site, add `globals.js` inside the `plugins` folder

```jsx
import Vue from 'vue'
import AssetsIcons from '@myicon/svgs/components/icons.json'

const allAssets = [...AssetsIcons.files]

allAssets.forEach(asset => {
  Vue.component(asset.name, () => import(`@myicon/svgs/dist/cjs/${asset.name}`))
})
```

then add it to `nuxt.config.js`

```jsx
export default {
...
plugins: [{ src: '~/plugins/globals.js' }],
...
}
```

### Icon Page
To show our icon in the documentation, let's create `icon.vue` in `pages` folder, to get the list of the icon we export `icons.json` from `svgs` packages, because we already install the icon globally, we can use the icon on any of our pages. On the icon page, you can see the full code [here](https://github.com/akbarnafisa/my-icon/blob/main-backup/packages/docs/pages/icons.vue)

```jsx
<template>
  <div>
    <div
      v-for="item in AssetsIcons.files"
      :key="item.name"
      class="icon__wrapper"
    >
      <div class="icon__item">
        <component :is="item.name" size="28" />
      </div>
      <div class="icon__desc">
        {{ item.name }}
      </div>
    </div>
  </div>
</template>

<script>
import AssetsIcons from '@myicon/svgs/components/icons.json'

export default {
  name: 'IconsPage',
  data() {
    return {
      AssetsIcons,
    }
  },
}
</script>
```

## Part 4: Deploy your Package to npm

To deploy a package to npm, you need to name it first, it can be scoped or unscoped  (i.e., `package` or `@organization/package`), the name of the package must be unique, not already owned by someone else, and not spelled in a similar way to another package name because it will confuse others about authorship, you can check the package name [here](https://remarkablemark.org/npm-package-name-checker/).

```json
{
  "name": "$package_name",
  "version": "0.0.1",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "files": [
    "dist"
  ],
}
```

To publish package to npm, you need to create an account and login to npm.

```json
$ npm login
```

After you authenticate yourself, we will push the package by using `lerna`, in `package.json` at the root directory add this `script`.

```json
{
"scripts": {
    "lerna:new-version": "lerna version patch --conventional-commits",
    "lerna:publish": "lerna publish from-package"
  },
}
```

To publish your package, you need to checkout to the `main` branch of your repository, then execute `lerna:new-version`. When run, it will update the version in the package.json, create and push tags to git remote, and update [CHANGELOG.md](https://github.com/akbarnafisa/my-icon/blob/main-backup/packages/svgs/CHANGELOG.md).

Finally execute `lerna:publish`. When it's executed, it will publish packages that have changed since the last release. If you successfully publish your package, you can check it in [npm](https://www.npmjs.com/package/@myicon/svgs)
## Part 5: Integration with Vercel

For continuous deployment we will use [Vercel](https://vercel.com/), to deploy your Nuxt project to Vercel you can follow [this guide](https://vercel.com/guides/deploying-nuxtjs-with-vercel) from Vercel, it's quite a straightforward tutorial, but you need to modify the build command to build the icon package first then build the Nuxt documentation, and also don't forget to set the root directory to `packages/docs` instead of the root directory of the repository. You can see the deployed documentation [here](https://my-icon.vercel.app/).

```bash
$ yarn workspace @myicon/svgs build && yarn build
```
![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ofhuv6iqdp75jx95lw1a.png)
![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ax2iiab14wi92let9zde.png)

## Conclusion

This blog post covers optimizing icons using svgo, the automation process for generating icons and documentation, publishing to npm, and continuous deployment using Vercel, these steps might seem a lot but this process provides an automatic setup for anyone to modify the assets in the icon library with the less amount of time.

In the end, the engineer or contributor that wants to add a new icon will only do these steps:

- Add icon to the repository
- Optimize and generate the Icon by running command line
- Preview the icon in the documentation that automatically generated
- If they are happy with the new/modified icon, they can create a merge request to the main branch to be published in the npm package

I hope this post helped give you some ideas, please do share your feedback within the comments section, I'd love to hear your thoughts!

## Resource

for icons and illustrations, we use [undraw](https://undraw.co/) and [coolicons](https://www.figma.com/community/file/800815864899415771)