UNPKG

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
//! 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 };