@ikona/cli
Version:
528 lines (502 loc) • 15.2 kB
JavaScript
// src/icons/generate-sprite.ts
import fsExtra3 from "fs-extra";
import { glob } from "glob";
// src/icons/generate-icon-files.ts
import crypto from "crypto";
import fsExtra2 from "fs-extra";
import * as path2 from "node:path";
// src/utils/validations.ts
import fsExtra from "fs-extra";
import { join, extname } from "node:path";
// src/utils/hash.ts
function addHashToSpritePath(path5, hash) {
return path5.replace(/\.svg$/, `.${hash}.svg`);
}
// src/utils/validations.ts
function clear(folderPath) {
fsExtra.readdir(folderPath, (err, files) => {
if (err) {
console.error("Error reading folder:", err);
return;
}
const svgFiles = files.filter(
(file) => extname(file).toLowerCase() === ".svg" && file.startsWith("sprite")
);
svgFiles.forEach((svgFile) => {
const filePath = join(folderPath, svgFile);
fsExtra.unlink(filePath, (err2) => {
if (err2) {
console.error(`Error removing file ${filePath}:`, err2);
} else {
console.log(`Removed file: ${filePath}`);
}
});
});
});
}
async function writeIfChanged({
filepath,
newContent,
hash,
force
}) {
let _filepath = filepath;
if (hash) {
_filepath = addHashToSpritePath(filepath, hash);
}
const currentContent = await fsExtra.readFile(_filepath, "utf8").catch(() => "");
const shouldSkip = currentContent === newContent && force !== true;
if (shouldSkip)
return false;
if (hash) {
const folder = filepath.replace(/sprite\.svg$/, ``);
clear(folder);
}
await fsExtra.writeFile(_filepath, newContent, "utf8");
return true;
}
// src/utils/file.ts
function calculateFileSizeInKB(str) {
const buffer = Buffer.from(str, "utf-8");
const fileSizeInKB = buffer.length / 1024;
return fileSizeInKB;
}
// src/icons/templates/svg-sprite.ts
import { parse } from "node-html-parser";
function svgSpriteTemplate(iconsData) {
const symbols = iconsData.map((iconData) => {
const input = iconData.content;
const root = parse(input);
const svg = root.querySelector("svg");
if (!svg)
throw new Error("No SVG element found");
svg.tagName = "symbol";
svg.setAttribute("id", iconData.name);
svg.removeAttribute("xmlns");
svg.removeAttribute("xmlns:xlink");
svg.removeAttribute("version");
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.removeAttribute("fill");
return svg.toString().trim();
});
return [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 0 0" width="0" height="0">`,
`<defs>`,
// for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs
...symbols,
`</defs>`,
`</svg>`,
""
// trailing newline
].join("\n");
}
// src/icons/icon-name.ts
function iconName(file) {
return file.replace(/\.svg$/, "").replace(/\\/g, "/");
}
// src/icons/get-icons-data.ts
import fs from "fs-extra";
import path from "node:path";
function getIconsData({
files,
inputDir
}) {
return files.map((file) => {
const name = iconName(file);
const content = fs.readFileSync(path.join(inputDir, file), "utf8");
return { name, content };
});
}
// src/icons/templates/type.ts
var typeTemplate = (iconNames) => `export type IconName =
| ${iconNames.join("\n | ").replace(/"/g, "'")};
`;
// src/icons/templates/icons.ts
var iconsTemplate = (iconNames) => `import { IconName } from './types/icon-name';
export const icons = [
${iconNames.join(",\n ")},
] satisfies Array<IconName>;
`;
// src/icons/templates/hash.ts
var hashTemplate = (hash) => `export const hash = '${hash}';
`;
// src/utils/optimize.ts
import { optimize as svgOptimize } from "svgo";
var defaultSVGOConfig = {
multipass: true,
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeHiddenElems: false,
removeUselessDefs: false,
cleanupIds: false,
convertColors: {
currentColor: true
},
removeViewBox: false
}
}
}
],
js2svg: {
indent: 2,
pretty: true
}
};
function optimize(output, options = defaultSVGOConfig) {
return svgOptimize(output, options).data;
}
// src/icons/generate-icon-files.ts
async function generateIconFiles({
files,
context
}) {
const {
spriteFilepath,
typeOutputFilepath,
iconsPath,
hashPath,
inputDir,
shouldOptimize,
shouldHash,
force
} = context;
const currentSprite = await fsExtra2.readFile(spriteFilepath, "utf8").catch(() => "");
const currentTypes = await fsExtra2.readFile(typeOutputFilepath, "utf8").catch(() => "");
const iconNames = files.map((file) => iconName(file));
const spriteUpToDate = iconNames.every(
(name) => currentSprite.includes(`id=${name}`)
);
const typesUpToDate = iconNames.every(
(name) => currentTypes.includes(`"${name}"`)
);
if (spriteUpToDate && typesUpToDate) {
console.log(`Icons are up to date`);
return {
hash: void 0
};
}
const iconsData = getIconsData({
files,
inputDir
});
if (shouldOptimize) {
for (const icon of iconsData) {
icon.content = optimize(icon.content, context.svgoConfig);
}
}
const output = svgSpriteTemplate(iconsData);
let hash;
if (shouldHash) {
hash = crypto.createHash("md5").update(output).digest("hex");
}
const spriteChanged = await writeIfChanged({
filepath: spriteFilepath,
newContent: output,
hash,
force
});
if (spriteChanged) {
console.log(`Generating sprite for ${inputDir}`);
for (const file of files) {
console.log("\u2705", file);
}
console.log(`File size: ${calculateFileSizeInKB(output)} KB`);
if (shouldHash) {
console.log(`Generated sprite with hash ${hash}`);
console.log(
`Saved to ${path2.relative(
process.cwd(),
addHashToSpritePath(spriteFilepath, hash)
)}`
);
} else {
console.log(`Saved to ${path2.relative(process.cwd(), spriteFilepath)}`);
}
}
const stringifiedIconNames = iconNames.map((name) => JSON.stringify(name));
const typeOutputContent = typeTemplate(stringifiedIconNames);
const typesChanged = await writeIfChanged({
filepath: typeOutputFilepath,
newContent: typeOutputContent,
force
});
if (typesChanged) {
console.log(
`Types saved to ${path2.relative(process.cwd(), typeOutputFilepath)}`
);
}
const iconsOutputFilepath = path2.join(iconsPath);
const iconsOutputContent = iconsTemplate(stringifiedIconNames);
const iconsChanged = await writeIfChanged({
filepath: iconsOutputFilepath,
newContent: iconsOutputContent,
force
});
if (iconsChanged) {
console.log(
`Icons names saved to ${path2.relative(
process.cwd(),
iconsOutputFilepath
)}`
);
}
if (shouldHash && hash) {
const hashOutputFilepath = path2.join(hashPath);
const hashFileContent = hashTemplate(hash);
const hashFileChanged = await writeIfChanged({
filepath: hashOutputFilepath,
newContent: hashFileContent,
force
});
if (hashFileChanged) {
console.log(
`Hash file saved to ${path2.relative(process.cwd(), hashOutputFilepath)}`
);
}
}
if (spriteChanged || typesChanged || iconsChanged) {
console.log(`Generated ${files.length} icons`);
} else {
console.log(`Icons are up to date`);
}
return {
hash
};
}
// src/icons/context.ts
import * as path3 from "node:path";
var createIconsContext = (config) => {
const { outputDir, icons, force, cwd } = config;
const inputDirRelative = path3.join(cwd, icons.inputDir);
const outputDirRelative = path3.join(cwd, outputDir);
const spriteOutputDirRelative = path3.join(cwd, icons.spriteOutputDir);
const spriteFilepath = path3.join(cwd, icons.spriteOutputDir, "sprite.svg");
const typesDir = path3.join(cwd, outputDir, "types");
const typeOutputFilepath = path3.join(typesDir, "icon-name.d.ts");
const iconsPath = path3.join(cwd, outputDir, "icons.ts");
const hashPath = path3.join(cwd, outputDir, "hash.ts");
return {
inputDir: inputDirRelative,
outputDir: outputDirRelative,
spriteOutputDir: spriteOutputDirRelative,
spriteFilepath,
typesDir,
typeOutputFilepath,
iconsPath,
hashPath,
shouldOptimize: icons.optimize,
shouldHash: icons.hash,
force,
svgoConfig: icons.svgoConfig
};
};
// src/icons/generate-sprite.ts
async function generateSprite(config) {
const context = createIconsContext(config);
const files = glob.sync("**/*.svg", {
cwd: context.inputDir
}).sort((a, b) => a.localeCompare(b));
if (files.length === 0) {
console.log(`No SVG files found in ${context.inputDir}`);
} else {
await Promise.all([
fsExtra3.ensureDir(context.outputDir),
fsExtra3.ensureDir(context.spriteOutputDir),
fsExtra3.ensureDir(context.typesDir)
]);
await generateIconFiles({ files, context });
}
}
// src/illustrations/types.ts
import fsExtra4 from "fs-extra";
import { glob as glob2 } from "glob";
import * as path4 from "node:path";
// src/illustrations/templates/illustrations.ts
var illustrationsTemplate = (illustrationNames) => `import { IllustrationPath } from './types/illustration-path';
export const illustrations = [
${illustrationNames.join(",\n ")},
] satisfies Array<IllustrationPath>;
`;
// src/illustrations/templates/paths.ts
var pathsTemplate = (illustrationNames) => `export type IllustrationPath =
| ${illustrationNames.join("\n | ").replace(/"/g, "'")};
`;
// src/utils/glob.ts
function getIllustrationsExtensionsGlobPattern(extensions) {
return `**/*.{${extensions.join(",")}}`;
}
// src/illustrations/types.ts
async function generateTypes({
files,
typeDir,
outputDir,
force
}) {
const typeOutputFilepath = path4.join(typeDir, "illustration-path.d.ts");
const currentTypes = await fsExtra4.readFile(typeOutputFilepath, "utf8").catch(() => "");
const typesUpToDate = files.every(
(path5) => currentTypes.includes(`"${path5}"`)
);
if (typesUpToDate) {
console.log("Illustrations are up to date");
return;
}
const stringifiedIllustrationNames = files.map(
(path5) => JSON.stringify(`/illustrations/${path5}`)
);
const typeOutputContent = pathsTemplate(stringifiedIllustrationNames);
const typesChanged = await writeIfChanged({
filepath: typeOutputFilepath,
newContent: typeOutputContent,
force
});
if (typesChanged) {
for (const file of files) {
console.log("\u2705", file);
}
console.log(
`Types saved to ${path4.relative(process.cwd(), typeOutputFilepath)}`
);
}
const illustrationsOutputFilepath = path4.join(outputDir, "illustrations.ts");
const illustrationsOutputContent = illustrationsTemplate(
stringifiedIllustrationNames
);
const illustrationsChanged = await writeIfChanged({
filepath: illustrationsOutputFilepath,
newContent: illustrationsOutputContent,
force
});
if (illustrationsChanged) {
console.log(
`Illustrations saved to ${path4.relative(
process.cwd(),
illustrationsOutputFilepath
)}`
);
}
if (typesChanged || illustrationsChanged) {
console.log(`Generated ${files.length} icons`);
} else {
console.log(`Illustrations are up to date`);
}
}
async function generateIllustrationTypes(config) {
const outputDir = config.outputDir;
const { inputDir } = config.illustrations;
const cwd = process.cwd();
const inputDirRelative = path4.relative(cwd, inputDir);
const outputDirRelative = path4.join(cwd, outputDir);
const typeDirRelative = path4.join(cwd, outputDir, "types");
await Promise.all([
fsExtra4.ensureDir(inputDirRelative),
fsExtra4.ensureDir(outputDirRelative),
fsExtra4.ensureDir(typeDirRelative)
]);
const files = glob2.sync(
getIllustrationsExtensionsGlobPattern(config.illustrations.extensions),
{
cwd: inputDir
}
).sort((a, b) => a.localeCompare(b));
if (files.length === 0) {
console.log(`No illustration files found in ${inputDirRelative}`);
} else {
await generateTypes({
files,
typeDir: typeDirRelative,
outputDir: outputDirRelative,
force: config.force
});
}
}
// src/utils/config.ts
import { bundleNRequire } from "bundle-n-require";
import findUp from "escalade/sync";
import { resolve } from "path";
var defaultConfig = {
verbose: false,
outputDir: ".ikona",
force: false,
icons: {
optimize: false,
inputDir: "icons",
spriteOutputDir: "output",
hash: false
},
illustrations: {
inputDir: "illustrations",
extensions: ["svg", "png", "jpg", "jpeg", "webp"]
},
cwd: process.cwd()
};
var configs = [".ts", ".js", ".mts", ".mjs", ".cts", ".cjs"];
var configRegex = new RegExp(`ikona.config(${configs.join("|")})$`);
var isConfig = (file) => configRegex.test(file);
function findConfigFile({ cwd, file }) {
if (file)
return resolve(cwd, file);
return findUp(cwd, (_dir, paths) => {
return paths.find(isConfig);
});
}
async function bundle(filepath, cwd) {
const { mod: config, dependencies } = await bundleNRequire(filepath, {
cwd,
interopDefault: true
});
return { config: config?.default ?? config, dependencies };
}
var resolveFileConfig = async () => {
const currentDir = process.cwd();
const filePath = findConfigFile({ cwd: currentDir });
if (!filePath) {
throw new Error("Config file not found");
}
const { config } = await bundle(filePath, currentDir);
return config;
};
// src/utils/merge-config.ts
var mergeConfigs = ({ cliConfig, fileConfig }) => {
return {
verbose: cliConfig.v ?? cliConfig.verbose ?? fileConfig.verbose ?? defaultConfig.verbose,
outputDir: cliConfig["out-dir"] ?? fileConfig.outputDir ?? defaultConfig.outputDir,
force: cliConfig.force ?? fileConfig.force ?? defaultConfig.force,
icons: {
optimize: cliConfig.optimize ?? fileConfig.icons?.optimize ?? defaultConfig.icons.optimize,
inputDir: fileConfig.icons?.inputDir ?? defaultConfig.icons.inputDir,
// TODO add missing cli config flag
spriteOutputDir: fileConfig.icons?.spriteOutputDir ?? defaultConfig.icons.spriteOutputDir,
// TODO add missing cli config flag
hash: cliConfig.hash ?? fileConfig.icons?.hash ?? defaultConfig.icons.hash
},
illustrations: {
inputDir: fileConfig.illustrations?.inputDir ?? defaultConfig.illustrations.inputDir,
// TODO add missing cli config flag
extensions: fileConfig.illustrations?.extensions ?? defaultConfig.illustrations.extensions
// TODO add missing cli config flag
},
cwd: cliConfig.cwd ?? fileConfig.cwd ?? defaultConfig.cwd
};
};
// src/index.ts
function defineConfig(config) {
return config;
}
async function init(cliConfig) {
const fileConfig = await resolveFileConfig();
const config = mergeConfigs({ cliConfig, fileConfig });
await Promise.all([
generateSprite(config),
generateIllustrationTypes(config)
]);
}
export {
defineConfig,
init
};