webpack-svg-sprites/plugin/generator.ts

309 lines
10 KiB
TypeScript

import * as fs from "fs-extra";
import * as path from "path";
import { pascalCase } from "change-case";
import XMLParser from "xml-parser";
import potpack from "potpack";
function generateAttributes(attributes: XMLParser.Attributes) {
const keys = Object.keys(attributes);
if(keys.length === 0) return "";
return Object.keys(attributes).map(e => ` ${e}="${attributes[e]}"`).join("");
}
function jsNode2xml(indent: string, data: XMLParser.Node) {
if(data.content && data.children.length)
throw "invalid node";
const tagOpen = `<${data.name}${generateAttributes(data.attributes)}>`;
const tagClose = `</${data.name}>`;
let content = data.children.length ? data.children.map(e => jsNode2xml(indent + " ", e)).join("\n") : data.content || "";
if(content.length !== 0)
content = "\n" + content + "\n" + indent;
return (
`${indent}${tagOpen}` +
`${content}` +
`${tagClose}`
);
}
interface SVGFile {
bounds: {
/* w & h required for potpack */
w: number,
h: number,
x: number,
y: number
},
name: string,
data: XMLParser.Document
}
export async function generateSpriteSvg(sprite: GeneratedSprite) {
let result = "";
result += `<?xml version="1.0" encoding="utf-8"?>\n`;
result += `<!-- ${sprite.entries.length} icons packed -->\n`;
result += `<svg xmlns="http://www.w3.org/2000/svg" width="${sprite.width}" height="${sprite.height}" viewBox="0 0 ${sprite.width} ${sprite.height}">\n`;
for(const file of sprite.entries) {
const root = file.data.root;
delete root.attributes["xmlns"];
delete root.attributes["xmlns:xlink"];
root.attributes["id"] = "client-" + file.name; /* appending the "client-" due to legacy reasons */
root.attributes["x"] = file.bounds.x.toString();
root.attributes["y"] = file.bounds.y.toString();
root.attributes["width"] = file.bounds.w.toString();
root.attributes["height"] = file.bounds.h.toString();
result += jsNode2xml(" ", root) + "\n";
}
result += "</svg>";
return result;
}
export interface SpriteCssOptions {
selector: string;
scale: number;
unit: "px" | "em";
}
export async function generateSpriteCss(options: SpriteCssOptions, classPrefix: string, sprite: GeneratedSprite, publicUrl: string) {
const boundsMap = {};
sprite.entries.forEach(e => {
const key = e.bounds.w + " " + e.bounds.h;
boundsMap[key] = (boundsMap[key] | 0) + 1;
});
const best = Object.keys(boundsMap).sort((a, b) => boundsMap[b] - boundsMap[a])[0];
const [ defaultWidth, defaultHeight ] = best.split(" ").map(parseFloat);
const scaleX = options.unit === "px" ? options.scale : options.scale / defaultWidth;
const scaleY = options.unit === "px" ? options.scale : options.scale / defaultHeight;
let result = "";
result += `${options.selector}{`;
result += `display:inline-block;`;
/* doing a relative path here as well since WebPack may messes around here since the public path may start with / and if it's a file path / means the root... */
result += `background-image:url(".${publicUrl}"),url("${publicUrl}");`;
result += `background-repeat:no-repeat;`;
result += `background-size:${sprite.width * scaleX}${options.unit} ${sprite.height * scaleY}${options.unit};`;
result += `height:${defaultHeight * scaleY}${options.unit};`;
result += `width:${defaultWidth * scaleX}${options.unit}`;
result += `}`;
const unit = (value: number) => {
let valStr = value.toString();
return value === 0 ? `${valStr}` : `${valStr}${options.unit}`;
};
for(const file of sprite.entries) {
result += `${options.selector}.${classPrefix}${file.name}{`;
result += `background-position:${unit(-file.bounds.x * scaleX)} ${unit(-file.bounds.y * scaleY)}`;
if(file.bounds.w !== defaultWidth || file.bounds.h !== defaultHeight) {
result += ";"; /* from before */
result += `background-size:${sprite.width * scaleX}${options.unit} ${sprite.height * scaleY}${options.unit};`;
result += `width:${file.bounds.w * scaleY}${options.unit};`;
result += `height:${file.bounds.h * scaleX}${options.unit}`;
}
result += `}`;
}
return result;
}
export interface SpriteDtsOptions {
module: boolean;
enumName: string;
classUnionName: string;
}
function generateEnumMembers(options: SpriteDtsOptions, sprite: GeneratedSprite, cssClassPrefix: string) : { [key: string]: string } {
const result = {};
for(const file of sprite.entries) {
let name = file.name;
name = name.replace(/[- ]/g, "_")
.replace(/[^a-zA-Z0-9_]/g, "");
name = pascalCase(name);
result[name] = cssClassPrefix + file.name;
}
return result;
}
export async function generateSpriteDts(options: SpriteDtsOptions, moduleName: string, sprite: GeneratedSprite, cssClassPrefix: string, modulePrefix: string, sourceDirectory: string) {
const headerLines = [];
const lines = [];
headerLines.push(`/*`);
headerLines.push(` * DO NOT MODIFY THIS FILE!`);
headerLines.push(` *`);
headerLines.push(` * This file has been auto generated by the svg-sprite generator.`);
headerLines.push(` * Sprite source directory: ${sourceDirectory}`);
headerLines.push(` * Sprite count: ${sprite.entries.length}`);
headerLines.push(` */`);
headerLines.push(``);
{
let union = "";
for(const file of sprite.entries)
union += ` | "${cssClassPrefix}${file.name}"`;
lines.push(`export type ${options.classUnionName} = ${union.substring(3)};`);
}
lines.push(``);
{
lines.push(`export enum ${options.enumName} {`);
const members = generateEnumMembers(options, sprite, cssClassPrefix);
for(const key of Object.keys(members))
lines.push(` ${key} = "${members[key]}",`);
lines.push("}");
}
//sprite.entries[0].bounds.
lines.push(``);
lines.push(`export const spriteEntries: {`);
lines.push(` id: string;`);
lines.push(` className: string;`);
lines.push(` width: number;`);
lines.push(` height: number;`);
lines.push(` xOffset: number;`);
lines.push(` yOffset: number;`);
lines.push(`}[];`);
lines.push(``);
lines.push(`export const spriteUrl: string;`);
lines.push(`export const classList: string[];`);
lines.push(``);
lines.push(`export const spriteWidth: number;`);
lines.push(`export const spriteHeight: number;`);
if(options.module) {
let result = "";
result += headerLines.join("\n");
result += `declare module "${modulePrefix}${moduleName}" {\n`;
result += lines.map(e => " " + e).join("\n");
result += `\n}`;
return result;
} else {
return headerLines.join("\n") + lines.join("\n");
}
}
export async function generateSpriteJs(options: SpriteDtsOptions, sprite: GeneratedSprite, publicUrl: string, cssClassPrefix: string) {
let lines = [];
{
lines.push(`let EnumClassList = {};`);
const members = generateEnumMembers(options, sprite, cssClassPrefix);
for(const key of Object.keys(members))
lines.push(`EnumClassList[EnumClassList["${key}"] = "${members[key]}"] = "${key}";`);
}
lines.push(``);
lines.push(`let SpriteEntries = [`);
for(const entry of sprite.entries) {
lines.push(` Object.freeze({`);
lines.push(` id: "${entry.name}",`);
lines.push(` className: "${cssClassPrefix}${entry.name}",`);
lines.push(` width: ${entry.bounds.w},`);
lines.push(` height: ${entry.bounds.h},`);
lines.push(` xOffset: ${entry.bounds.x},`);
lines.push(` yOffset: ${entry.bounds.y},`);
lines.push(` }),`);
}
lines.push(`];`);
lines.push(``);
lines.push(`let SpriteUrl = decodeURIComponent("${encodeURIComponent(publicUrl)}");`);
lines.push(``);
lines.push(`let ClassList = [${sprite.entries.map(e => `"${cssClassPrefix}${e.name}", `)}];`);
lines.push(``);
lines.push(`Object.defineProperty(module.exports, "__esModule", { value: true });`);
lines.push(`module.exports.${options.enumName} = Object.freeze(EnumClassList);`);
lines.push(`module.exports.spriteUrl = SpriteUrl;`);
lines.push(`module.exports.classList = Object.freeze(ClassList);`);
lines.push(`module.exports.spriteEntries = Object.freeze(SpriteEntries);`);
lines.push(`module.exports.spriteWidth = ${sprite.width};`);
lines.push(`module.exports.spriteHeight = ${sprite.height};`);
return lines.join("\n");
}
export interface GeneratedSprite {
width: number;
height: number;
entries: SVGFile[];
}
export async function generateSprite(files: string[]) : Promise<GeneratedSprite> {
const result = {
entries: [],
} as GeneratedSprite;
for(const file of files) {
const svg = {} as SVGFile;
svg.data = XMLParser((await fs.readFile(file)).toString());
svg.name = path.basename(file, path.extname(file));
if(!svg.data.root) {
console.warn("Missing svg root element for %s. Skipping file.", file);
continue;
}
if(svg.data.root.name !== "svg") {
console.warn("invalid svg root attribute for " + file + " (" + svg.data.root.name + ")");
continue;
}
const rootAttributes = svg.data.root.attributes;
const [ /* xOff */, /* yOff */, width, height ] = rootAttributes["viewBox"].split(" ").map(parseFloat);
if(isNaN(width) || isNaN(height)) {
console.warn("Skipping SVG %s because of invalid bounds (Parsed: %d x %d, Values: %o).", file, width, height, rootAttributes["viewBox"].split(" "));
continue;
} else if(Math.floor(width) !== width || Math.floor(height) !== height) {
console.warn("Skipping SVG %s because of an non interger height/width (%fx%f)", file, width, height);
continue;
}
svg.bounds = {
w: width,
h: height,
x: undefined,
y: undefined
};
result.entries.push(svg);
}
const spriteDim = potpack(result.entries.map(e => e.bounds));
result.width = spriteDim.w;
result.height = spriteDim.h;
return result;
}