iconforged
Version:
Frontend icon library. Bridging the gap between traditional icons and modern workflows.
423 lines (374 loc) • 16.6 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const glob = require('glob');
const chokidar = require('chokidar');
const os = require('os');
const subsetFont = require('subset-font');
const DIST_DIR = path.resolve(__dirname, '../dist/');
const META_ICONS = path.join(DIST_DIR, 'meta/iconforge-icons.json');
const META_STYLES = path.join(DIST_DIR, 'meta/iconforge-styles.json');
const OUTPUT_DIR = path.join(process.cwd(), 'iconforge-output');
const OUTPUT_CSS = path.join(OUTPUT_DIR, 'iconforge.css');
const OUTPUT_WOFF2 = path.join(OUTPUT_DIR, 'iconforge.woff2');
const TEMP_DIR = path.join(os.tmpdir(), 'iconforge-subset');
const CJS_CONFIG_PATH = path.join(process.cwd(), 'iconforge.config.cjs');
const JS_CONFIG_PATH = path.join(process.cwd(), 'iconforge.config.js');
async function loadConfig() {
let configPath = '';
if (fs.existsSync(CJS_CONFIG_PATH)) {
configPath = CJS_CONFIG_PATH;
} else if (fs.existsSync(JS_CONFIG_PATH)) {
configPath = JS_CONFIG_PATH;
console.warn('Warning: iconforge.config.js is deprecated. Please rename to iconforge.config.cjs');
}
if (!configPath) return {};
try {
const fileUrl = 'file://' + configPath;
const module = await import(fileUrl);
return module.default || module;
} catch (e) {
console.error(`Error loading ${configPath} as a module, falling back to require.`, e);
try {
delete require.cache[require.resolve(configPath)];
return require(configPath);
} catch (e2) {
console.error(`Error loading ${configPath} with require.`, e2);
return {};
}
}
}
function escapeSelector(selector) {
return selector.replace(/([ !"#$%&'()*+,./:;<=>?@[\]^`{|}~])/g, '\\$1');
}
const responsiveBreakpoints = {
'xs:': '(min-width: 420px)',
'sm:': '(min-width: 640px)',
'md:': '(min-width: 768px)',
'lg:': '(min-width: 1024px)',
'xl:': '(min-width: 1280px)',
};
const pseudoMap = {
'hover:': ':hover',
'focus:': ':focus',
'active:': ':active',
'disabled:': ':disabled',
};
const darkToken = 'dark:';
const allVariantTokens = [
...Object.keys(responsiveBreakpoints),
...Object.keys(pseudoMap),
darkToken,
];
function parseVariants(token) {
let rest = token;
let mediaQuery = '';
const pseudos = [];
let darkPrefix = '';
outer: while (true) {
for (const t of allVariantTokens) {
if (rest.startsWith(t)) {
if (t in responsiveBreakpoints) {
mediaQuery = `@media ${responsiveBreakpoints[t]}`;
} else if (t === darkToken) {
darkPrefix = '.dark ';
} else if (t in pseudoMap) {
pseudos.push(pseudoMap[t]);
}
rest = rest.slice(t.length);
continue outer;
}
}
break;
}
return {
base: rest,
mediaQuery,
variantSel: pseudos.join(''),
darkPrefix,
};
}
function wrapMedia(css, mediaQuery) {
return mediaQuery ? `${mediaQuery} { ${css} }` : css;
}
async function buildCSS(content) {
fs.ensureDirSync(OUTPUT_DIR);
const iconsMeta = JSON.parse(fs.readFileSync(META_ICONS, 'utf-8'));
const stylesMeta = JSON.parse(fs.readFileSync(META_STYLES, 'utf-8'));
const usedClasses = new Set();
const config = await loadConfig();
if (config.safelist && Array.isArray(config.safelist)) {
config.safelist.forEach(cls => usedClasses.add(cls));
}
const regex = /(?:class|className)\s*=\s*["'](.*?)["']/gms;
let match;
while ((match = regex.exec(content)) !== null) {
match[1].split(/\s+/).forEach(cls => {
if (!cls) return;
const { base } = parseVariants(cls);
if (base && (base.startsWith('if-') || base.startsWith('is-') || base.startsWith('is-['))) {
usedClasses.add(cls);
}
});
}
let cssOutput =
`@font-face {font-family: 'IconForge'; src: url('iconforge.woff2') format('woff2'); font-style: normal; font-display: block; }\n` +
`[class^="if-"], [class*=" if-"] { font-family: 'IconForge' !important; display: inline-block; font-style: normal; font-weight: normal; font-variant: normal; text-transform: none; line-height: 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }\n`;
const keyframesSet = new Set();
const stylesByMedia = { default: new Set() };
for (let cls of usedClasses) {
const { base, mediaQuery, variantSel, darkPrefix } = parseVariants(cls);
let rule = null;
const arbitraryMatch = base.match(/^(is-(?:color|bg|w|h|sq|size|p|pt|pr|pb|pl|px|py|m|mt|mr|mb|ml|my|mx|z|scale|opacity|rot|grid-cols|grid-rows|gap|top|bottom|left|right|translate|translate-x|translate-y|border|border-t|border-b|border-l|border-r|fixed-bg|gradient-linear|gradient-radial|gradient-conic|outline|outline-dashed|outline-dotted|outline-double|blur|backdrop-blur|brightness|contrast|grayscale|saturate))-\[(.+)\]$/);
if (arbitraryMatch) {
const [, type, valueRaw] = arbitraryMatch;
const value = typeof valueRaw === 'string' ? valueRaw : String(valueRaw);
let properties = '';
switch (type) {
case 'is-color': properties = `color: ${value};`; break;
case 'is-bg': properties = `background-color: ${value};`; break;
case 'is-w': properties = `width: ${value};`; break;
case 'is-h': properties = `height: ${value};`; break;
case 'is-sq': properties = `width: ${value}; height: ${value};`; break;
case 'is-size': properties = `font-size: ${value};`; break;
case 'is-p': properties = `padding: ${value};`; break;
case 'is-pt': properties = `padding-top: ${value};`; break;
case 'is-pr': properties = `padding-right: ${value};`; break;
case 'is-pb': properties = `padding-bottom: ${value};`; break;
case 'is-pl': properties = `padding-left: ${value};`; break;
case 'is-px': properties = `padding-left: ${value}; padding-right: ${value};`; break;
case 'is-py': properties = `padding-top: ${value}; padding-bottom: ${value};`; break;
case 'is-m': properties = `margin: ${value};`; break;
case 'is-mt': properties = `margin-top: ${value};`; break;
case 'is-mr': properties = `margin-right: ${value};`; break;
case 'is-mb': properties = `margin-bottom: ${value};`; break;
case 'is-ml': properties = `margin-left: ${value};`; break;
case 'is-mx': properties = `margin-left: ${value}; margin-right: ${value};`; break;
case 'is-my': properties = `margin-top: ${value}; margin-bottom: ${value};`; break;
case 'is-z': properties = `z-index: ${value};`; break;
case 'is-scale':properties = `transform: scale(${value});`; break;
case 'is-opacity': properties = `opacity: ${value};`; break;
case 'is-rot': properties = `transform: rotate(${value});`; break;
case 'is-grid-cols':properties = `grid-template-columns: repeat(${value}, minmax(0, 1fr));`;break;
case 'is-grid-rows':properties = `grid-template-rows: repeat(${value}, minmax(0, 1fr));`;break;
case 'is-gap':properties = `gap: ${value};`;break;
case 'is-top':properties = `top: ${value};`;break;
case 'is-bottom':properties = `bottom: ${value};`;break;
case 'is-left':properties = `left: ${value};`;break;
case 'is-right':properties = `right: ${value};`;break;
case 'is-translate':properties = `transform: translate(${value}, ${value});`;break;
case 'is-translate-x':properties = `transform: translateX(${value});`;break;
case 'is-translate-y':properties = `transform: translateY(${value});`;break;
case 'is-border': properties = `border-width: ${value} ${value} ${value} ${value}; border-style: solid;`;break;
case 'is-border-t':properties = `border-top-width: ${value}; border-style: solid;`;break;
case 'is-border-b':properties = `border-bottom-width: ${value}; border-style: solid;`;break;
case 'is-border-l':properties = `border-left-width: ${value}; border-style: solid;`;break;
case 'is-border-r':properties = `border-right-width: ${value}; border-style: solid;`;break;
case 'is-outline':properties = `outline: 1px solid ${value};`;break;
case 'is-outline-dashed':properties = `outline: 1px dashed ${value};`;break;
case 'is-outline-dotted':properties = `outline: 1px dotted ${value};`;break;
case 'is-outline-double':properties = `outline: 2px solid ${value};`;break;
case 'is-blur':properties = `filter: blur(${value});`;break;
case 'is-backdrop-blur':properties = `backdrop-filter: blur(${value});`;break;
case 'is-brightness': properties = `filter: brightness(${value});`;break;
case 'is-contrast': properties = `filter: contrast(${value});`;break;
case 'is-grayscale': properties = `filter: grayscale(${value});`;break;
case 'is-saturate': properties = `filter: saturate(${value});`;break;
case 'is-fixed-bg': {const safeVal = value.replace(/"/g, '\"');properties = `position: fixed; top: 0; left: 0; width: 100dvw; height: 100dvh; z-index: -1; background-repeat: no-repeat; background-size: cover; background-image: url("${safeVal}");`;break;}
case 'is-gradient-linear': {const safeVal = value.replace(/_/g, ' ').replace(/"/g, '"');properties = `background: linear-gradient(${safeVal}); background-clip: text; -webkit-text-fill-color: transparent;`; break;}
case 'is-gradient-radial': {const safeVal = value.replace(/_/g, ' ').replace(/"/g, '"');properties = `background: radial-gradient(${safeVal}); background-clip: text; -webkit-text-fill-color: transparent;`; break;}
case 'is-gradient-conic': {const safeVal = value.replace(/_/g, ' ').replace(/"/g, '"');properties = `background: conic-gradient(${safeVal}); background-clip: text; -webkit-text-fill-color: transparent;`; break;}
default:properties = '';
}
if (properties) {
const sel = `${darkPrefix}.${escapeSelector(cls)}${variantSel}`;
rule = `${sel} { ${properties} }`;
}
} else {
const style = stylesMeta[base];
if (style) {
let properties = '';
let ruleSrc = '';
if (typeof style === 'object' && style !== null) {
if (style.keyframes) keyframesSet.add(style.keyframes);
ruleSrc = style.class || '';
} else {
ruleSrc = style || '';
}
const m = typeof ruleSrc === 'string' ? ruleSrc.match(/{([^}]+)}/) : null;
properties = m ? m[1].trim() : ruleSrc;
if (properties) {
const sel = `${darkPrefix}.${escapeSelector(cls)}${variantSel}`;
rule = `${sel} { ${properties} }`;
}
} else if (iconsMeta[base]) {
const iconStyle = iconsMeta[base];
let fullRule = '';
if (typeof iconStyle === 'object' && iconStyle.value) {
fullRule = iconStyle.value;
} else if (typeof iconStyle === 'string') {
fullRule = iconStyle;
}
if (fullRule) {
const m = fullRule.match(/{([^}]+)}/);
if (m) {
const properties = m[1].trim();
const sel = `${darkPrefix}.${escapeSelector(cls)}${variantSel}:before`;
rule = `${sel} { ${properties} }`;
}
}
}
}
if (rule) {
const bucket = mediaQuery || 'default';
if (!stylesByMedia[bucket]) stylesByMedia[bucket] = new Set();
stylesByMedia[bucket].add(rule);
}
}
cssOutput += Array.from(keyframesSet).join('\n') + '\n';
if (stylesByMedia.default) {
cssOutput += Array.from(stylesByMedia.default).join('\n') + '\n';
}
Object.keys(responsiveBreakpoints).forEach(bpKey => {
const mq = `@media ${responsiveBreakpoints[bpKey]}`;
if (stylesByMedia[mq] && stylesByMedia[mq].size > 0) {
cssOutput += `${mq} {\n${Array.from(stylesByMedia[mq]).map(r => ` ${r}`).join('\n')}\n}\n`;
}
});
if (config.customCSS && Array.isArray(config.customCSS)) {
config.customCSS.forEach(customFile => {
const customFilePath = path.resolve(process.cwd(), customFile);
if (fs.existsSync(customFilePath)) {
cssOutput += fs.readFileSync(customFilePath, 'utf-8') + '\n';
} else {
console.warn(`Warning: Custom CSS file not found: ${customFilePath}`);
}
});
}
fs.writeFileSync(OUTPUT_CSS, cssOutput);
console.log(`CSS generated: ${OUTPUT_CSS}`);
await subsetWOFF2(Array.from(usedClasses).map(stripAllPrefixes));
}
function stripAllPrefixes(token) {
let rest = token;
let changed = true;
while (changed) {
changed = false;
for (const t of allVariantTokens) {
if (rest.startsWith(t)) {
rest = rest.slice(t.length);
changed = true;
}
}
}
return rest;
}
async function subsetWOFF2(usedClasses) {
try {
const iconsMeta = JSON.parse(await fs.readFile(META_ICONS, 'utf-8'));
const unicodes = usedClasses
.filter(cls => cls.startsWith('if-') && iconsMeta[cls])
.map(cls => {
const iconStyle = iconsMeta[cls];
let content = null;
if (typeof iconStyle === 'object' && iconStyle.value) {
content = iconStyle.value;
} else if (typeof iconStyle === 'string') {
content = iconStyle;
}
if (content) {
const match = content.match(/content:\s*'\\([0-9a-fA-F]+)'/);
if (match && match[1]) {
return String.fromCodePoint(parseInt(match[1], 16));
}
}
return null;
})
.filter(Boolean)
.join('');
if (!unicodes.length) {
console.log('No icons to subset, copying full font...');
await fs.copyFile(path.join(DIST_DIR, 'iconforge.woff2'), OUTPUT_WOFF2);
return;
}
const fontBuffer = await fs.readFile(path.join(DIST_DIR, 'iconforge.woff2'));
const subsetBuffer = await subsetFont(fontBuffer, unicodes, {
targetFormat: 'woff2',
getFontName: (name) => `${name}-subset`,
});
await fs.writeFile(OUTPUT_WOFF2, subsetBuffer);
const originalSize = fontBuffer.length;
const newSize = subsetBuffer.length;
const reduction = ((originalSize - newSize) / originalSize * 100).toFixed(1);
console.log(`File size reduced by ${reduction}% (${originalSize} → ${newSize} bytes)`);
} catch (err) {
console.error('Error during font subsetting:', err);
await fs.copyFile(path.join(DIST_DIR, 'iconforge.woff2'), OUTPUT_WOFF2);
}
}
function runInit() {
if (fs.existsSync(CJS_CONFIG_PATH) || fs.existsSync(JS_CONFIG_PATH)) {
console.log('iconforge.config.js or .cjs already exists.');
return;
}
const configContent = `module.exports = {
content: [
'./**/*.{html,js,ts,vue,jsx,tsx}',
],
// Add any classes that are generated dynamically and not found by the parser.
safelist: [],
customCSS: [],
};
`;
fs.writeFileSync(CJS_CONFIG_PATH, configContent);
console.log('iconforge.config.cjs created successfully.');
}
async function getInputGlob(cliArgs, config) {
if (cliArgs[1]) return cliArgs[1];
if (config.content && Array.isArray(config.content) && config.content.length > 0) {
return config.content;
}
return ['**/*.{html,js,ts,vue,jsx,tsx}'];
}
async function runBuild(inputGlob) {
const patterns = [].concat(inputGlob);
const files = patterns.flatMap(pattern => glob.sync(pattern, { nodir: true, ignore: ['node_modules/**'] }));
let combinedContent = '';
[...new Set(files)].forEach(file => {
combinedContent += fs.readFileSync(file, 'utf-8') + '\n';
});
await buildCSS(combinedContent);
}
async function runWatch(inputGlob) {
console.log(`Watching for changes...`);
const watcher = chokidar.watch(inputGlob, { ignored: ['node_modules/**'], ignoreInitial: true });
const buildOnChange = async (filePath) => {
console.log(`File changed: ${filePath}. Rebuilding...`);
await runBuild(inputGlob);
};
watcher.on('change', buildOnChange);
watcher.on('add', buildOnChange);
watcher.on('unlink', buildOnChange);
}
async function main() {
const args = process.argv.slice(2);
const mode = args[0] || 'build';
if (mode === 'init') {
runInit();
return;
}
const config = await loadConfig();
const inputGlob = await getInputGlob(args, config);
if (mode === 'build') {
await runBuild(inputGlob);
} else if (mode === 'watch') {
await runBuild(inputGlob);
await runWatch(inputGlob);
} else {
console.log(`Unknown command: ${mode}`);
}
}
main().catch(e => {
console.error("An unexpected error occurred:", e);
process.exit(1);
});