Skip to content

Instantly share code, notes, and snippets.

@robertknight
Last active July 19, 2021 08:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save robertknight/9bf0ac2b95d7712294d1df3663bf0977 to your computer and use it in GitHub Desktop.
Save robertknight/9bf0ac2b95d7712294d1df3663bf0977 to your computer and use it in GitHub Desktop.
Generate Preact components that render SVG icons from SVG markup
/* global process */
'use strict';
const fs = require('fs');
const { basename } = require('path');
const escapeHtml = require('escape-html');
const htmlParser = require('htmlparser2');
const prettier = require('prettier');
// Incomplete mapping of lower-cased SVG element names to JSX element types.
//
// See https://developer.mozilla.org/en-US/docs/Web/SVG/Element.
const jsxElementTypes = {
animatetransform: 'animateTransform',
circle: 'circle',
defs: 'defs',
ellipse: 'ellipse',
g: 'g',
line: 'line',
lineargradient: 'linearGradient',
path: 'path',
polygon: 'polygon',
polyline: 'polyline',
rect: 'rect',
stop: 'stop',
svg: 'svg',
};
// Incomplete mapping of lower-cased SVG attribute names to JSX prop names.
//
// See https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute.
const jsxPropNames = {
'fill-rule': 'fillRule',
'stop-color': 'stopColor',
'stop-opacity': 'stopOpacity',
'stroke-linecap': 'strokeLinecap',
'stroke-linejoin': 'strokeLinejoin',
'stroke-width': 'strokeWidth',
attributename: 'attributeName',
class: 'className',
cx: 'cx',
cy: 'cy',
d: 'd',
dur: 'dur',
fill: 'fill',
from: 'from',
height: 'height',
id: 'id',
offset: 'offset',
points: 'points',
r: 'r',
repeatcount: 'repeatCount',
rx: 'rx',
ry: 'ry',
stroke: 'stroke',
style: 'style',
to: 'to',
transform: 'transform',
type: 'type',
viewbox: 'viewBox',
width: 'width',
x1: 'x1',
x2: 'x2',
x: 'x',
y1: 'y1',
y2: 'y2',
y: 'y',
};
// Set of attributes which should not be retained when generating JSX elements
// from parsed SVG elements.
const dropAttributes = {
// We assume that any references to external classes in the SVG source are
// not going to be used and that there are no `<style>` elements in the SVG
// source.
class: true,
// Browsers ignore this attribute AFAIK. See https://allyjs.io/tutorials/focusing-in-svg.html.
focusable: true,
// Drop element IDs which we assume are not going to be used.
id: true,
// Attributes that are not needed when the browser is processing an inline SVG.
// See https://stackoverflow.com/questions/18467982/.
xmlns: true,
xmlnsXlink: true,
version: true,
};
// Set of elements which should not be retained when generating JSX element trees
// from parsed `<svg>` elements.
const dropElements = {
desc: true,
metadata: true,
title: true,
};
/**
* Object that contains the data for a JSX element (aka. a "vnode").
*
* This is a simplified version of what Preact's `createElement` returns.
*
* @typedef JSXElement
* @prop {string} type
* @prop {object} props
*/
/**
* Return the JSX prop that should be used for a given attribute name.
*
* @param {string} attrName - Attribute name
*/
function jsxPropName(attrName) {
const propName = jsxPropNames[attrName.toLowerCase()];
if (propName === undefined) {
// If you get this error for a valid attribute, add an entry to `jsxPropNames`.
throw new Error(`Unknown SVG attribute "${attrName}"`);
}
return propName;
}
function jsxElementType(elementName) {
const type = jsxElementTypes[elementName.toLowerCase()];
if (type === undefined) {
// If you get this error for a valid attribute, add an entry to `jsxElementTypes`.
throw new Error(`Unknown SVG element "${elementName}"`);
}
return type;
}
/**
* Escape a string for use as the string value for a JSX attribute.
*
* @param {string} value
*/
function escapeJSXAttrValue(value) {
return escapeHtml(value);
}
/**
* Escape a string for use as the text children of a JSX element.
*
* @param {string} value
*/
function escapeJSXText(value) {
return escapeHtml(value);
}
/**
* Return true if `element` is an SVG element that produces no visual output.
*
* @param {Element} element
*/
function isHiddenElement(element) {
const getAttribute = name => {
const attr = element.attributes.find(a => a.name === name);
return attr ? attr.value : null;
};
// Incomplete list of SVG graphic elements.
const isGraphicElement = ['line', 'rect', 'path', 'polygon'].includes(
element.name
);
return (
isGraphicElement &&
getAttribute('fill') === 'none' &&
getAttribute('stroke') === 'none'
);
}
/**
* Generate a JSX Element from a parsed SVG element.
*
* @param {Element} element - Parsed data for the element.
* @return {JSXElement}
*/
function jsxElement(element) {
const props = {};
for (let attr of element.attributes) {
// Drop all accessibility-related attributes. We assume the only one that
// matters is for the caller to provide a suitable accessible label via
// `aria-label`.
if (attr.name.startsWith('aria-') || attr.name === 'role') {
continue;
}
// Drop all custom attributes and attributes in non-SVG/HTML namespaces.
if (attr.name.startsWith('data-') || attr.name.includes(':')) {
continue;
}
if (attr.name in dropAttributes) {
continue;
}
props[jsxPropName(attr.name)] = attr.value;
}
let children = [];
for (let child of element.children) {
if (child.type === 'text') {
children.push(child.data);
} else if (child.type === 'tag') {
// Drop all non-SVG/HTML elements
if (child.name.includes(':')) {
continue;
}
if (child.name in dropElements) {
continue;
}
if (isHiddenElement(child)) {
continue;
}
children.push(jsxElement(child));
}
}
return {
type: jsxElementType(element.name),
props: {
...props,
children,
},
};
}
/**
* Generate code for a JSX element.
*
* @param {JSXElement} vnode
*/
function jsxString(vnode) {
const propStr = Object.entries(vnode.props)
.map(([name, value]) => {
if (name === 'children') {
return '';
}
return `${name}="${escapeJSXAttrValue(value)}"`;
})
.join(' ');
const children = (vnode.props.children || [])
.map(child => {
if (typeof child === 'string') {
return escapeJSXText(child);
} else {
return jsxString(child);
}
})
.join('');
const openTag = `<${vnode.type} ${propStr}>`;
if (children) {
return openTag + children + `</${vnode.type}>`;
} else {
// Make tag self-closing if there are no children.
return openTag.replace(/>$/, '/>');
}
}
/**
* Generate a JSX element tree from SVG source.
*
* @param {string} svgMarkup
* @return {JSXElement}
*/
function generateJSX(svgMarkup) {
// `parseDocument` returns a DOM `Document`-like object.
const dom = htmlParser.parseDocument(svgMarkup);
const svg = dom.children.find(
node => node.type === 'tag' && node.name === 'svg'
);
if (!svg) {
throw new Error('Markup does not contain an `<svg>` root element');
}
return jsxElement(svg);
}
/**
* Generate a React/Preact component that renders a given JSX expression.
*
* @param {string} name - Name of the component
* @param {JSXElement} jsx
*/
function generateComponentCode(name, jsx) {
const jsxStr = jsxString(jsx);
return `function ${name}() {
return ${jsxStr};
}`;
}
/**
* Generate code for a function component from SVG markup in `path`.
*
* The name of the component is inferred from the file name. eg. If the file
* name is `big-widget.svg` this returns `BigWidgetIcon`.
*
* @param {string} path
*/
function generateComponentFromSVGFile(path) {
const svgMarkup = fs.readFileSync(path).toString();
// Generate JSX markup from `<svg>` content.
const jsx = generateJSX(svgMarkup);
// Generate component name from file name:
// `some-thing.svg` => `SomeThingIcon`
const componentName = basename(path, '.svg')
.split('-')
.filter(token => token.length > 0)
.map(token => token[0].toUpperCase() + token.slice(1))
.concat('Icon')
.join('');
return generateComponentCode(componentName, jsx);
}
/**
* Generate a module containing a set of Preact components from a list of SVG files.
*
* See `generateComponentFromSVGFile`.
*
* @param {string[]} paths
*/
function generateComponentsFromSVGFiles(paths) {
return paths
.map(generateComponentFromSVGFile)
.map(code => 'export ' + code)
.join('\n\n');
}
const paths = process.argv.slice(2);
const code = prettier.format(generateComponentsFromSVGFiles(paths), {
parser: 'babel',
});
console.log(code);
@robertknight
Copy link
Author

robertknight commented Jul 16, 2021

This is a rough script that generates an ES module containing source code for Preact/React components from a set of SVG files.

This uses htmlparser2 for parsing which provides a familiar DOM-like interface for traversing parsed SVG.

Usage:

node scripts/generate-svg-components.js icons/*.svg > output-file.js

The generated code is formatted using Prettier.

The output assumes that you are using the new JSX transform in your projects and therefore do not need to import createElement from React/Preact. This makes the code compatible with both libraries.

Caveat that I have only tested this script with SVG icons from Hypothesis projects. The mappings of SVG element and attribute names to JSX element types and props only include elements and attributes used by those icons.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment