fontgen-cli
Version:
An awesome CLI (Terminal) Tool made using NodeJS and PNPM as the package manager to manage fonts in TailwindCSS! Easily add local fonts by using a specified folder and input into the @theme variable in Tailwind css file.
299 lines (261 loc) ⢠11.7 kB
JavaScript
import fs from 'fs-extra';
import path from 'path';
import inquirer from 'inquirer';
import { sync as globSync } from 'glob';
import chalk from 'chalk';
import fuzzy from 'fuzzy'; // For fuzzy searching and tab completion
const { prompt } = inquirer;
const projectRoot = process.cwd();
// Compute relative URL from CSS file to font file
function relativeUrl(from, to) {
let rel = path.relative(path.dirname(from), to).replace(/\\/g, '/');
return rel.startsWith('.') ? rel : `./${rel}`;
}
// Prompt for a valid fonts root with interactive directory selection
async function askFontsRoot() {
while (true) {
const { root } = await prompt({
name: 'root',
type: 'autocomplete', // Use autocomplete for directory selection
message: 'Enter the path to your top-level fonts folder:',
default: 'src/fonts',
source: async (answersSoFar, input = '') => {
const currentDir = projectRoot;
const files = await fs.readdir(currentDir);
const directories = files.filter(file =>
fs.statSync(path.join(currentDir, file)).isDirectory() && !file.startsWith('.')
);
const fuzzyResult = fuzzy.filter(input, directories);
return fuzzyResult.map(el => el.original);
},
validate: async (v) => {
if (!v) return 'Path is required.';
const full = path.join(projectRoot, v);
if (!(await fs.pathExists(full)) || !(await fs.stat(full)).isDirectory()) {
return `ā Directory not found: ${full}`;
}
return true;
},
});
return path.join(projectRoot, root);
}
}
// Let user pick one or more families with clearer options
async function chooseFamilies(fontsRoot) {
const subdirectories = (await fs.readdir(fontsRoot))
.filter(d => fs.statSync(path.join(fontsRoot, d)).isDirectory() && !d.startsWith('.'));
if (subdirectories.length === 0) {
console.log(chalk.yellow('ā No font family folders found in the specified directory.'));
process.exit(0);
}
const choices = [
{ name: chalk.bold('All Font Families'), value: '__ALL__' },
new inquirer.Separator(),
...subdirectories.map(d => ({ name: d, value: path.join(fontsRoot, d) })),
];
const { families } = await prompt({
name: 'families',
type: 'checkbox',
message: 'Select the font families to include:',
choices,
validate: v => v.length > 0 || 'Please select at least one font family.',
});
if (families.includes('__ALL__')) return subdirectories.map(d => path.join(fontsRoot, d));
return families;
}
// Ask for Tailwind CSS file with interactive file selection
async function askTailwindFile() {
while (true) {
const { tw } = await prompt({
name: 'tw',
type: 'autocomplete', // Use autocomplete for file selection
message: 'Enter the path to your main Tailwind CSS file:',
default: 'src/app.css',
source: async (answersSoFar, input = '') => {
const currentDir = projectRoot;
const files = await fs.readdir(currentDir);
const fuzzyResult = fuzzy.filter(input, files.filter(f => !f.startsWith('.')));
return fuzzyResult.map(el => el.original);
},
validate: async (v) => {
if (!v) return 'File path is required.';
const fullPath = path.join(projectRoot, v);
if (!(await fs.pathExists(fullPath)) || !(await fs.stat(fullPath)).isFile()) {
return `ā File not found: ${fullPath}`;
}
return true;
},
});
return path.join(projectRoot, tw);
}
}
// Ask for the output CSS file for Standard CSS
async function askStandardCssFile() {
const { output } = await prompt({
name: 'output',
type: 'input',
message: 'Enter the desired path for the generated fonts CSS file:',
default: 'src/fonts.css',
});
return path.join(projectRoot, output);
}
// Remove all @theme and @font-face blocks for given families (improved regex)
function cleanCss(css, familyNames) {
// Remove any @theme block entirely
css = css.replace(/@theme\s*\{[\s\S]*?\}/g, '');
familyNames.forEach(name => {
// More specific regex to target our font-face blocks
const fontFaceRegex = new RegExp(
`\\/\\* Custom @font-face rules for ${name} \\*\\/[\\s\\S]*?(?=@theme|\\/\\* Custom @font-face rules for|$)`,
'g'
);
css = css.replace(fontFaceRegex, '');
// Also remove individual @font-face rules that might be left
const individualFontFaceRegex = new RegExp(
`@font-face\\s*\\{[\\s\\S]*?font-family:\\s*"${name}-[^"]+"[\\s\\S]*?\\}`,
'g'
);
css = css.replace(individualFontFaceRegex, '');
});
// Also tidy up multiple blank lines
css = css.replace(/\n{3,}/g, '\n\n');
return css;
}
async function main() {
const { cssType } = await prompt({
name: 'cssType',
type: 'list',
message: 'Choose the type of CSS to generate:',
choices: ['TailwindCSS', 'Standard CSS'],
});
const fontsRoot = await askFontsRoot();
const familyDirs = await chooseFamilies(fontsRoot);
const familyNames = familyDirs.map(fd => path.basename(fd));
if (cssType === 'TailwindCSS') {
const twPath = await askTailwindFile();
let css = await fs.readFile(twPath, 'utf-8');
const cleanMode = process.argv.includes('--clean');
if (cleanMode) {
css = cleanCss(css, familyNames);
await fs.writeFile(twPath, css, 'utf-8');
console.log(chalk.green(`š Successfully removed font configurations for: ${familyNames.join(', ')}`));
process.exit(0);
}
// ADD MODE:
// Remove existing theme and custom font-face blocks to avoid duplicates
css = css
.replace(/@theme\s*\{[\s\S]*?\}/g, '')
.replace(/\/\* Custom @font-face rules \*\/[\s\S]*/g, '');
const themeLines = ['@theme {'];
const faceLines = ['/* Custom @font-face rules */'];
for (const familyDir of familyDirs) {
const familyName = path.basename(familyDir).toLowerCase().replace(/ /g, '-');
// Search for any file within the family directory
const fontFiles = globSync(`*`, { cwd: familyDir, absolute: true });
const actualFontFiles = fontFiles.filter(file => {
const lowerCaseFile = file.toLowerCase();
return lowerCaseFile.endsWith('.ttf') || lowerCaseFile.endsWith('.otf') || lowerCaseFile.endsWith('.woff') || lowerCaseFile.endsWith('.woff2');
});
if (!actualFontFiles.length) {
console.warn(chalk.yellow(`ā No font files (ttf, otf, woff, woff2) found under ${familyName}`));
continue;
}
const styleToFile = {};
actualFontFiles.forEach(fp => {
const base = path.basename(fp).split('.')[0];
// Attempt to extract style from filename (e.g., Roboto-BoldItalic -> BoldItalic)
const parts = base.split('-');
const familyPart = parts[0].toLowerCase() === familyName ? 1 : 0; // Skip family name if present
const style = parts.slice(familyPart).join('-').toLowerCase().replace(/_/g, '-');
if (style) styleToFile[style] = fp;
});
const styles = Object.keys(styleToFile).sort();
console.log(chalk.green(`ā [${familyName}] Found styles: ${styles.join(', ')}`));
// Generate Tailwind theme extensions
themeLines.push(` /* ${familyName} */`);
styles.forEach(style => {
const themeKey = `font-${familyName}-${style}`.toLowerCase().replace(/_/g, '-');
themeLines.push(` --${themeKey}: "${familyName}-${style}", sans-serif;`);
});
// Generate @font-face rules
faceLines.push(`\n/* Custom @font-face rules for ${familyName} */`);
styles.forEach(style => {
const filePath = styleToFile[style];
const url = relativeUrl(twPath, filePath);
const fontWeight = style.includes('bold') ? '700' : (style.includes('medium') ? '500' : '400');
const fontStyle = style.includes('italic') ? 'italic' : 'normal';
const fontFaceName = `${familyName}-${style}`.toLowerCase().replace(/_/g, '-');
const format = path.extname(filePath).slice(1); // Extract extension for format
faceLines.push(
`@font-face {`,
` font-family: "${fontFaceName}";`,
` src: url("${url}") format("${format}");`,
` font-weight: ${fontWeight};`,
` font-style: ${fontStyle};`,
`}`,
''
);
});
}
themeLines.push('}', '');
css += '\n' + themeLines.join('\n') + '\n' + faceLines.join('\n');
await fs.writeFile(twPath, css, 'utf-8');
console.log(chalk.green('\nš Tailwind CSS font integration completed successfully!'));
} else {
// Standard CSS logic
const cssOutputPath = await askStandardCssFile();
let css = '';
for (const familyDir of familyDirs) {
const familyName = path.basename(familyDir).toLowerCase().replace(/ /g, '-');
// Search for any file within the family directory
const fontFiles = globSync(`*`, { cwd: familyDir, absolute: true });
const actualFontFiles = fontFiles.filter(file => {
const lowerCaseFile = file.toLowerCase();
return lowerCaseFile.endsWith('.ttf') || lowerCaseFile.endsWith('.otf') || lowerCaseFile.endsWith('.woff') || lowerCaseFile.endsWith('.woff2');
});
if (!actualFontFiles.length) {
console.warn(chalk.yellow(`ā No font files (ttf, otf, woff, woff2) found under ${familyName}`));
continue;
}
const styleToFile = {};
actualFontFiles.forEach(fp => {
const base = path.basename(fp).split('.')[0];
const parts = base.split('-');
const familyPart = parts[0].toLowerCase() === familyName ? 1 : 0;
const style = parts.slice(familyPart).join('-').toLowerCase().replace(/_/g, '-');
if (style) styleToFile[style] = fp;
});
const styles = Object.keys(styleToFile).sort();
console.log(chalk.green(`ā [${familyName}] Found styles: ${styles.join(', ')}`));
styles.forEach(style => {
const filePath = styleToFile[style];
const url = relativeUrl(cssOutputPath, filePath);
const fontWeight = style.includes('bold') ? '700' : (style.includes('medium') ? '500' : '400');
const fontStyle = style.includes('italic') ? 'italic' : 'normal';
const fontFaceName = `${familyName}-${style}`.toLowerCase().replace(/_/g, '-');
const className = `font-${fontFaceName}`;
const format = path.extname(filePath).slice(1);
// Generate @font-face
css += `@font-face {\n`;
css += ` font-family: "${fontFaceName}";\n`;
css += ` src: url("${url}") format("${format}");\n`;
css += ` font-weight: ${fontWeight};\n`;
css += ` font-style: ${fontStyle};\n`;
css += `}\n\n`;
// Generate CSS class
css += `.${className} {\n`;
css += ` font-family: "${fontFaceName}", sans-serif;\n`;
css += ` font-weight: ${fontWeight};\n`;
css += ` font-style: ${fontStyle};\n`;
css += `}\n\n`;
});
}
await fs.writeFile(cssOutputPath, css, 'utf-8');
console.log(chalk.green('\nš Standard CSS font generation completed successfully!'));
}
}
main().catch(err => {
console.error(chalk.red('š„ An error occurred:'), chalk.red(err.message || err));
process.exit(1);
});