diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 2bced11900..2fa00336df 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -218,4 +218,58 @@ describe('fetch:template', () => { }); }); }); + + describe('cookiecutter compatibility mode', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + itemList: ['first', 'second', 'third'], + }, + cookieCutterCompat: true, + }); + + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + [outputPath]: { + '{{ cookiecutter.name }}.txt': 'static content', + subdir: { + 'templated-content.txt': + '{{ cookiecutter.name }}: {{ cookiecutter.count }}', + }, + '{{ cookiecutter.name }}.json': + '{{ cookiecutter.itemList | jsonify }}', + }, + }); + + return Promise.resolve(); + }); + + await action.handler(context); + }); + + it('copies files with cookiecutter-style templated names successfully', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.txt`, 'utf-8'), + ).resolves.toEqual('static content'); + }); + + it('copies files with cookiecutter-style templated content successfully', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/subdir/templated-content.txt`, + 'utf-8', + ), + ).resolves.toEqual('test-project: 1234'); + }); + + it('includes the jsonify filter', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.json`, 'utf-8'), + ).resolves.toEqual('["first","second","third"]'); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index f4cfacb175..e2f0047542 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -24,11 +24,25 @@ import globby from 'globby'; import nunjucks from 'nunjucks'; import fs from 'fs-extra'; +/* + * Maximise compatibility with Jinja (and therefore cookiecutter) + * using nunjucks jinja compat mode. Since this method mutates + * the global nunjucks instance, we can't enable this per-template, + * or only for templates with cookiecutter compat enabled, so the + * next best option is to explicitly enable it globally and allow + * folks to rely on jinja compatibility behaviour in fetch:template + * templates if they wish. + * + * cf. https://mozilla.github.io/nunjucks/api.html#installjinjacompat + */ +nunjucks.installJinjaCompat(); + export type FetchTemplateInput = { url: string; targetPath?: string; values: any; copyWithoutRender?: string[]; + cookieCutterCompat?: boolean; }; export function createFetchTemplateAction(options: { @@ -72,6 +86,13 @@ export function createFetchTemplateAction(options: { type: 'string', }, }, + cookieCutterCompat: { + title: 'Cookiecutter compatibility mode', + // TODO(mtlewis): documentation for cookiecutter compat mode + description: + 'Enable features to maximise compatibility with templates built for fetch:cookiecutter', + type: 'boolean', + }, // TODO(mtlewis/orkohunter): do we need to replicate the template extensions support // from fetch:cookiecutter? }, @@ -109,8 +130,6 @@ export function createFetchTemplateAction(options: { outputPath: templateDir, }); - // at this point the templateDir contains the unprocessed contents of the skeleton directory - ctx.logger.info( 'Fetched template, beginning templating process with values', ctx.input.values, @@ -138,39 +157,51 @@ export function createFetchTemplateAction(options: { ).flat(), ); - // Nice for Cookiecutter compat - // - // TODO(mtlewis/orkohunter): parameterize all jinja2/cookiecutter compat - // TODO(mtlewis): introduce "cookiecutter" prefix for input variables when - // compat is enabled? - nunjucks.installJinjaCompat(); + ctx.logger.info(allEntriesInTemplate); // Create a templater const templater = nunjucks.configure({ // TODO(mtlewis/orkohunter): Document Why we are changing the literals? Not here, but on scaffolder docs. ADR? - tags: { - variableStart: '${{', - variableEnd: '}}', - }, + ...(ctx.input.cookieCutterCompat + ? {} + : { + tags: { + variableStart: '${{', + variableEnd: '}}', + }, + }), // We don't want this builtin auto-escaping, since uses HTML escape sequences // like `"` - the correct way to escape strings in our case depends on // the file type. autoescape: false, }); - // TODO(mtlewis/orkohunter) Evaluate whether this behavior is still appropriate when using nunjucks. - // As of now jsonify seems to be the most reliable way to do escaping, - // but is there a builtin filter to do this inside nunjucks - // (other than `autoescape` inside `configure` which escapes strings as HTML, which isn't right.). - templater.addFilter('jsonify', s => JSON.stringify(s)); + if (ctx.input.cookieCutterCompat) { + // The "jsonify" filter built into cookiecutter is common + // in fetch:cookiecutter templates, so when compat mode + // is enabled we alias the "dump" filter from nunjucks as + // jsonify. Dump accepts an optional `spaces` parameter + // which enables indented output, but when this parameter + // is not supplied it works identically to jsonify. + // + // cf. https://cookiecutter.readthedocs.io/en/latest/advanced/template_extensions.html?highlight=jsonify#jsonify-extension + // cf. https://mozilla.github.io/nunjucks/templating.html#dump + templater.addFilter('jsonify', templater.getFilter('dump')); + } + + // Cookiecutter prefixes all parameters in templates with + // `cookiecutter.`. To replicate this, we wrap our parameters + // in an object with a `cookiecutter` property when compat + // mode is enabled. + const parameters = ctx.input.cookieCutterCompat + ? { cookiecutter: ctx.input.values } + : ctx.input.values; for (const location of allEntriesInTemplate) { const isTemplated = !nonTemplatedEntries.has(location); const outputPath = resolvePath( outputDir, - isTemplated - ? templater.renderString(location, ctx.input.values) - : location, + isTemplated ? templater.renderString(location, parameters) : location, ); if (location.endsWith('/')) { @@ -184,7 +215,7 @@ export function createFetchTemplateAction(options: { await fs.outputFile( outputPath, isTemplated - ? templater.renderString(inputFileContents, ctx.input.values) + ? templater.renderString(inputFileContents, parameters) : inputFileContents, ); }