DEV Community

Akbar Nafisa
Akbar Nafisa

Posted on • Updated on

Automate your Vue Icon Library

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:

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:

└── icon-library
    ├── assets
    │   ├── icon-circle.svg
    │   └── icon-arrow.svg
    ├── build
    │   ├── generate-icon.js
    │   └── optimize-icon.js
    └── package.json
Enter fullscreen mode Exit fullscreen mode

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:

# setup new npm package
$ yarn init

# create a new Lerna repo
$ npx lerna init
Enter fullscreen mode Exit fullscreen mode

Then add some devDependencies and workspaces to package.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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Also, update lerna.json file

{
  "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"
}
Enter fullscreen mode Exit fullscreen mode

and finally, add jsconfig.jsonto specify the root of the project

{
  "compilerOptions": {
    "baseUrl": ".",
  },
  "exclude": [
    "node_modules"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The project structure of the example will look like this:

├── packages
├── package.json
├── lerna.json
├── jsconfig.json
Enter fullscreen mode Exit fullscreen mode

Part 2: Setup Icon Library Package

Init your icon library inside packages folder then create the folder structure as such

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

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. SVG Optimizer is a Node.js-based tool for optimizing SVG vector graphics files.

$ cd packages/svgs
$ yarn add globby fs-extra svgo chalk -D
Enter fullscreen mode Exit fullscreen mode

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

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,
  },
}
Enter fullscreen mode Exit fullscreen mode

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

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')
  })
})
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

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

...

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)
  }
})
Enter fullscreen mode Exit fullscreen mode

it will generate components/icons.js

export { default as IconMove } from './icons/IconMove'
Enter fullscreen mode Exit fullscreen mode

and generate components/icons.json

{
  "total": 1,
  "files": [
    {
      "name": "IconMove",
      "path": "icons/IconMove",
      "size": 173
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Build Vue Component

The last step is to build Vue component into esm and cjs using rollup

$ 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
Enter fullscreen mode Exit fullscreen mode
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
    },
  ]))
Enter fullscreen mode Exit fullscreen mode

finally, let's add a script in our package.json, you can see the full config here

{
"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"
  },
}
Enter fullscreen mode Exit fullscreen mode

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 as our main framework, to start the Nuxt project you can follow this command:

$ cd packages
$ yarn create nuxt-app docs
Enter fullscreen mode Exit fullscreen mode

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

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}`))
})
Enter fullscreen mode Exit fullscreen mode

then add it to nuxt.config.js

export default {
...
plugins: [{ src: '~/plugins/globals.js' }],
...
}
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

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.

{
  "name": "$package_name",
  "version": "0.0.1",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "files": [
    "dist"
  ],
}
Enter fullscreen mode Exit fullscreen mode

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

$ npm login
Enter fullscreen mode Exit fullscreen mode

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

{
"scripts": {
    "lerna:new-version": "lerna version patch --conventional-commits",
    "lerna:publish": "lerna publish from-package"
  },
}
Enter fullscreen mode Exit fullscreen mode

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.

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

Part 5: Integration with Vercel

For continuous deployment we will use Vercel, to deploy your Nuxt project to Vercel you can follow this guide 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.

$ yarn workspace @myicon/svgs build && yarn build
Enter fullscreen mode Exit fullscreen mode


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 and coolicons

Discussion (0)