diff --git a/.eslintrc.json b/.eslintrc.json index 21169f3..338ee87 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -38,7 +38,28 @@ "class-methods-use-this": "off", "no-await-in-loop": "off", "no-continue": "off", - "no-plusplus": "off" + "no-plusplus": "off", + "prefer-destructuring": "off", + + "import/order": [ + "warn", + { + "groups": [ + "builtin", + "external", + "internal", + "parent", + "sibling", + "index", + "object" + ], + "alphabetize": { + "order": "asc", + "caseInsensitive": true + }, + "newlines-between": "always" + } + ] }, "overrides": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d1f7c2..b96245b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] +### Added +- Prompt the user for any extra variable found inside a file template content that was not resolved from the template configuration, template file name, or global variables. + ## [1.0.0] ### Fixed - Fixed the order of commands in explorer context so New File is always the first. diff --git a/README.md b/README.md index 461b4fb..7be1a70 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Moreover, the variables `foo` and `bar` will be available to you in your file te ### Inside file template groups -Your file template groups have an additional setting so you can configure whether or not all file templates or the group share the same variable values. +Your file template groups have an additional setting so you can configure whether or not all file templates of the group share the same variable values. If yes, and you have file templates with file names `{{foo}}.{{bar}}.jsx` and `{{foo}}.md`, you will be prompted once for `foo` and once for `bar`. @@ -261,6 +261,14 @@ When defining a template in `ejs`, these are the provided variables, in ascendin - **fileName** *(string)* - The name of your file (with extension). - **baseFileName** *(string)* - The name of your file, without the extensions. E.g. if your generated file name is `foo.module.scss`, baseFileName is `foo`. +### Extra variables + +Any extra variable found inside a file template will be prompted: +* independently for each template if the template is used as a standalone, or within a group not sharing its variables. +* once per different variable if the template is used within a group sharing its variables. + +Note: to find extra variables inside a `ejs` template, I have to try to render the file for each extra variable, so this might affect performance if there are a lot of them. + ### Example Considering the following: @@ -293,17 +301,31 @@ Other file templates in your file template group with file names: The current file template with file name: - `{{name}}.jsx` +And file template content: +```jsx +import { memo } from 'react'; + +export const <%= baseFileName %> = memo(function <%= baseFileName %>() { + return ( +
+ <%= someExtraVariable %> +
+ ); +}); +``` + Inside your file template content, you have access to: - **foo**: `'yolo'` (overridden in the local configuration). -- **bar**: `'bbb` (global configuration). -- **baz**: overridden and prompted to the user in `{{name}}.{{baz}}.{{id}}.whatever`. -- **name**: overridden in your current template file name. -- **id**: prompted to the user in `{{name}}.{{baz}}.{{id}}.whatever`. +- **bar**: `'bbb'` (global configuration). +- **baz**: overridden and prompted to you from `{{name}}.{{baz}}.{{id}}.whatever`. +- **name**: prompted to you from your current template file name. +- **id**: prompted to you from `{{name}}.{{baz}}.{{id}}.whatever`. - **groupTemplates**: computed when creating the file using the file template. - **timestamp**: computed when creating the file using the file template. - **relativeFilePath**: computed when creating the file using the file template. - **fileName**: computed when creating the file using the file template. - **baseFileName**: computed when creating the file using the file template. +- **someExtraVariable**: prompted to you when creating the file because it was not resolved from the variables above. ## Release Notes diff --git a/package.json b/package.json index b81ee76..e989407 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "file-template-manager", "displayName": "File template manager", "description": "Create files from templates", - "version": "1.0.0", + "version": "1.1.0", "publisher": "gautier-lfbvr", "homepage": "https://github.com/gautier-lefebvre/vscode-file-template-manager#readme", "repository": { diff --git a/src/commands/createFileFromTemplate.ts b/src/commands/createFileFromTemplate.ts index e35dbf7..44ccd47 100644 --- a/src/commands/createFileFromTemplate.ts +++ b/src/commands/createFileFromTemplate.ts @@ -1,14 +1,16 @@ import { commands, + FileSystemError, Uri, window, workspace, } from 'vscode'; import { COMMANDS } from '../constants'; -import { renderFile } from '../domain/renderer'; +import { TemplateRenderer } from '../domain/renderer/templateRenderer'; import { Template } from '../domain/templates/data/template'; import { generateFileName } from '../domain/templates/data/template/utils'; + import { promptUserForTemplatesVariablesValues } from './utils/templateGroups'; import { getTemplatesOfWorkspaceFolderQuickPickItems, getGlobalTemplatesQuickPickItems } from './utils/templates'; @@ -51,18 +53,21 @@ export const createFileFromTemplate = async (baseFolderUri: Uri): Promise if (!template) { return; } - const fileNameVariablesPerTemplates = await promptUserForTemplatesVariablesValues( + const templateRenderer = new TemplateRenderer(template); + + const variablesPerTemplates = await promptUserForTemplatesVariablesValues( true, - [template], + [templateRenderer], + baseFolderUri, ); - if (!fileNameVariablesPerTemplates) { return; } + if (!variablesPerTemplates) { return; } - const fileNameVariables = fileNameVariablesPerTemplates[template.metadata.id]; + const templateVariables = variablesPerTemplates[templateRenderer.template.metadata.id]; const fileUri = Uri.joinPath( baseFolderUri, - generateFileName(template.metadata.fileTemplateName, fileNameVariables), + generateFileName(templateRenderer.template.metadata.fileTemplateName, templateVariables), ); try { @@ -78,14 +83,13 @@ export const createFileFromTemplate = async (baseFolderUri: Uri): Promise if (actionSelected !== OVERWRITE_ACTION) { return; } } catch (err) { // Ignore FileNotFound. - if (err.code !== 'FileNotFound') { throw err; } + if (!(err instanceof FileSystemError && err.code === 'FileNotFound')) { throw err; } } - await renderFile( + await templateRenderer.renderToFile( fileUri, - template, - fileNameVariables, - [template], + templateVariables, + [templateRenderer.template], ); await window.showTextDocument(fileUri); diff --git a/src/commands/createFilesFromTemplateGroup.ts b/src/commands/createFilesFromTemplateGroup.ts index 23850d5..940fe72 100644 --- a/src/commands/createFilesFromTemplateGroup.ts +++ b/src/commands/createFilesFromTemplateGroup.ts @@ -1,14 +1,19 @@ import { - commands, Uri, window, workspace, + commands, + FileSystemError, + Uri, + window, + workspace, } from 'vscode'; + import { COMMANDS } from '../constants'; import { FolderType } from '../domain/config/types'; -import { renderFile } from '../domain/renderer'; -import { Template } from '../domain/templates/data/template'; +import { TemplateRenderer } from '../domain/renderer/templateRenderer'; import { generateFileName } from '../domain/templates/data/template/utils'; import { TemplateGroup } from '../domain/templates/data/templateGoup'; import { templatesService } from '../domain/templates/services'; import { compact } from '../utils/array'; + import { getGlobalTemplateGroupsQuickPickItems, getTemplateGroupsOfWorkspaceFolderQuickPickItems, promptUserForTemplatesVariablesValues } from './utils/templateGroups'; export const createFilesFromTemplateGroup = async (baseFolderUri: Uri): Promise => { @@ -58,22 +63,27 @@ export const createFilesFromTemplateGroup = async (baseFolderUri: Uri): Promise< ? templatesService.getGlobalTemplateById(templateId) : templatesService.getTemplateOfWorkspaceFolderById(workspaceFolder.uri, templateId) ))), - ); + ).map((template) => new TemplateRenderer(template)); - const templatesVariablesValues = await promptUserForTemplatesVariablesValues( + const variablesPerTemplates = await promptUserForTemplatesVariablesValues( templateGroup.metadata.templatesUseSameVariables, templatesOfGroup, + baseFolderUri, ); - if (!templatesVariablesValues) { return; } + if (!variablesPerTemplates) { return; } const filesUris = await Promise.all(templatesOfGroup.map( - async (template): Promise<{ template: Template, fileUri: Uri, exists: boolean }> => { - const { fileTemplateName, id } = template.metadata; + async (templateRenderer): Promise<{ + templateRenderer: TemplateRenderer; + fileUri: Uri; + exists: boolean; + }> => { + const { fileTemplateName, id } = templateRenderer.template.metadata; const fileUri = Uri.joinPath( baseFolderUri, - generateFileName(fileTemplateName, templatesVariablesValues[id]), + generateFileName(fileTemplateName, variablesPerTemplates[id]), ); let exists = false; @@ -82,14 +92,14 @@ export const createFilesFromTemplateGroup = async (baseFolderUri: Uri): Promise< await workspace.fs.stat(fileUri); exists = true; } catch (err) { - if (err.code === 'FileNotFound') { + if (err instanceof FileSystemError && err.code === 'FileNotFound') { exists = false; } else { throw err; } } - return { template, fileUri, exists }; + return { templateRenderer, fileUri, exists }; }, )); @@ -111,12 +121,11 @@ export const createFilesFromTemplateGroup = async (baseFolderUri: Uri): Promise< } // Create each file. - await Promise.all(filesUris.map(({ template, fileUri }) => ( - renderFile( + await Promise.all(filesUris.map(({ templateRenderer, fileUri }) => ( + templateRenderer.renderToFile( fileUri, - template, - templatesVariablesValues[template.metadata.id], - templatesOfGroup, + variablesPerTemplates[templateRenderer.template.metadata.id], + templatesOfGroup.map(({ template }) => template), ) ))); diff --git a/src/commands/createTemplate.ts b/src/commands/createTemplate.ts index bac634e..62874cf 100644 --- a/src/commands/createTemplate.ts +++ b/src/commands/createTemplate.ts @@ -1,8 +1,9 @@ import { window } from 'vscode'; -import { Template } from '../domain/templates/data/template'; import { FolderType } from '../domain/config/types'; +import { Template } from '../domain/templates/data/template'; import { templatesService } from '../domain/templates/services'; + import { FolderQuickPickItem, getCreateTemplateFoldersQuickPickItems, WorkspaceFolderQuickPickItem } from './utils/folders'; import { showFileTemplateNameInputBox, showOnlyAsPartOfGroupQuickPick, showTemplateNameInputBox } from './utils/templates'; diff --git a/src/commands/createTemplateGroup.ts b/src/commands/createTemplateGroup.ts index 5ff879e..45d1947 100644 --- a/src/commands/createTemplateGroup.ts +++ b/src/commands/createTemplateGroup.ts @@ -3,6 +3,7 @@ import { window } from 'vscode'; import { FolderType } from '../domain/config/types'; import { TemplateGroup } from '../domain/templates/data/templateGoup'; import { templatesService } from '../domain/templates/services'; + import { FolderQuickPickItem, getCreateTemplateGroupFoldersQuickPickItems, WorkspaceFolderQuickPickItem } from './utils/folders'; import { askUserToCreateTemplate, showTemplateGroupNameInputBox, showTemplatesUseSameVariablesQuickPick } from './utils/templateGroups'; import { getTemplateQuickPickItemsOfSelectedFolder, mapTemplateToQuickPickItem } from './utils/templates'; diff --git a/src/commands/editTemplate.ts b/src/commands/editTemplate.ts index dce29bb..126c0f7 100644 --- a/src/commands/editTemplate.ts +++ b/src/commands/editTemplate.ts @@ -2,6 +2,7 @@ import { commands, window } from 'vscode'; import { COMMANDS } from '../constants'; import { Template } from '../domain/templates/data/template'; + import { getTemplateFoldersQuickPickItems } from './utils/folders'; import { getTemplateQuickPickItemsOfSelectedFolder } from './utils/templates'; diff --git a/src/commands/editTemplateGroupMetadata.ts b/src/commands/editTemplateGroupMetadata.ts index bd5a955..d8e872f 100644 --- a/src/commands/editTemplateGroupMetadata.ts +++ b/src/commands/editTemplateGroupMetadata.ts @@ -2,8 +2,8 @@ import { window } from 'vscode'; import { FolderType } from '../domain/config/types'; import { templatesService } from '../domain/templates/services'; + import { getTemplateGroupFoldersQuickPickItems, WorkspaceFolderQuickPickItem } from './utils/folders'; -import { getTemplateQuickPickItemsOfSelectedFolder, mapTemplateToQuickPickItem } from './utils/templates'; import { askUserToCreateTemplate, askUserToCreateTemplateGroup, @@ -11,6 +11,7 @@ import { showTemplateGroupNameInputBox, showTemplatesUseSameVariablesQuickPick, } from './utils/templateGroups'; +import { getTemplateQuickPickItemsOfSelectedFolder, mapTemplateToQuickPickItem } from './utils/templates'; export const editTemplateGroupMetadata = async (): Promise => { const foldersQuickPickItems = getTemplateGroupFoldersQuickPickItems(); diff --git a/src/commands/editTemplateMetadata.ts b/src/commands/editTemplateMetadata.ts index ca565fe..8236c10 100644 --- a/src/commands/editTemplateMetadata.ts +++ b/src/commands/editTemplateMetadata.ts @@ -1,8 +1,9 @@ import { commands, window } from 'vscode'; -import { COMMANDS } from '../constants'; +import { COMMANDS } from '../constants'; import { FolderType } from '../domain/config/types'; import { templatesService } from '../domain/templates/services'; + import { getTemplateFoldersQuickPickItems, WorkspaceFolderQuickPickItem } from './utils/folders'; import { getTemplateQuickPickItemsOfSelectedFolder, diff --git a/src/commands/removeTemplate.ts b/src/commands/removeTemplate.ts index c13f722..9100044 100644 --- a/src/commands/removeTemplate.ts +++ b/src/commands/removeTemplate.ts @@ -2,6 +2,7 @@ import { window } from 'vscode'; import { FolderType } from '../domain/config/types'; import { templatesService } from '../domain/templates/services'; + import { getTemplateFoldersQuickPickItems, WorkspaceFolderQuickPickItem } from './utils/folders'; import { getTemplateQuickPickItemsOfSelectedFolder } from './utils/templates'; diff --git a/src/commands/removeTemplateGroup.ts b/src/commands/removeTemplateGroup.ts index b244410..816dfb6 100644 --- a/src/commands/removeTemplateGroup.ts +++ b/src/commands/removeTemplateGroup.ts @@ -2,6 +2,7 @@ import { window } from 'vscode'; import { FolderType } from '../domain/config/types'; import { templatesService } from '../domain/templates/services'; + import { getTemplateGroupFoldersQuickPickItems, WorkspaceFolderQuickPickItem } from './utils/folders'; import { getTemplateGroupQuickPickItemsOfSelectedFolder } from './utils/templateGroups'; diff --git a/src/commands/utils/templateGroups.ts b/src/commands/utils/templateGroups.ts index 9fd93ee..dcfa5ac 100644 --- a/src/commands/utils/templateGroups.ts +++ b/src/commands/utils/templateGroups.ts @@ -4,13 +4,15 @@ import { Uri, window, } from 'vscode'; -import { COMMANDS } from '../../constants'; +import { COMMANDS } from '../../constants'; import { FolderType } from '../../domain/config/types'; +import { TemplateRenderer } from '../../domain/renderer/templateRenderer'; import { Template } from '../../domain/templates/data/template'; -import { getAllDistinctVarNames, TemplateGroupVariableValues } from '../../domain/templates/data/template/utils'; +import { getAllDistinctVarNames, getVariablesInsideTemplate, TemplateGroupVariableValues } from '../../domain/templates/data/template/utils'; import { TemplateGroup } from '../../domain/templates/data/templateGoup'; import { templatesService } from '../../domain/templates/services'; + import { BooleanQuickPickItem } from './common'; import { FolderQuickPickItem, WorkspaceFolderQuickPickItem } from './folders'; @@ -173,12 +175,15 @@ export async function askUserToCreateTemplateGroup( export async function promptUserForTemplatesVariablesValues( templatesUseSameVariables: boolean, - templates: Template[], + templateRenderers: TemplateRenderer[], + baseFolderUri: Uri, ): Promise { - const variablesInTemplates = ( - templates.reduce( - (acc: { [templateId: string]: string[] }, template) => { - acc[template.metadata.id] = getAllDistinctVarNames(template.metadata.fileTemplateName); + const variablesInTemplateNames = ( + templateRenderers.reduce( + (acc: { [templateId: string]: string[] }, templateRenderer) => { + acc[templateRenderer.template.metadata.id] = getAllDistinctVarNames( + templateRenderer.template.metadata.fileTemplateName, + ); return acc; }, {}, @@ -186,36 +191,37 @@ export async function promptUserForTemplatesVariablesValues( ); const variablesValues = ( - templates.reduce( - (acc: TemplateGroupVariableValues, template) => { - acc[template.metadata.id] = {}; + templateRenderers.reduce( + (acc: TemplateGroupVariableValues, templateRenderer) => { + acc[templateRenderer.template.metadata.id] = {}; return acc; }, {}, ) ); - for (let i = 0; i < templates.length; ++i) { - const template = templates[i]; - const varNames = variablesInTemplates[template.metadata.id]; + /* Get all variables inside template file names. */ + for (let i = 0; i < templateRenderers.length; ++i) { + const templateRenderer = templateRenderers[i]; + const varNames = variablesInTemplateNames[templateRenderer.template.metadata.id]; for (let j = 0; j < varNames.length; ++j) { const varName = varNames[j]; - const value = variablesValues[template.metadata.id][varName]; + const value = variablesValues[templateRenderer.template.metadata.id][varName]; if (value !== undefined) { continue; } - const templatesVariableUsage = templates.map((t) => ({ - template: t, - usesVariable: variablesInTemplates[t.metadata.id].includes(varName), + const templatesVariableUsage = templateRenderers.map((t) => ({ + templateRenderer: t, + usesVariable: variablesInTemplateNames[t.template.metadata.id].includes(varName), })); const fileTemplateNames = !templatesUseSameVariables - ? [template.metadata.fileTemplateName] + ? [templateRenderer.template.metadata.fileTemplateName] : templatesVariableUsage .filter(({ usesVariable }) => !!usesVariable) - .map(({ template: { metadata } }) => metadata.fileTemplateName); + .map(({ templateRenderer: { template } }) => template.metadata.fileTemplateName); const variableValue = await window.showInputBox({ placeHolder: `What is the value of '${varName}'?`, @@ -225,20 +231,85 @@ export async function promptUserForTemplatesVariablesValues( if (variableValue === undefined) { return undefined; } if (templatesUseSameVariables) { - templates.forEach(({ metadata: { id } }) => { + /* Add variable to all templates. */ + templateRenderers.forEach(({ template: { metadata: { id } } }) => { variablesValues[id][varName] = variableValue; }); } else { - variablesValues[template.metadata.id][varName] = variableValue; + /* + * Add variable to current template, + * and any template who do not use the variable in their name. + * + * NB: this is done for retrocompatibility. A more logical way would be to ignore other + * templates, and prompt them independently based on their content. + */ + variablesValues[templateRenderer.template.metadata.id][varName] = variableValue; templatesVariableUsage .filter(({ usesVariable }) => !usesVariable) - .forEach(({ template: { metadata: { id } } }) => { + .forEach(({ templateRenderer: { template: { metadata: { id } } } }) => { variablesValues[id][varName] = variableValue; }); } } } + const variablesInsideTemplates = ( + await Promise.all(templateRenderers.map(async (templateRenderer) => ({ + templateRenderer, + variables: await getVariablesInsideTemplate( + baseFolderUri, + templateRenderer, + variablesValues[templateRenderer.template.metadata.id], + templateRenderers.map(({ template }) => template), + ), + }))) + ).reduce( + (acc: { [key:string]: string[] }, { templateRenderer, variables }) => { + acc[templateRenderer.template.metadata.id] = variables; + return acc; + }, + {}, + ); + + /* Prompt any variable in templates that is not already defined. */ + /* If templates uses the same variables, prompt once per variable. */ + /* Otherwise prompt for each file independently. */ + for (let i = 0; i < templateRenderers.length; ++i) { + const templateRenderer = templateRenderers[i]; + const varNames = variablesInsideTemplates[templateRenderer.template.metadata.id]; + + for (let j = 0; j < varNames.length; ++j) { + const varName = varNames[j]; + + const value = variablesValues[templateRenderer.template.metadata.id][varName]; + + if (value !== undefined) { continue; } + + const templatesUsingVariable = !templatesUseSameVariables + ? [templateRenderer.template.metadata.name] + : templateRenderers + .filter((t) => variablesInsideTemplates[t.template.metadata.id].includes(varName)) + .map((t) => t.template.metadata.name); + + const variableValue = await window.showInputBox({ + placeHolder: `What is the value of '${varName}'?`, + prompt: `'${varName}' inside content of ${templatesUsingVariable.length > 1 ? 'templates' : 'template'} ${templatesUsingVariable.map((name) => `'${name}'`).join(', ')}?`, + }); + + if (variableValue === undefined) { return undefined; } + + if (templatesUseSameVariables) { + /* Add variable to all templates. */ + templateRenderers.forEach(({ template: { metadata: { id } } }) => { + variablesValues[id][varName] = variableValue; + }); + } else { + /* Add variable to current template only. */ + variablesValues[templateRenderer.template.metadata.id][varName] = variableValue; + } + } + } + return variablesValues; } diff --git a/src/commands/utils/templates.ts b/src/commands/utils/templates.ts index 6d747d4..c6189f8 100644 --- a/src/commands/utils/templates.ts +++ b/src/commands/utils/templates.ts @@ -4,6 +4,7 @@ import { FolderType } from '../../domain/config/types'; import { Template } from '../../domain/templates/data/template'; import { findInvalidVarNamePattern } from '../../domain/templates/data/template/utils'; import { templatesService } from '../../domain/templates/services'; + import { BooleanQuickPickItem } from './common'; import { FolderQuickPickItem, WorkspaceFolderQuickPickItem } from './folders'; diff --git a/src/constants.ts b/src/constants.ts index d7e9c10..fd354ed 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -40,6 +40,8 @@ export const TEMPLATE_CONTENT_PLACEHOLDER = `/** * - relativeFilePath: path of the created file, relative to the workspace folder root. * - fileName: name of the file (with extension). e.g. "foo.module.scss". * - baseFileName: name of the file (without extension). e.g. "foo". + * + * You can also use any other variable, it will be prompted to you when creating a new file from this template if it was not resolved from the variables listed above. */ `; diff --git a/src/domain/config/index.ts b/src/domain/config/index.ts index 10aefb2..dccdb1a 100644 --- a/src/domain/config/index.ts +++ b/src/domain/config/index.ts @@ -3,14 +3,16 @@ import { TextEncoder } from 'util'; import { cosmiconfig } from 'cosmiconfig'; import { Disposable, + FileSystemError, Uri, window, workspace, } from 'vscode'; -import { logger } from '../../services/logger'; -import { getExtensionContext } from '../../services/extensionContext'; import { CONFIG_FILE_MODULE_NAME, DEFAULT_TEMPLATES_FOLDER } from '../../constants'; +import { getExtensionContext } from '../../services/extensionContext'; +import { logger } from '../../services/logger'; + import { FolderConfiguration, FolderType, @@ -74,7 +76,7 @@ class ConfigurationService { filePath, } = await this.readConfiguration(workspaceFolderUri)); } catch (err) { - logger.appendLine(err); + logger.appendLine((err as Error).toString()); window.showWarningMessage(`Could not load configuration for folder '${workspaceFolderUri.path}'. Default configuration will be used instead.`); config = {} as RawWorkspaceFolderConfiguration; @@ -118,7 +120,7 @@ class ConfigurationService { filePath, } = await this.readConfiguration(globalStorageUri)); } catch (err) { - logger.appendLine(err); + logger.appendLine((err as Error).toString()); window.showWarningMessage('Could not load global templates configuration. Default configuration will be used instead.'); config = {} as RawFolderConfiguration; @@ -185,7 +187,7 @@ class ConfigurationService { clearCache(); } catch (err) { clearCache(); - if (err.code !== 'FileNotFound') { throw err; } + if (!(err instanceof FileSystemError && err.code === 'FileNotFound')) { throw err; } } } @@ -207,7 +209,7 @@ class ConfigurationService { folderConfiguration, }); } catch (err) { - logger.appendLine(err); + logger.appendLine((err as Error).toString()); } } } diff --git a/src/domain/renderer/index.ts b/src/domain/renderer/index.ts deleted file mode 100644 index a888d4e..0000000 --- a/src/domain/renderer/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { TextDecoder, TextEncoder } from 'util'; -import { basename } from 'path'; - -import { Uri, workspace } from 'vscode'; -import ejs from 'ejs'; - -import { config } from '../config'; -import { Template } from '../templates/data/template'; - -/** - * Render a file. - * Make sure that the file does not exist or can be overwritten before calling this method. - */ -export const renderFile = async ( - fileUri: Uri, - template: Template, - fileNameVariables: { [key: string]: string }, - groupTemplates: Template[] = [], -): Promise => { - const workspaceFolder = workspace.getWorkspaceFolder(fileUri); - - if (!workspaceFolder) { - throw Error('Cannot create files outside of a workspace folder.'); - } - - const [ - globalConfiguration, - workspaceFolderConfiguration, - templateContent, - ] = await Promise.all([ - config.getGlobalFolderConfiguration(), - config.getWorkspaceFolderConfiguration(workspaceFolder.uri), - workspace.fs.readFile(template.contentFileUri), - ]); - - const variables = { - ...globalConfiguration.variables, - ...workspaceFolderConfiguration.variables, - ...fileNameVariables, - - groupTemplates: groupTemplates.map(({ metadata: { name } }) => name), - timestamp: new Date().toISOString(), - relativeFilePath: workspace.asRelativePath(fileUri, false), - fileName: basename(fileUri.path), - baseFileName: basename(fileUri.path).split('.')[0], - }; - - const generatedFileContent = await ejs.render( - new TextDecoder('utf-8').decode(templateContent), - variables, - { async: true }, - ); - - await workspace.fs.writeFile( - fileUri, - new TextEncoder().encode(generatedFileContent), - ); -}; diff --git a/src/domain/renderer/missingVariableError.ts b/src/domain/renderer/missingVariableError.ts new file mode 100644 index 0000000..c3f84d4 --- /dev/null +++ b/src/domain/renderer/missingVariableError.ts @@ -0,0 +1,18 @@ +export class MissingVariableError extends Error { + public readonly missingVariableName: string; + + constructor(error: ReferenceError) { + super(error.toString()); + + // Not sure about the \w, might need to be changed. + const matchResult = error.message.match(/(\w+) is not defined/); + + if (!matchResult?.[1]) { + throw error; + } + + this.missingVariableName = matchResult[1]; + + Error.captureStackTrace(this, MissingVariableError); + } +} diff --git a/src/domain/renderer/templateRenderer.ts b/src/domain/renderer/templateRenderer.ts new file mode 100644 index 0000000..78bbfef --- /dev/null +++ b/src/domain/renderer/templateRenderer.ts @@ -0,0 +1,131 @@ +import { basename } from 'path'; +import { TextDecoder, TextEncoder } from 'util'; + +import ejs from 'ejs'; +import { Uri, workspace } from 'vscode'; + +import { config } from '../config'; +import { Template } from '../templates/data/template'; + +import { MissingVariableError } from './missingVariableError'; + +/** + * Do not cache this class, the template content might change between commands. + */ +export class TemplateRenderer { + public readonly template: Template; + + private compiledTemplate: ejs.AsyncTemplateFunction | null = null; + + constructor(template: Template) { + this.template = template; + } + + /** + * Reset the compiled template. + */ + public reset(): void { + this.compiledTemplate = null; + } + + /** + * Get the list of variables to render. + * + * @param fileUri - File uri. + * @param templateVariables - Variables in the template name and content. + * @param groupTemplates - Other templates inside the group being rendered. + * @returns Variables to pass to the template. + */ + private async getVariablesForRender( + fileUri: Uri, + templateVariables: ejs.Data, + groupTemplates: Template[], + ): Promise { + const workspaceFolder = workspace.getWorkspaceFolder(fileUri); + + if (!workspaceFolder) { + throw Error('Cannot create files outside of a workspace folder.'); + } + + const [ + globalConfiguration, + workspaceFolderConfiguration, + ] = await Promise.all([ + config.getGlobalFolderConfiguration(), + config.getWorkspaceFolderConfiguration(workspaceFolder.uri), + ]); + + return { + ...globalConfiguration.variables, + ...workspaceFolderConfiguration.variables, + ...templateVariables, + + groupTemplates: groupTemplates.map(({ metadata: { name } }) => name), + timestamp: new Date().toISOString(), + relativeFilePath: workspace.asRelativePath(fileUri, false), + fileName: basename(fileUri.path), + baseFileName: basename(fileUri.path).split('.')[0], + }; + } + + /** + * Try to render the template. + * Throw MissingVariableError if a variable inside a template is not provided. + * + * @param fileUri - File uri. + * @param templateVariables - Variables in the template name and content. + * @param groupTemplates - Other templates inside the group being rendered. + * @returns Rendered file content. + */ + public async renderTemplate( + fileUri: Uri, + templateVariables: ejs.Data, + groupTemplates: Template[], + ): Promise { + if (!this.compiledTemplate) { + this.compiledTemplate = ejs.compile( + new TextDecoder('utf-8').decode(await workspace.fs.readFile(this.template.contentFileUri)), + { async: true }, + ); + } + + const variables = await this.getVariablesForRender( + fileUri, + templateVariables, + groupTemplates, + ); + + try { + return await this.compiledTemplate(variables); + } catch (err) { + if (err instanceof ReferenceError) { + throw new MissingVariableError(err); + } + + throw err; + } + } + + /** + * Render the template to a file. + * Throw MissingVariableError if a variable inside a template is not provided. + * + * @param fileUri - File uri. + * @param templateVariables - Variables in the template name and content. + * @param groupTemplates - Other templates inside the group being rendered. + */ + public async renderToFile( + fileUri: Uri, + templateVariables: ejs.Data, + groupTemplates: Template[], + ): Promise { + await workspace.fs.writeFile( + fileUri, + new TextEncoder().encode(await this.renderTemplate( + fileUri, + templateVariables, + groupTemplates, + )), + ); + } +} diff --git a/src/domain/templates/data/template/index.ts b/src/domain/templates/data/template/index.ts index a13f235..194b33d 100644 --- a/src/domain/templates/data/template/index.ts +++ b/src/domain/templates/data/template/index.ts @@ -2,6 +2,7 @@ import { Uri } from 'vscode'; import { TEMPLATE_CONTENT_FILENAME, TEMPLATE_METADATA_FILENAME } from '../../../../constants'; import { FolderConfiguration } from '../../../config/types'; + import { TemplateMetadata } from './metadata'; export { TemplateMetadata }; diff --git a/src/domain/templates/data/template/utils.ts b/src/domain/templates/data/template/utils.ts index 230d67a..7d0f39b 100644 --- a/src/domain/templates/data/template/utils.ts +++ b/src/domain/templates/data/template/utils.ts @@ -1,6 +1,11 @@ import escapeStringRegexp from 'escape-string-regexp'; +import { Uri } from 'vscode'; import { validVariableName } from '../../../../constants'; +import { MissingVariableError } from '../../../renderer/missingVariableError'; +import { TemplateRenderer } from '../../../renderer/templateRenderer'; + +import { Template } from './index'; export type TemplateVariableValues = { [varName: string]: string, @@ -64,3 +69,63 @@ export const generateFileName = ( fileTemplateName, ) ); + +export const generateFileUri = ( + baseFolderUri: Uri, + fileTemplateName: Template['metadata']['fileTemplateName'], + fileNameVariables: { [key: string]: string }, +): Uri => Uri.joinPath( + baseFolderUri, + generateFileName(fileTemplateName, fileNameVariables), +); + +/** + * Return name of variables inside the template content. + * This might be slow because we need to try/catch around ejs.render to get missing variables. + * + * @param baseFolderUri - Base folder URI of the command. + * @param templateRenderer - Template renderer. + * @param templateFileNameVariables - Name of variables inside the template file name. + * @param groupTemplates - Templates inside the group. + * @returns Name of variables inside the template content. + */ +export const getVariablesInsideTemplate = async ( + baseFolderUri: Uri, + templateRenderer: TemplateRenderer, + templateFileNameVariables: { [key: string]: string }, + groupTemplates: Template[], +): Promise => { + const fileUri = generateFileUri( + baseFolderUri, + templateRenderer.template.metadata.fileTemplateName, + templateFileNameVariables, + ); + + const fakeVariablesInTemplateContent: { [key: string]: string} = {}; + + let hasMissingVariable = false; + + do { + try { + await templateRenderer.renderTemplate( + fileUri, + { + ...templateFileNameVariables, + ...fakeVariablesInTemplateContent, + }, + groupTemplates, + ); + + hasMissingVariable = false; + } catch (err) { + if (err instanceof MissingVariableError) { + hasMissingVariable = true; + fakeVariablesInTemplateContent[err.missingVariableName] = 'placeholderValue'; + } else { + throw err; + } + } + } while (hasMissingVariable); + + return Object.keys(fakeVariablesInTemplateContent); +}; diff --git a/src/domain/templates/data/templateGoup/index.ts b/src/domain/templates/data/templateGoup/index.ts index 350179a..231cf1d 100644 --- a/src/domain/templates/data/templateGoup/index.ts +++ b/src/domain/templates/data/templateGoup/index.ts @@ -1,6 +1,7 @@ import { Uri } from 'vscode'; import { FolderConfiguration } from '../../../config/types'; + import { TemplateGroupMetadata } from './metadata'; export { TemplateGroupMetadata }; diff --git a/src/domain/templates/services/folder/base.ts b/src/domain/templates/services/folder/base.ts index 4875dc8..5128196 100644 --- a/src/domain/templates/services/folder/base.ts +++ b/src/domain/templates/services/folder/base.ts @@ -3,6 +3,7 @@ import { TextDecoder, TextEncoder } from 'util'; import slugify from 'slugify'; import { + FileSystemError, FileType, Uri, window, @@ -98,8 +99,8 @@ export abstract class FolderTemplatesService implements IFolderTemplatesService } catch (err) { delete this.templatesCache[id]; - if (err.code !== 'FileNotFound') { - logger.appendLine(err); + if (!(err instanceof FileSystemError && err.code === 'FileNotFound')) { + logger.appendLine((err as Error).toString()); // Show warning and abort. window.showWarningMessage(`Could not load metadata in template folder ${templateFolderUri.path}. Template is omitted.`); @@ -149,8 +150,8 @@ export abstract class FolderTemplatesService implements IFolderTemplatesService } catch (err) { delete this.templateGroupsCache[id]; - if (err !== 'FileNotFound') { - logger.appendLine(err); + if (!(err instanceof FileSystemError && err.code === 'FileNotFound')) { + logger.appendLine((err as Error).toString()); // Show warning and abort. window.showWarningMessage(`Could not load template group metadata file at ${metadataFileUri.path}. Template group is omitted.`); @@ -174,7 +175,7 @@ export abstract class FolderTemplatesService implements IFolderTemplatesService this.folderUri = folderUri; } - public abstract async getFolderConfiguration(): Promise; + public abstract getFolderConfiguration(): Promise; public async getTemplates(): Promise> { const folderConfiguration = await this.getFolderConfiguration(); @@ -203,7 +204,7 @@ export abstract class FolderTemplatesService implements IFolderTemplatesService return compact(templates); } catch (err) { - if (err.code !== 'FileNotFound') { throw err; } + if (!(err instanceof FileSystemError && err.code === 'FileNotFound')) { throw err; } return []; } @@ -248,7 +249,7 @@ export abstract class FolderTemplatesService implements IFolderTemplatesService return compact(templateGroups); } catch (err) { - if (err.code !== 'FileNotFound') { throw err; } + if (!(err instanceof FileSystemError && err.code === 'FileNotFound')) { throw err; } return []; } @@ -406,12 +407,11 @@ export abstract class FolderTemplatesService implements IFolderTemplatesService try { await workspace.fs.stat(await getUri(id, configuration)); } catch (err) { - switch (err.code) { - case 'FileNotFound': - return id; - default: - throw err; + if (err instanceof FileSystemError && err.code === 'FileNotFound') { + return id; } + + throw err; } } while (count < 100); diff --git a/src/domain/templates/services/folder/global.ts b/src/domain/templates/services/folder/global.ts index a0857fd..3b59703 100644 --- a/src/domain/templates/services/folder/global.ts +++ b/src/domain/templates/services/folder/global.ts @@ -1,6 +1,7 @@ import { getExtensionContext } from '../../../../services/extensionContext'; import { config } from '../../../config'; import { FolderConfiguration } from '../../../config/types'; + import { FolderTemplatesService } from './base'; export class GlobalFolderTemplatesService extends FolderTemplatesService { diff --git a/src/domain/templates/services/folder/workspaceFolder.ts b/src/domain/templates/services/folder/workspaceFolder.ts index 0791a1b..8518e30 100644 --- a/src/domain/templates/services/folder/workspaceFolder.ts +++ b/src/domain/templates/services/folder/workspaceFolder.ts @@ -1,5 +1,6 @@ import { config } from '../../../config'; import { FolderConfiguration } from '../../../config/types'; + import { FolderTemplatesService } from './base'; export class WorkspaceFolderTemplatesService extends FolderTemplatesService { diff --git a/src/domain/templates/services/index.ts b/src/domain/templates/services/index.ts index 99f9df7..2b742e5 100644 --- a/src/domain/templates/services/index.ts +++ b/src/domain/templates/services/index.ts @@ -2,12 +2,13 @@ import { isDeepStrictEqual } from 'util'; import { Uri } from 'vscode'; +import { FolderConfiguration } from '../../config/types'; import { Template, TemplateMetadata } from '../data/template'; -import { TemplateGroupToCreate, TemplateToCreate } from './folder/base'; import { TemplateGroup, TemplateGroupMetadata } from '../data/templateGoup'; + +import { TemplateGroupToCreate, TemplateToCreate } from './folder/base'; import { GlobalFolderTemplatesService } from './folder/global'; import { WorkspaceFolderTemplatesService } from './folder/workspaceFolder'; -import { FolderConfiguration } from '../../config/types'; class TemplatesService { private workspaceFolderTemplatesServicesCache diff --git a/src/extension.ts b/src/extension.ts index 909b0d4..1c55ccc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,20 +1,19 @@ import { commands, ExtensionContext } from 'vscode'; -import { config } from './domain/config'; -import { setExtensionContext } from './services/extensionContext'; - -import { COMMANDS } from './constants'; +import { createFileFromTemplate } from './commands/createFileFromTemplate'; +import { createFilesFromTemplateGroup } from './commands/createFilesFromTemplateGroup'; import { createTemplate } from './commands/createTemplate'; +import { createTemplateGroup } from './commands/createTemplateGroup'; +import { editGlobalConfiguration } from './commands/editGlobalConfiguration'; import { editTemplate } from './commands/editTemplate'; +import { editTemplateGroupMetadata } from './commands/editTemplateGroupMetadata'; import { editTemplateMetadata } from './commands/editTemplateMetadata'; +import { editWorkspaceFolderConfiguration } from './commands/editWorkspaceFolderConfiguration'; import { removeTemplate } from './commands/removeTemplate'; -import { createTemplateGroup } from './commands/createTemplateGroup'; -import { editTemplateGroupMetadata } from './commands/editTemplateGroupMetadata'; import { removeTemplateGroup } from './commands/removeTemplateGroup'; -import { createFileFromTemplate } from './commands/createFileFromTemplate'; -import { createFilesFromTemplateGroup } from './commands/createFilesFromTemplateGroup'; -import { editGlobalConfiguration } from './commands/editGlobalConfiguration'; -import { editWorkspaceFolderConfiguration } from './commands/editWorkspaceFolderConfiguration'; +import { COMMANDS } from './constants'; +import { config } from './domain/config'; +import { setExtensionContext } from './services/extensionContext'; export async function activate(context: ExtensionContext): Promise { setExtensionContext(context); diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index 30cd44b..47524f8 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -1,6 +1,7 @@ import path from 'path'; -import Mocha from 'mocha'; + import glob from 'glob'; +import Mocha from 'mocha'; export function run(): Promise { // Create the mocha test