Skip to content

Instantly share code, notes, and snippets.

@eastside
Last active May 8, 2023 22:55
Show Gist options
  • Save eastside/adcdf0d189c7470be241a851c5add350 to your computer and use it in GitHub Desktop.
Save eastside/adcdf0d189c7470be241a851c5add350 to your computer and use it in GitHub Desktop.
Svg inliner that works with the latest version of blueprint, sass, webpack, node, etc
// This is a workaround for an outstanding issue in Blueprint as of 5/8/23.
// Issue is here: https://github.com/palantir/blueprint/issues/6051
//
// Basically, there's an issue where Sass-loader in webpack can't use Sass' new API for creating custom functions in Sass.
// Blueprint.js needs to register a custom function to render some svg icons.
// However, I guess the build process in Blueprint doesn't actually use webpack at all (lucky them), so we get to modify Blueprint's nice svg loader code to work with the old API.
// In our webpack.config.mjs file, we import legacySassSvgInlinerFactory and use it instead of sassSvgInlinerFactory.
//
// Taken from https://github.com/palantir/blueprint/blob/develop/packages/node-build-scripts/src/svg/svgOptimizer.mjs
/**
* Copyright 2023 Palantir Technologies, Inc. All rights reserved.
*/
// Note: we had issues with this approach using svgo v2.x, so for now we stick with v1.x
// With v2.x, some shapes within the icon SVGs would not get converted to paths correctly,
// resulting in invalid d="..." attributes rendered by the <Icon> component.
import SVGO from "svgo";
const svgOptimizer = new SVGO({ plugins: [{ convertShapeToPath: { convertArcs: true } }] });
// Taken from https://github.com/palantir/blueprint/blob/develop/packages/node-build-scripts/src/sass/sassSvgInliner.mjs
/*
* (c) Copyright 2023 Palantir Technologies Inc. All rights reserved.
*/
/**
* @fileoverview adapted from a fork of sass-inline-svg which implements dart-sass support
* @see https://github.com/Liquid-JS/sass-inline-svg/blob/958bd0e27782d46349da7d8a831467257d4130d1/index.js
*/
// @ts-check
import selectAll, { selectOne } from "css-select";
import serialize from "dom-serializer";
import { parseDocument } from "htmlparser2";
import svgToDataUri from "mini-svg-data-uri";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import sass from "sass";
import { OrderedMap } from 'immutable';
/**
* @param {sass.LegacyValue} value
* @returns {sass.SassString | sass.SassNumber | sass.SassBoolean | sass.SassColor | sass.SassList | sass.SassMap}
*/
function legacyToSass(value) {
if (value instanceof sass.types.String) {
let s = value.getValue();
return new sass.SassString(s, { quotes: false });
} else if (value instanceof sass.types.Number) {
return new sass.SassNumber(value.getValue());
} else if (value instanceof sass.types.Boolean) {
if (value.getValue() === true) {
return sass.sassTrue;
} else {
return sass.sassFalse;
}
} else if (value instanceof sass.types.Color) {
return new sass.SassColor({
red: value.getR(),
green: value.getG(),
blue: value.getB(),
alpha: value.getA()
});
} else if (value instanceof sass.types.List) {
let out = [];
for (let i = 0; i < value.getLength(); i++) {
let v = value.getValue(i);
if (v != undefined) {
out.push(legacyToSass(v));
}
}
return new sass.SassList(out);
} else if (value instanceof sass.types.Map) {
// Iterable<[sass.value, sass.Value]>
let out = [];
for (let i = 0; i < value.getLength(); i++) {
let k = value.getKey(i);
let v = value.getValue(i);
if (k == undefined || v == undefined) {
throw `undefined key or value: k: ${k}, v: ${v}`;
}
if (!(k instanceof sass.types.String)) {
throw `key is not a string: ${k}`;
}
k = new sass.SassString(k.getValue(), { quotes: false });
v = legacyToSass(v);
out.push([k, v]);
}
// @ts-ignore Typescript isn't quite smart enough to figure out that the ordered map type is correct.
return new sass.SassMap(OrderedMap(out));
} else if (value instanceof sass.types.Number) {
return new sass.SassNumber(value.getValue());
}
throw `unable to convert legacy value: ${value}`
}
/**
* This is the same as below, an SVG inline function, except it can work with the legacy Sass render API.
* This was created to work with Webpack's sass-loader module, which as of writing only works with the legacy API.
*
* See: https://github.com/webpack-contrib/sass-loader/issues/1137#issuecomment-1537240206
*/
export function legacySassSvgInlinerFactory(base, opts) {
const { optimize = false, encodingFormat = "base64" } = opts;
/**
* @param {sass.types.String} path
* @param {sass.types.Map} selectors
* @returns {sass.SassString}
*/
return function (path, selectors) {
const resolvedPath = resolve(base, path.getValue());
try {
let svgContents = readFileSync(resolvedPath, { encoding: "utf8" });
if (selectors !== undefined && selectors.getLength() > 0) {
let selectorsMap = legacyToSass(selectors);
if (selectorsMap instanceof sass.SassMap) {
svgContents = changeStyle(svgContents, selectorsMap);
} else {
throw `selectors should be a map, but was't. Was: ${selectorsMap}`;
}
}
// sass legacy can't work with promises... for some reason
let out = encode(svgContents, { encodingFormat });
return out;
} catch (err) {
console.error("[node-build-scripts]", err);
return new sass.SassString("");
}
}
}
/**
* The SVG inliner function.
* This is a factory that expects a base path and returns the actual function.
*
* @param {string} base
* @param {{optimize: boolean, encodingFormat: string}} opts
* @returns {sass.CustomFunction<"async">}
*/
export function sassSvgInlinerFactory(base, opts) {
const { optimize = false, encodingFormat = "base64" } = opts;
/**
* @param {sass.Value[]} args
* @returns {Promise<sass.SassString>}
*/
return async function (args) {
const path = /** @type {sass.SassString} */ (args[0]);
const selectors = /** @type {sass.SassMap | undefined} */ (args[1]);
const resolvedPath = resolve(base, path.text);
try {
let svgContents = readFileSync(resolvedPath, { encoding: "utf8" });
if (selectors !== undefined && selectors.asList.size > 0) {
svgContents = changeStyle(svgContents, selectors);
}
if (optimize) {
svgContents = (await svgOptimizer.optimize(svgContents, { path: resolvedPath })).data;
}
return encode(svgContents, { encodingFormat });
} catch (err) {
console.error("[node-build-scripts]", err);
return new sass.SassString("");
}
};
}
/**
* Encode a JS string as a Sass string.
*
* @param {any} content
* @param {any} opts
* @returns {sass.SassString}
*/
function encode(content, opts) {
if (opts.encodingFormat === "uri") {
return new sass.SassString(`url("${svgToDataUri(content.toString("UTF-8"))}")`, { quotes: false });
}
if (opts.encodingFormat === "base64") {
return new sass.SassString(`url("data:image/svg+xml;base64,${content.toString("base64")})`, { quotes: false });
}
throw new Error(`[node-build-scripts] encodingFormat ${opts.encodingFormat} is not supported`);
}
/**
* Change the style attributes of an SVG string.
*
* @param {string} source
* @param {sass.SassMap} selectorsMap
* @returns {*}
*/
function changeStyle(source, selectorsMap) {
const document = parseDocument(source, {
xmlMode: true,
});
const svg = document ? selectOne("svg", document.childNodes) : null;
const selectors = mapToObj(selectorsMap);
if (!svg) {
throw Error("[node-build-scripts] Invalid svg file");
}
Object.keys(selectors).forEach(function (selector) {
const elements = selectAll(selector, svg);
const newAttributes = selectors[selector];
elements.forEach(function (element) {
// @ts-ignore -- attribs property does exist
Object.assign(element.attribs, newAttributes);
});
});
return serialize(document);
}
/**
* Recursively transforms a Sass map into a JS object.
*
* @param {sass.SassMap} sassMap
* @returns {Record<any, any>}
*/
function mapToObj(sassMap) {
const obj = Object.create(null);
const map = sassMap.contents.toJS();
for (const [key, value] of /** @type {[string, sass.Value][]} */ (Object.entries(map))) {
obj[key] = value instanceof sass.SassMap ? mapToObj(value) : value.toString();
}
return obj;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment