@validkeys/ollypop-ts
Version:
Automatic TypeScript barrel file generator CLI.
373 lines • 16 kB
JavaScript
import fs from 'fs';
import inflection from 'inflection';
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);
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) {
const results = [];
try {
const entries = fs.readdirSync(cwd, { withFileTypes: true });
const isFileBasedPattern = this.isFileBasedPattern(exportTemplate);
if (isFileBasedPattern) {
const files = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
.map((entry) => entry.name);
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