UNPKG

@validkeys/ollypop-ts

Version:

Automatic TypeScript barrel file generator CLI.

379 lines 16.3 kB
import fs from 'fs'; import inflection from 'inflection'; import { minimatch } from 'minimatch'; import path from 'path'; export class TemplateEngine { templates = new Map(); constructor() { this.registerDefaultTemplates(); } registerDefaultTemplates() { this.templates.set('variable-template', (ctx) => this.generateVariableTemplate(ctx)); } generate(templateName, context) { const template = this.templates.get(templateName); if (!template) { throw new Error(`Template '${templateName}' not found`); } let content = template(context); if (context.banner) { content = this.addBanner(context.banner, content); } return content; } registerTemplate(name, template) { this.templates.set(name, template); } toPascalCase(str) { return str .replace(/[-_.]/g, ' ') .split(' ') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(''); } toCamelCase(str) { const pascal = this.toPascalCase(str); return pascal.charAt(0).toLowerCase() + pascal.slice(1); } toKebabCase(str) { return str .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/[\s_]+/g, '-') .toLowerCase(); } toSingular(str) { return inflection.singularize(str); } toPlural(str) { return inflection.pluralize(str); } trimPrefix(str, prefixes) { const prefixList = prefixes ? prefixes.split(',').map((p) => p.trim()) : ['warehouse-', 'ops-']; for (const prefix of prefixList) { if (str.startsWith(prefix)) { return str.substring(prefix.length); } } return str; } trimSuffix(str, suffixes) { if (!suffixes) { console.warn('trimSuffix requires suffixes parameter, e.g., trimSuffix:Service,Manager'); return str; } const suffixList = suffixes.split(',').map((s) => s.trim()); for (const suffix of suffixList) { if (str.endsWith(suffix)) { return str.substring(0, str.length - suffix.length); } } return str; } addPrefix(str, prefix) { if (!prefix) { console.warn('addPrefix requires prefix parameter, e.g., addPrefix:I,Base'); return str; } return prefix + str; } addSuffix(str, suffix) { if (!suffix) { console.warn('addSuffix requires suffix parameter, e.g., addSuffix:Factory,Service'); return str; } return str + suffix; } replaceText(str, replaceParams) { if (!replaceParams) { console.warn('replace requires parameters, e.g., replace:old,new or replace:pattern1,replacement1;pattern2,replacement2'); return str; } const replacements = replaceParams.split(';'); let result = str; for (const replacement of replacements) { const [search, replace] = replacement.split(',').map((s) => s.trim()); if (search && replace !== undefined) { result = result.split(search).join(replace); } } return result; } capitalize(str) { if (!str) return str; return str.charAt(0).toUpperCase() + str.slice(1); } uncapitalize(str) { if (!str) return str; return str.charAt(0).toLowerCase() + str.slice(1); } addBanner(banner, content) { const bannerLines = banner.split('\n').map((line) => `// ${line}`); return bannerLines.join('\n') + '\n\n' + content; } applyTransform(value, transform) { const [transformName, ...paramParts] = transform.split(':'); const params = paramParts.join(':'); switch (transformName.toLowerCase()) { case 'raw': return value; case 'camel': return this.toCamelCase(value); case 'kebab': return this.toKebabCase(value); case 'pascal': return this.toPascalCase(value); case 'singular': return this.toSingular(value); case 'plural': return this.toPlural(value); case 'trimprefix': case 'trim-prefix': return this.trimPrefix(value, params); case 'trimsuffix': case 'trim-suffix': return this.trimSuffix(value, params); case 'addprefix': case 'add-prefix': return this.addPrefix(value, params); case 'addsuffix': case 'add-suffix': return this.addSuffix(value, params); case 'replace': return this.replaceText(value, params); case 'uppercase': return value.toUpperCase(); case 'lowercase': return value.toLowerCase(); case 'capitalize': return this.capitalize(value); case 'uncapitalize': return this.uncapitalize(value); default: console.warn(`Unknown transformation: ${transformName}. Available: raw, camel, kebab, pascal, singular, plural, trimPrefix, trimSuffix, addPrefix, addSuffix, replace, uppercase, lowercase, capitalize, uncapitalize`); return value; } } generateVariableTemplate(ctx) { const { templateConfig, outputPath } = ctx; if (!templateConfig?.export) { throw new Error('Variable template requires an "export" configuration'); } const exportTemplate = templateConfig.export; if (ctx.options?.verbose) { console.log(`[ollypop / template] Using template: ${templateConfig.name}`); } const pathVariables = this.extractPathVariables(exportTemplate); if (ctx.options?.verbose) { console.log(`[ollypop / template] Path variables: ${pathVariables.join(', ')}`); } if (pathVariables.length === 0) { throw new Error('No path variables found in export template. Use {variableName} in the path portion.'); } const exportPathMatch = exportTemplate.match(/from ['"]([^'"]*)\{/); if (!exportPathMatch) { if (exportTemplate.includes('from ') && !exportTemplate.match(/from ['"][^'"]*['"]/) && !exportTemplate.includes('{')) { const pathMatch = exportTemplate.match(/from\s+([^\s{]+)/); const foundPath = pathMatch ? pathMatch[1] : 'unknown'; throw new Error(`Path in export template must be enclosed in quotes. Found: ${foundPath}\n` + `Correct format: export * from "${foundPath}/{variable}" or export * from '${foundPath}/{variable}'`); } else if (exportTemplate.includes('from ') && exportTemplate.match(/from ['"][^'"]*['"]/) && !exportTemplate.includes('{')) { const quotedPathMatch = exportTemplate.match(/from ['"]([^'"]+)['"]/); const foundPath = quotedPathMatch ? quotedPathMatch[1] : 'unknown'; throw new Error(`Export template path must contain at least one variable in curly braces. Found: ${foundPath}\n` + `Correct format: export * from "${foundPath}/{variable}" or export * from '${foundPath}/{variable}/'`); } if (ctx.options?.verbose) { console.warn(`[ollypop / template] No export path found in template: ${exportTemplate}`); console.warn("[ollypop / template] path match", exportPathMatch); } throw new Error('Cannot determine path from export template. Expected format: export * from "./path/{variable}/..." or export * as Name from "./path/{variable}/..."\n' + 'Common issues:\n' + ' - Path must be enclosed in quotes: export * from "./path/{variable}"\n' + ' - Path must contain at least one variable: {variable}\n' + ' - Variables must be in curly braces: {variableName}'); } let exportPath = exportPathMatch[1]; if (ctx.options?.verbose) { console.log(`[ollypop / template] Export path: ${exportPath}`); } if (exportPath.endsWith('/')) { exportPath = exportPath.slice(0, -1); } let workingDirectory; if (exportPath.startsWith('./')) { const outputDir = path.dirname(outputPath || '.'); const relativePath = exportPath.substring(2); if (!relativePath || relativePath === '.' || relativePath === '') { workingDirectory = outputDir; } else { workingDirectory = path.join(outputDir, relativePath); } } else if (exportPath.startsWith('../')) { const outputDir = path.dirname(outputPath || '.'); const cwd = process.cwd(); const absoluteOutputDir = path.resolve(cwd, outputDir); workingDirectory = path.resolve(absoluteOutputDir, exportPath); } else { if (exportPath === '.') { workingDirectory = path.dirname(outputPath || '.'); } else { workingDirectory = exportPath || '.'; } } const matchingPaths = this.findMatchingPaths(workingDirectory, pathVariables, exportTemplate, outputPath, ctx.options?.verbose, ctx.exclude || []); const filteredPaths = this.filterExistingFiles(matchingPaths, exportTemplate, outputPath || '.'); const lines = []; if (ctx.options?.verbose) { console.log(`[ollypop] Generating barrel file: ${outputPath}`); } for (const pathMatch of filteredPaths) { const resolvedExport = this.resolveVariableTemplate(exportTemplate, pathMatch.variables); lines.push(resolvedExport); } return lines.join('\n') + '\n'; } extractPathVariables(template) { const pathPattern = /\{([^}:]+)(?::(\w+))?\}/g; const variables = []; let match; while ((match = pathPattern.exec(template)) !== null) { const varName = match[1]; if (!variables.includes(varName)) { variables.push(varName); } } return variables; } isFileBasedPattern(template) { const fileExtensionPattern = /\{[^}:]+(?::[^}]+)?\}\.(ts|js|tsx|jsx|json|md)(?:['"`]|$)/; const fileVariablePattern = /\{file(?::[^}]+)?\}/; const variableAtEndPattern = /\{[^}:]+(?::[^}]+)?\}['"`]?\s*$/; return fileExtensionPattern.test(template) || fileVariablePattern.test(template) || variableAtEndPattern.test(template); } findMatchingPaths(cwd, pathVariables, exportTemplate, outputPath, verbose, excludePatterns = []) { const results = []; try { const entries = fs.readdirSync(cwd, { withFileTypes: true }); const isFileBasedPattern = this.isFileBasedPattern(exportTemplate); if (isFileBasedPattern) { let files = entries .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) .map((entry) => entry.name); if (excludePatterns.length > 0) { files = files.filter((fileName) => { return !excludePatterns.some((pattern) => minimatch(fileName, pattern, { dot: true })); }); } if (pathVariables.length === 1) { for (const file of files) { if (outputPath && path.basename(outputPath) === file) { continue; } const fileNameWithoutExt = path.parse(file).name; const variables = new Map([ [pathVariables[0], { value: fileNameWithoutExt, casing: 'raw' }], ]); results.push({ path: `${cwd}/${file}`, variables, }); } } } else { const directories = entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); if (pathVariables.length === 1) { for (const dir of directories) { const variables = new Map([[pathVariables[0], { value: dir, casing: 'raw' }]]); results.push({ path: `${cwd}/${dir}`, variables, }); } } } } catch (error) { console.warn(`Warning: Could not scan directory ${cwd}:`, error); } return results; } filterExistingFiles(paths, exportTemplate, outputPath) { const isFileBasedPattern = this.isFileBasedPattern(exportTemplate); if (isFileBasedPattern) { return paths; } return paths.filter((pathMatch) => { let resolvedPath = exportTemplate; for (const [varName, varData] of pathMatch.variables) { const variablePattern = new RegExp(`\\{${varName}(?::([^}]+))?\\}`, 'g'); let match; while ((match = variablePattern.exec(resolvedPath)) !== null) { const fullMatch = match[0]; const transformChain = match[1] || ''; let transformedValue = varData.value; if (transformChain) { const transforms = transformChain.split('|'); for (const transform of transforms) { transformedValue = this.applyTransform(transformedValue, transform.trim()); } } else { transformedValue = this.toPascalCase(transformedValue); } resolvedPath = resolvedPath.replace(fullMatch, transformedValue); variablePattern.lastIndex = 0; } } const importMatch = resolvedPath.match(/from\s+['"`]([^'"`]+)['"`]/); if (!importMatch) { return false; } let importPath = importMatch[1]; const outputDir = path.dirname(outputPath || '.'); if (importPath.startsWith('./')) { importPath = path.join(outputDir, importPath.substring(2)); } else if (importPath.startsWith('../')) { importPath = path.resolve(outputDir, importPath); } const tsPath = importPath.replace(/\.js$/, '.ts'); const exists = fs.existsSync(tsPath); return exists; }); } resolveVariableTemplate(template, variables) { let result = template; for (const [varName, varData] of variables) { const variablePattern = new RegExp(`\\{${varName}(?::([^}]+))?\\}`, 'g'); let match; while ((match = variablePattern.exec(result)) !== null) { const fullMatch = match[0]; const transformChain = match[1] || ''; let transformedValue = varData.value; if (transformChain) { const transforms = transformChain.split('|'); for (const transform of transforms) { transformedValue = this.applyTransform(transformedValue, transform.trim()); } } else { transformedValue = this.toPascalCase(transformedValue); } result = result.replace(fullMatch, transformedValue); variablePattern.lastIndex = 0; } } return result; } } //# sourceMappingURL=templates.js.map