path-icons
Version:
A utility for generating C# and CSS with SVG path-based icons
345 lines (299 loc) • 13.6 kB
JavaScript
import { promises as fs } from 'fs';
import { join, dirname, basename, extname } from 'path';
import { fileURLToPath } from 'url';
import Mustache from 'mustache';
// Define file paths and constants
const __dirname = dirname(fileURLToPath(import.meta.url));
const CSS_TEMPLATE_FILE = join(__dirname, 'base.css.mustache');
const HTML_TEMPLATE_FILE = join(__dirname, 'preview.html.mustache');
// Helper function to convert 'string value' to object { d: 'string value' }
function _toPathObject(item) {
if (typeof item === 'string') return { d: item };
return typeof item === 'object' ? item || {} : {};
}
// Helper function to convert object { d: 'string value' } without `fill` to 'string value'.
function _fromPathObject(item = {}) {
if (typeof item !== 'object') return null;
const { d, fill } = item;
return typeof d === 'string' && d && !fill ? d : item;
}
// Merges data for a single icon
function _mergeIconData(baseData, sourceData, iconName) {
const baseIconData = baseData[iconName];
const sourceIconData = sourceData[iconName];
// If no base data for this icon, use source data if available, otherwise empty array
if (!baseIconData) {
return Array.isArray(sourceIconData) ? sourceIconData : [];
}
// If base data is already in the new array format
if (Array.isArray(baseIconData)) {
// Check if base array contains any path ('d') after conversion.
const baseHasPath = baseIconData.some(item => _toPathObject(item).d);
// If base has any path OR source is not array, return base data directly.
if (baseHasPath || !Array.isArray(sourceIconData)) {
return baseIconData;
}
// Get base fills (up to 2)
const baseFill0 = baseIconData.length > 0 ? _toPathObject(baseIconData[0]).fill : undefined;
const baseFill1 = baseIconData.length > 1 ? _toPathObject(baseIconData[1]).fill : undefined;
// Apply base fill to source[0]
if (typeof baseFill0 === 'string' && baseFill0 && sourceIconData.length > 0) {
sourceIconData[0] = Object.assign(_toPathObject(sourceIconData[0]), { fill: baseFill0 });
}
// Apply base fill to source[1]
if (typeof baseFill1 === 'string' && baseFill1 && sourceIconData.length > 1) {
sourceIconData[1] = Object.assign(_toPathObject(sourceIconData[1]), { fill: baseFill1 });
}
// Return the modified sourceIconData array
return sourceIconData;
}
// LEGACY format
if (baseIconData && typeof baseIconData === 'object' && !Array.isArray(baseIconData) && Array.isArray(sourceIconData)) {
const newFormatArray = [];
// Handle pathBefore/colorBefore, using source's first element as fallback
if (baseIconData.pathBefore || baseIconData.colorBefore) {
const srcItem = sourceIconData.length > 0 ? _toPathObject(sourceIconData[0]) : {};
const baseItem = {
d: baseIconData.pathBefore || srcItem.d,
fill: baseIconData.colorBefore || srcItem.fill
};
const resultItem = _fromPathObject(baseItem);
if (resultItem)
newFormatArray.push(resultItem);
}
// Handle pathAfter/colorAfter, using source's second element as fallback
if (baseIconData.pathAfter || baseIconData.colorAfter) {
const srcItem = sourceIconData.length > 1 ? _toPathObject(sourceIconData[1]) : {};
const baseItem = {
d: baseIconData.pathAfter|| srcItem.d,
fill: baseIconData.colorAfter || srcItem.fill
};
const resultItem = _fromPathObject(baseItem);
if (resultItem)
newFormatArray.push(resultItem);
}
return newFormatArray;
}
// If neither source nor base has valid data in expected formats, return null
return null;
}
/**
* Merges base JSON and override JSON, optionally including new icons.
* @param {string} baseJsonPath - Path to base JSON file.
* @param {string} sourceJsonPath - Path to source JSON file.
* @param {object} options - Configuration options.
* @param {boolean} [options.addSource] - If true, include new icons from source; if false, only update existing.
* @returns {Promise<object>} Merged icon data.
*/
async function prepareJson(baseJsonPath, sourceJsonPath, options = {}) {
const jsonOptions = options.jsonOptions || {};
try {
const {
addSource = false
} = jsonOptions;
// Read base JSON
const baseData = JSON.parse(await fs.readFile(baseJsonPath, 'utf8'));
// Read override JSON, default to empty object if not found
let sourceData = {};
try {
sourceData = JSON.parse(await fs.readFile(sourceJsonPath, 'utf8'));
} catch (error) {
console.error(`Error loading ${sourceJsonPath}, using base JSON only.`);
console.error(error);
}
// Determine which icon names to process
const baseIconNames = Object.keys(baseData);
const sourceIconNames = Object.keys(sourceData);
const iconNames = addSource ? [...new Set([...baseIconNames, ...sourceIconNames])] : baseIconNames;
// Merge data for each icon
const merged = {};
for (const iconName of iconNames) {
const mergedIcon = _mergeIconData(baseData, sourceData, iconName);
if (Array.isArray(mergedIcon) && mergedIcon.length !== 0) {
merged[iconName] = mergedIcon;
}
else {
console.warn(` WARN: No source data for icon "${iconName}".`);
}
}
return merged;
} catch (error) {
console.error('Error merging JSON:', error);
throw error;
}
}
/**
* Generates the raw CSS content string from merged icon data.
* @param {object} mergedData - Merged icon data.
* @returns {Promise<string>} The generated CSS content string.
*/
async function generateCssContent(mergedData) {
try {
// Load CSS template
const baseCSSTemplate = await fs.readFile(CSS_TEMPLATE_FILE, 'utf8');
// Generate icon rules
const iconNames = Object.keys(mergedData);
const iconRules = iconNames
.map(iconName => {
const iconData = mergedData[iconName];
if (!Array.isArray(iconData) || iconData.length === 0) {
return '';
}
const rules = [];
const firstItem = _toPathObject(iconData[0]);
if (firstItem.d) {
const beforeRule = firstItem.fill
? `.pi-${iconName}::before { content: ''; clip-path: path("${firstItem.d}"); background-color: ${firstItem.fill}; }`
: `.pi-${iconName}::before { content: ''; clip-path: path("${firstItem.d}"); }`;
rules.push(beforeRule);
}
if (iconData.length > 1) {
const secondItem = _toPathObject(iconData[1]);
if (secondItem.d) {
const pathAfter = iconData.slice(1)
.map(item => _toPathObject(item).d)
.join(' ');
const afterRule = secondItem.fill
? `.pi-${iconName}::after { content: ''; clip-path: path("${pathAfter}"); background-color: ${secondItem.fill}; }`
: `.pi-${iconName}::after { content: ''; clip-path: path("${pathAfter}"); }`;
rules.push(afterRule);
}
}
return rules.join('\n').trim();
})
.filter(Boolean)
.join('\n');
// Render the final CSS string
const finalCSS = Mustache.render(baseCSSTemplate, { iconRules: iconRules });
return finalCSS; // Return the generated string
} catch (error) {
console.error('Error generating CSS content:', error);
throw error;
}
}
/**
* Generates CSS from merged icon data and writes it to output path.
* @param {object} mergedData - Merged icon data.
* @param {string} outputCssPath - Path for output CSS file.
* @returns {Promise<void>}
*/
async function createCSS(mergedData, outputCssPath) {
try {
const finalCSS = await generateCssContent(mergedData);
// Write CSS output
await fs.mkdir(dirname(outputCssPath), { recursive: true });
await fs.writeFile(outputCssPath, finalCSS, 'utf8');
} catch (error) {
console.error('Error generating CSS:', error);
throw error;
}
}
/**
* Generates HTML preview from merged icon data and writes it to output path.
* @param {object} mergedData - Merged icon data.
* @param {string} outputHtmlPath - Path for output HTML file.
* @param {string} cssPath - Relative path to CSS file.
* @returns {Promise<void>}
*/
async function createHtml(mergedData, outputHtmlPath) {
try {
// Extract icon data with names and headers
const iconsData = Object.entries(mergedData)
.filter(([name, data]) => Array.isArray(data) && data.length !== 0)
.map(([name, data]) => ({
name,
header: data[0].header || name
}));
// Load HTML template
const htmlTemplate = await fs.readFile(HTML_TEMPLATE_FILE, 'utf8');
// Prepare CSS content
const cssContent = await generateCssContent(mergedData);
// Render template
const htmlContent = Mustache.render(htmlTemplate, {
cssContent: cssContent,
icons: iconsData
});
// Write HTML output
await fs.mkdir(dirname(outputHtmlPath), { recursive: true });
await fs.writeFile(outputHtmlPath, htmlContent, 'utf8');
} catch (error) {
console.error('Error generating HTML:', error);
throw error;
}
}
/**
* Generates C# enum from merged icon data and writes it to output path.
* @param {object} mergedData - Merged icon data.
* @param {string} outputCsharpPath - Path for output C# file.
* @param {object} options - Configuration options.
* @param {string} [options.namespace="SharedLib.Bootstrap"] - Namespace for the enum.
* @param {string} [options.enumName="BootstrapSymbol"] - Name of the enum.
* @param {string} [options.attrName="SymbolPath"] - Name of the attribute for symbol paths.
* @returns {Promise<void>}
*/
async function createCSharp(mergedData, outputCsharpPath, options = {}) {
const csharpOptions = options.csharpOptions || {};
const enumName = basename(outputCsharpPath, extname(outputCsharpPath));
try {
// Default options
const {
namespace = 'SharedLib.Controls',
attrName = 'SymbolPath'
} = csharpOptions;
// Load C# template
const csharpTemplate = await fs.readFile(join(__dirname, 'csharp.mustache'), 'utf8');
// Process icon data for C# enum
const iconDefinitions = Object.keys(mergedData)
.filter(iconName => Array.isArray(mergedData[iconName]) && mergedData[iconName].length > 0)
.map(iconName => {
const iconDataArray = mergedData[iconName];
// Convert icon name to PascalCase for C# enum member
const pascalCaseName = iconName
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join('');
// Prepare attributes for SymbolPath
const attributes = [];
// Iterate through each path/color item in the array to create SymbolPath attributes
for (const item of iconDataArray) {
const pathItem = _toPathObject(item);
const path = pathItem.d;
const color = pathItem.fill;
if (path) {
if (color) {
let colorValue = '0';
if (color.startsWith('#')) {
// Convert HEX to ARGB (add 0xff for full opacity)
colorValue = `0xff${color.slice(1).toLowerCase()}`;
} else {
// Use KnownColor for named colors (requires System.Drawing)
colorValue = `KnownColor.${color}`;
}
attributes.push(`[${attrName}("${path}", ${colorValue})]`);
}
else {
attributes.push(`[${attrName}("${path}")]`);
}
}
}
return {
comment: iconName,
name: pascalCaseName,
attributes: attributes
};
});
// Render C# template
const csharpContent = Mustache.render(csharpTemplate, {
namespace,
enumName,
icons: iconDefinitions
});
// Write C# output
await fs.mkdir(dirname(outputCsharpPath), { recursive: true });
await fs.writeFile(outputCsharpPath, csharpContent, 'utf8');
} catch (error) {
console.error('Error generating C#:', error);
throw error;
}
}
export { prepareJson, createCSS, createHtml, createCSharp };