bufferapp/ui

View on GitHub
scripts/generateIconComponents.js

Summary

Maintainability
C
1 day
Test Coverage
/* eslint-disable no-console */

require('dotenv').config()

const fs = require('fs')
const path = require('path')
const inq = require('inquirer')
const Figma = require('figma-api')
const chalk = require('chalk')
const ora = require('ora')
const eachLimit = require('async/eachLimit')
const fetch = require('node-fetch')
const { promisify } = require('util')
const SVGO = require('svgo')
const svg2jsx = require('@balajmarius/svg2jsx')
const prettier = require('prettier')

const writeFileAsync = promisify(fs.writeFile)

const iconCachePath = path.join(__dirname, '../config/cachedIconData.json')
const pathToIconComponents = path.join(__dirname, '../src/components/Icon')
const pathToIconExamples = path.join(
  __dirname,
  '../src/documentation/examples/Icon',
)

const figmaIconFileId = 'D9T6BuWxbTVKhlDU8faZSQ9G'
const figmaIconFileUrl = `https://www.figma.com/file/${figmaIconFileId}/`

const iconsToIgnore = [
  'Grids', // ignore this since it's just the grid background for the icons file
]

const figma = new Figma.Api({
  personalAccessToken: process.env.FIGMA_ACCESS_TOKEN,
})

function saveIconCache(lastModified, icons) {
  const data = {
    lastModified,
    icons,
  }
  fs.writeFileSync(iconCachePath, JSON.stringify(data, null, 2))
}

function getIconCache() {
  try {
    const cacheFile = fs.readFileSync(iconCachePath).toString()
    return JSON.parse(cacheFile)
  } catch (error) {
    return null
  }
}

/**
 * Get a Figma file object from their API
 *
 * @param {String} fileId  The ID if the Figma file
 */
async function getFigmaFile(fileId) {
  return new Promise(async (resolve, reject) => {
    const [error, figmaFile] = await figma.getFile(fileId)
    if (error) {
      reject(error)
    } else {
      resolve(figmaFile)
    }
  })
}

/**
 * Returns a list of frames for the user to choose from.
 * (Only looks at the first page.)
 *
 * @param {Object} figmaFile
 */
function getFramesToChoose(figmaFile) {
  const frames = figmaFile.document.children[0].children
  const framesFormatted = frames.map((f) => ({
    name: `${f.name} (${f.children.length} children)`,
    value: f.id,
    short: f.name,
  }))
  return framesFormatted
}

/**
 * Extracts a list of icons in the frame, keyed by their IDs.
 *
 * @param {Object} figmaFile
 * @param {String} frameId
 */
function getIconsFromFrame(figmaFile, frameId) {
  const frame = figmaFile.document.children[0].children.find(
    (f) => f.id === frameId,
  )
  if (frame) {
    return (
      frame.children
        // Filter out any ignored icons
        .filter((icon) => !iconsToIgnore.includes(icon.name))
        // Simplify the data structure, just grab what we need
        .map((icon) => ({
          id: icon.id,
          name: icon.name,
        }))
        // Create an object keyed by ID
        .reduce((acc, icon) => {
          acc[icon.id] = icon
          return acc
        }, {})
    )
  }
  return {}
}

function addUrlsToIcons(icons, renderedIcons) {
  Object.entries(renderedIcons.images).forEach(([id, svgUrl]) => {
    icons[id].svgUrl = svgUrl
  })
  return icons
}

/**
 * Returns the icons with the `svgBody` appended.
 *
 * @param {Object} icons
 */
function downloadSvgs(icons) {
  return new Promise((resolve, reject) => {
    const newIcons = Object.assign({}, icons)
    eachLimit(
      icons,
      8, // max parallel downloads
      async (icon) => {
        if (icon.svgUrl) {
          const res = await fetch(icon.svgUrl)
          const svgBody = await res.text()
          newIcons[icon.id].svgBody = svgBody
        }
      },
      (err) => {
        if (err) {
          reject(err)
        }
        resolve(newIcons)
      },
    )
  })
}

const getReactSource = ({ componentName, svg }) => `
/** WARNING
 * This file is automatically generated by the \`yarn gen:icons\` command.
 * ALL changes will be overwritten.
 */
import React from 'react';
import createIconComponent from '../utils/createIconComponent';

const ${componentName}Icon = createIconComponent({
  content: (
    <g>
      ${svg}
    </g>
  ),
});
${componentName}Icon.displayName = '${componentName}Icon';

export default ${componentName}Icon;
`

const getExampleSource = (componentName) => `
import React from 'react';
import ${componentName}Icon from '@bufferapp/ui/Icon/Icons/${componentName}';

/** ${componentName} */
export default function ${componentName}IconExample() {
  return (
    <${componentName}Icon size="large" />
  );
}
`

/**
 * Takes the name of an object in Figma (i.e., 'ico-arrow-down')
 * and converts it to a CamelCase component name ('ArrowDown').
 *
 * @param {String} figmaObjectName
 */
const getComponentName = (figmaObjectName) => {
  const formatted = figmaObjectName
    .replace(/ico(n)?-/, '')
    .replace(/( \w)/g, (m) => m[1].toUpperCase())
    .replace(/ /g, '')
    .replace(/(-\w)/g, (m) => m[1].toUpperCase())
  // Capitalize first char
  return formatted.charAt(0).toUpperCase() + formatted.slice(1)
}

/**
 * This method takes a string which somewhere contains <svg>...</svg>
 * and extracts the contents. It's not super smart, but it works well enough.
 *
 * @param {String} source
 * @returns {String} svg
 */
const getSVGContent = (source) =>
  source
    // grab everything after the first tag ends (assumed to be <svg ...>)
    .slice(source.indexOf('>') + 1)
    // now split the text on the closing </svg> tag, and take only the first part
    .split('</svg>')[0]
    // trim for good measure
    .trim()

/**
 * Generate and save React component files based on a set of icon data
 * Also generates examples for the documentation.
 *
 * @param {Object} icons
 */
function generateReactIconComponents(icons, spinner) {
  return new Promise((resolve) => {
    const iconsCreated = []
    eachLimit(
      Object.values(icons),
      10,
      async (icon) => {
        const componentName = getComponentName(icon.name)
        const svg = getSVGContent(icon.svgBodyOptimized)
        const reactSource = getReactSource({ componentName, svg })
        const prettyReactSource = prettier.format(reactSource, {
          parser: 'babel',
          singleQuote: true,
        })
        const exampleSource = getExampleSource(componentName)

        const componentFilePath = path.join(
          pathToIconComponents,
          'Icons',
          `${componentName}.tsx`,
        )
        const exampleFilePath = path.join(
          pathToIconExamples,
          `${componentName}.tsx`,
        )

        await writeFileAsync(componentFilePath, prettyReactSource)
        await writeFileAsync(exampleFilePath, exampleSource)

        spinner.info(chalk.gray(`Created ${componentFilePath}`))

        iconsCreated.push(componentName)
      },
      (err) => {
        if (err) {
          console.error('Error writing component file!', err)
        }
        resolve(iconsCreated)
      },
    )
  })
}

/**
 * For some reason the Figma API sometimes returns our icons wrapped in a <g clip-path='...'>
 * which causes them to render blank. This method strips out that clip path manually.
 *
 * @todo Revisit this in the future in case we figure it out in the Figma source file or API
 * side, since it feels very hacky/brittle to use a RegEx here for this.
 *
 * @param {String} svgBody
 */
function removeClipPath(svgBody) {
  const clipPathStartRegex = /<g clip-path="url\(#clip0\)">/ims
  const clipPathEndRegex = /<\/g>\n<defs>\n<clipPath id="clip0">\n(.*?)\n<\/clipPath>\n<\/defs>/ims

  return svgBody.replace(clipPathStartRegex, '').replace(clipPathEndRegex, '')
}

/**
 * Optimize icon SVG with `svgo`
 *
 * @param {Object} icons
 */
function optimizeSvgs(icons) {
  // https://github.com/svg/svgo/blob/master/examples/test.js
  const svgo = new SVGO({
    // Remove fills so all shapes inherit the CSS color
    plugins: [{ removeAttrs: { attrs: '(stroke|fill)' } }],
  })
  const newIcons = Object.assign({}, icons)
  return new Promise((resolve) => {
    eachLimit(
      Object.values(icons),
      10,
      async (icon) => {
        const removedClipPath = removeClipPath(icon.svgBody)
        let result
        try {
          result = await svgo.optimize(removedClipPath)
        } catch (e) {
          console.error(
            `Failed to optimize icon ${icon.name} – it's possible the Figma API
          has changed their SVG output. Please ask for help in #proj-design-system.`,
            e,
          )
          console.log('Raw icon data:', icon)
          process.exit()
        }
        const transformed = await svg2jsx(result.data)
        newIcons[icon.id].svgBodyOptimized = transformed
      },
      (err) => {
        if (err) {
          console.error('Error optimizing SVG! ', err)
        }
        resolve(newIcons)
      },
    )
  })
}

function generateReactIconIndex(icons) {
  const sortedIcons = [...icons].sort()
  const indexContent = sortedIcons
    .map((name) => `export ${name} from './Icons/${name}';`)
    .join('\n')
  const pathToIndex = path.join(pathToIconComponents, 'index.ts')
  fs.writeFileSync(pathToIndex, `${indexContent}\n`)
}

async function main() {
  let spinner
  try {
    spinner = ora(`Loading BDS Icons Figma file: ${figmaIconFileUrl}`).start()
    const figmaFile = await getFigmaFile(figmaIconFileId)
    spinner.succeed()

    const cachedIconData = getIconCache()

    // console.log(
    //   chalk.gray('figmaFile.lastModified =>'),
    //   figmaFile.lastModified,
    // );
    // console.log(
    //   chalk.gray('cachedIconData.lastModified =>'),
    //   cachedIconData && cachedIconData.lastModified,
    // );

    let iconsWithSvgData

    /**
     * If we don't have a cache OR the Figma has changed then we'll update from the API
     */
    if (
      !cachedIconData ||
      cachedIconData.lastModified !== figmaFile.lastModified
    ) {
      console.log(
        chalk.gray('\nNo cache found or Figma file has been updated\n'),
      )
      const pagesToChoose = getFramesToChoose(figmaFile)
      const answer = await inq.prompt([
        {
          type: 'list',
          name: 'frameId',
          message: 'Which frame contains the icons?',
          choices: [
            ...pagesToChoose,
            new inq.Separator(),
            { name: 'Cancel', value: 'cancel' },
          ],
        },
      ])

      if (answer.frameId === 'cancel') {
        process.exit()
      }

      const icons = getIconsFromFrame(figmaFile, answer.frameId)
      const iconIds = Object.keys(icons).join(',')

      spinner.start('Rendering icons to SVG')
      const [err, renderedIcons] = await figma.getImage(figmaIconFileId, {
        ids: iconIds,
        format: 'svg',
      })
      if (err) {
        throw err
      }
      spinner.succeed()

      // Add the `svgUrl`s to the icons
      const iconsWithUrls = addUrlsToIcons(icons, renderedIcons)

      // Download the icons
      spinner.start('Download SVG files')
      iconsWithSvgData = await downloadSvgs(iconsWithUrls)
      spinner.succeed()

      // Save a cached copy of the output
      saveIconCache(figmaFile.lastModified, iconsWithSvgData)
    } else {
      iconsWithSvgData = cachedIconData.icons
    }

    spinner.start('Optimize SVGs with svgo')
    const optimizedIcons = await optimizeSvgs(iconsWithSvgData)
    spinner.succeed()

    // Write component files
    spinner.start()
    const iconsGenerated = await generateReactIconComponents(
      optimizedIcons,
      spinner,
    )
    spinner.succeed('Generate React components and examples')

    // Write index.js file
    spinner.start('Generate `Icons/index.ts`')
    generateReactIconIndex(iconsGenerated)
    spinner.succeed()

    console.log(chalk.bold.green('✔ Done!'))
  } catch (error) {
    if (spinner) {
      spinner.fail()
    }
    console.error(chalk.red('Uh oh! Something went wrong.\n\n'), error)
  }
}

main()