replacer-util
Version:
Find and replace strings or template outputs in text files (CLI tool designed for use in npm package.json scripts)
236 lines (234 loc) • 12.3 kB
JavaScript
//! replacer-util v1.6.3 ~~ https://github.com/center-key/replacer-util ~~ MIT License
import { cliArgvUtil } from 'cli-argv-util';
import { globSync } from 'glob';
import { isBinary } from 'istextorbinary';
import { Liquid } from 'liquidjs';
import chalk from 'chalk';
import fs from 'node:fs';
import log from 'fancy-log';
import os from 'node:os';
import path from 'node:path';
import slash from 'slash';
const task = {
cleanPath(folder) {
const string = typeof folder === 'string' ? folder : '';
const trailingSlash = /\/$/;
return slash(path.normalize(string)).trim().replace(trailingSlash, '');
},
isTextFile(filename) {
return fs.statSync(filename).isFile() && !isBinary(filename);
},
};
const replacer = {
assert(ok, message) {
if (!ok)
throw new Error(`[replacer-util] ${message}`);
},
cli() {
const validFlags = ['cd', 'concat', 'content', 'exclude', 'ext', 'find', 'header',
'no-liquid', 'no-source-map', 'non-recursive', 'note', 'quiet', 'regex', 'rename',
'replacement', 'summary', 'title-sort', 'virtual-input'];
const cli = cliArgvUtil.parse(validFlags);
const source = cli.params[0];
const target = cli.params[1];
const badRegex = cli.flagOn.regex && !/^\/.*\/[a-z]*$/.test(cli.flagMap.regex);
const missingContent = cli.flagOn.virtualInput && !cli.flagMap.content;
const missingRename = cli.flagOn.virtualInput && !cli.flagMap.rename;
const error = cli.invalidFlag ? cli.invalidFlagMsg :
!source ? 'Missing source folder.' :
!target ? 'Missing target folder.' :
badRegex ? 'Regex must be enclosed in slashes.' :
missingContent ? 'Use the --content flag to set the source.' :
missingRename ? 'Use the --rename flag to specify the output filename.' :
cli.paramCount > 2 ? 'Extraneous parameter: ' + cli.params[2] :
null;
replacer.assert(!error, error);
const sourceFile = path.join(cli.flagMap.cd ?? '', source);
const isFile = fs.existsSync(sourceFile) && fs.statSync(sourceFile).isFile();
const sourceFolder = isFile ? path.dirname(source) : source;
const regex = cli.flagMap.regex?.substring(1, cli.flagMap.regex.lastIndexOf('/'));
const regexCodes = cli.flagMap.regex?.replace(/.*\//, '');
replacer.assert(!cli.flagOn.virtualInput || !isFile, 'Source must be a folder not a file.');
const options = {
cd: cli.flagMap.cd ?? null,
concat: cli.flagMap.concat ?? null,
content: cli.flagMap.content ?? null,
exclude: cli.flagMap.exclude ?? null,
extensions: cli.flagMap.ext?.split(',') ?? [],
filename: isFile ? path.basename(source) : null,
find: cli.flagMap.find ?? null,
header: cli.flagMap.header ?? null,
nonRecursive: cli.flagOn.nonRecursive,
noSourceMap: cli.flagOn.noSourceMap,
regex: cli.flagMap.regex ? new RegExp(regex, regexCodes) : null,
rename: cli.flagMap.rename ?? null,
replacement: cli.flagMap.replacement ?? null,
templatingOn: !cli.flagOn.noLiquid,
titleSort: cli.flagOn.titleSort,
virtualInput: cli.flagOn.virtualInput,
};
const results = replacer.transform(sourceFolder, target, options);
if (!cli.flagOn.quiet)
replacer.reporter(results, { summaryOnly: cli.flagOn.summary });
},
transform(sourceFolder, targetFolder, options) {
const defaults = {
cd: null,
concat: null,
content: null,
exclude: null,
extensions: [],
filename: null,
find: null,
header: null,
nonRecursive: false,
noSourceMap: false,
regex: null,
rename: null,
replacement: null,
templatingOn: true,
titleSort: false,
virtualInput: false,
};
const settings = { ...defaults, ...options };
const startTime = Date.now();
const startFolder = settings.cd ? task.cleanPath(settings.cd) + '/' : '';
const source = task.cleanPath(startFolder + sourceFolder);
const target = task.cleanPath(startFolder + targetFolder);
const concatFile = settings.concat ? path.join(target, settings.concat) : null;
const missingFind = !settings.find && !settings.regex && !!settings.replacement;
const invalidSort = settings.titleSort && !settings.concat;
if (targetFolder)
fs.mkdirSync(target, { recursive: true });
const error = !sourceFolder ? 'Must specify the source folder path.' :
!targetFolder ? 'Must specify the target folder path.' :
!fs.existsSync(source) ? 'Source folder does not exist: ' + source :
!fs.existsSync(target) ? 'Target folder cannot be created: ' + target :
!fs.statSync(source).isDirectory() ? 'Source is not a folder: ' + source :
!fs.statSync(target).isDirectory() ? 'Target is not a folder: ' + target :
missingFind ? 'Must specify search text with --find or --regex' :
invalidSort ? 'Use of --titleSort requires --concat' :
null;
replacer.assert(!error, error);
const getNewFilename = (file) => {
const baseNameLoc = () => file.length - path.basename(file).length;
const relativePath = () => file.substring(source.length, baseNameLoc());
const newFilename = () => target + relativePath() + settings.rename;
return settings.rename ? newFilename() : null;
};
const outputFilename = (file) => target + '/' + file.substring(source.length + 1);
const getFileRoute = (file) => ({
origin: file,
dest: concatFile ?? getNewFilename(file) ?? outputFilename(file),
});
const titleCase = () => {
const psuedo = /\/index\.[a-z]*$/;
const leadingArticle = /^(a|an|the)[- _]/;
const toTitle = (filename) => path.basename(filename.replace(psuedo, '')).toLowerCase().replace(leadingArticle, '');
return (a, b) => toTitle(a).localeCompare(toTitle(b));
};
const correctType = (file) => {
const extInList = settings.extensions.includes(path.extname(file));
return task.isTextFile(file) || (settings.content && extInList);
};
const wildcard = settings.nonRecursive ? '/*' : '/**/*';
const readPaths = (ext) => globSync(source + wildcard + ext).map(slash);
const comparator = settings.titleSort ? titleCase() : undefined;
const getFiles = () => exts.map(readPaths).flat().sort(comparator);
const keep = (file) => !settings.exclude || !file.includes(settings.exclude);
const exts = settings.extensions.length ? settings.extensions : [''];
const filename = settings.virtualInput ? '.' : settings.filename;
const filesRaw = filename ? [source + '/' + filename] : getFiles();
const filtered = filesRaw.filter(correctType).filter(keep);
const files = settings.virtualInput ? filesRaw : filtered;
const fileRoutes = files.map(file => slash(file)).map(getFileRoute);
const pkg = cliArgvUtil.readPackageJson();
const sourceMapLine = /^\/.#\ssourceMappingURL=.*\r?\n/gm;
const header = settings.header ? settings.header + os.EOL : '';
const replacement = settings.replacement ?? '';
const getFileInfo = (origin) => {
const parsedPath = path.parse(origin);
const dir = slash(parsedPath.dir);
const filePath = dir + '/' + slash(parsedPath.base);
const folder = path.basename(dir);
const date = fs.statSync(origin).mtime;
const dateFormat = { day: 'numeric', month: 'long', year: 'numeric' };
const modified = date.toLocaleString([], dateFormat);
const timestamp = date.toISOString();
return { ...parsedPath, dir, folder, path: filePath, date, modified, timestamp };
};
const getWebRoot = (origin) => {
const depth = origin.substring(source.length).split('/').length - 2;
return depth === 0 ? '.' : '..' + '/..'.repeat(depth - 1);
};
const createEngine = (file) => {
const globals = {
package: pkg,
file: getFileInfo(file.origin),
webRoot: getWebRoot(file.origin),
};
const engine = new Liquid({ globals });
const versionFormatter = (numIds) => (str) => str.replace(/[^0-9]*/, '').split('.').slice(0, numIds).join('.');
engine.registerFilter('version', versionFormatter(3));
engine.registerFilter('minor-version', versionFormatter(2));
engine.registerFilter('major-version', versionFormatter(1));
return engine;
};
const extractPageVars = (engine, file) => {
const tags = engine.parseFileSync(file);
const toPair = (tag) => [tag.key, tag.value.initial.postfix[0]?.content];
const tagPairs = tags.filter(tag => tag.name === 'assign').map(toPair);
return Object.fromEntries(tagPairs);
};
const eofNewline = (text) => text.endsWith(os.EOL) ? text : text + os.EOL;
const processFile = (file, index) => {
const engine = createEngine(file);
const needVars = settings.content && !settings.virtualInput && task.isTextFile(file.origin);
const pageVars = needVars ? extractPageVars(engine, file.origin) : {};
const render = (text) => String(engine.parseAndRenderSync(text, pageVars));
const append = settings.concat && index > 0;
const altText = settings.content ? render(settings.content) : null;
const text = altText ?? fs.readFileSync(file.origin, 'utf-8');
const content = render(header) + text;
const newStr = render(replacement);
const out1 = settings.templatingOn ? render(content) : content;
const out2 = settings.find ? out1.replaceAll(settings.find, newStr) : out1;
const out3 = settings.regex ? out2.replace(settings.regex, newStr) : out2;
const out4 = settings.noSourceMap ? out3.replace(sourceMapLine, '') : out3;
const out5 = eofNewline(out4.trimStart());
const final = append && settings.header ? os.EOL + out5 : out5;
fs.mkdirSync(path.dirname(file.dest), { recursive: true });
return append ? fs.appendFileSync(file.dest, final) : fs.writeFileSync(file.dest, final);
};
fileRoutes.forEach(processFile);
const relativePaths = (file) => ({
origin: file.origin.substring(source.length + 1),
dest: file.dest.substring(target.length + 1),
});
const results = {
source: source,
target: target,
count: fileRoutes.length,
duration: Date.now() - startTime,
files: fileRoutes.map(relativePaths),
};
return results;
},
reporter(results, options) {
const defaults = {
summaryOnly: false,
};
const settings = { ...defaults, ...options };
const name = chalk.gray('replacer');
const indent = chalk.gray('|');
const ancestor = cliArgvUtil.calcAncestor(results.source, results.target);
const infoColor = results.count ? chalk.white : chalk.red.bold;
const info = infoColor(`(files: ${results.count}, ${results.duration}ms)`);
log(name, ancestor.message, info);
const logFile = (file) => log(name, indent, cliArgvUtil.calcAncestor(file.origin, file.dest).message);
if (!settings.summaryOnly)
results.files.forEach(logFile);
return results;
},
};
export { replacer };