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
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
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"
}
}
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"
}
and finally, add jsconfig.json
to specify the root of the project
{
"compilerOptions": {
"baseUrl": ".",
},
"exclude": [
"node_modules"
]
}
The project structure of the example will look like this:
├── 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
├── 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 usingSVGO
- Compile the
svg
file intovue
component - Compile the
vue
file of icons and illustrations intoesm
andcjs
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
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,
},
}
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')
})
})
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 usingsvgo
- 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>
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 inicons.js
, this file will be used as an entry in rollup to build our code tocjs
andesm
- Lastly, stringify the
iconsInfo
and createicons.json
, this file is ametafile
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)
}
})
it will generate components/icons.js
export { default as IconMove } from './icons/IconMove'
and generate components/icons.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
$ 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
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
{
"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 thevue
file of icons and illustration intoesm
andcjs
-
generate-svgs
- Compile thesvg
file intovue
component -
prettier
- Format thevue
file aftergenerate-svgs
-
svgs:icon
- Execute thebuild-icon
script -
svgs:optimize
- Optimize all of thesvg
assets usingSVGO
-
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
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}`))
})
then add it to nuxt.config.js
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
<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.
{
"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.
$ npm login
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"
},
}
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
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!
Top comments (0)