diff --git a/lib/index.d.ts b/lib/index.d.ts index 799ee3d..4816ca8 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -29,6 +29,11 @@ export type Options = { * @default {} */ engineOptions?: any; + /** + * Pass `''` to remove the extension or `'.'` to keep or rename it. + * @default transform.outputFormat + */ + extname?: string; }; /** * A metalsmith plugin for in-place templating diff --git a/src/index.js b/src/index.js index 4f31496..65aabc1 100644 --- a/src/index.js +++ b/src/index.js @@ -93,50 +93,52 @@ function parseFilepath(filename) { } /** - * Engine, renders file contents with all available transformers + * @param {string} filename + * @param {Options} opts + * @returns */ - -async function render({ filename, files, metalsmith, settings, transform }) { +export function handleExtname(filename, opts) { const { dirname, base, extensions } = parseFilepath(filename) + const extname = opts.extname && opts.extname.slice(1) + // decouples file extension chaining order from transformer usage order + for (let i = extensions.length; i--; ) { + if (opts.transform.inputFormats.includes(extensions[i])) { + extensions.splice(i, 1) + break + } + } + const isLast = !extensions.length + if (isLast && extname) extensions.push(extname) + return [path.join(dirname, base), ...extensions].join('.') +} + +async function render({ filename, files, metalsmith, options, transform }) { const file = files[filename] - const engineOptions = Object.assign({}, settings.engineOptions) - if (settings.engineOptions.filename) { + const engineOptions = Object.assign({}, options.engineOptions) + if (options.engineOptions.filename) { Object.assign(engineOptions, { // set the filename in options for jstransformers requiring it (like Pug) filename: metalsmith.path(metalsmith.source(), filename) }) } const metadata = metalsmith.metadata() - const isLastExtension = extensions.length === 1 debug(`rendering ${filename}`) - const ext = extensions.pop() const locals = Object.assign({}, metadata, file) - - // Stringify file contents const contents = file.contents.toString() - // If this is the last extension, replace it with a new one - if (isLastExtension) { - debug(`last extension reached, replacing extension with ${transform.outputFormat}`) - extensions.push(transform.outputFormat) - } - - // Transform the contents - debug(`rendering ${ext} extension for ${filename}`) - return transform .renderAsync(contents, engineOptions, locals) .then((rendered) => { - // Delete old file - delete files[filename] + const newName = handleExtname(filename, { ...options, transform }) + debug('Done rendering %s', filename) + debug('Renaming "%s" to "%s"', filename, newName) - // Update files with the newly rendered file - const newName = [path.join(dirname, base), ...extensions].join('.') - files[newName] = file + if (newName !== filename) { + delete files[filename] + files[newName] = file + } files[newName].contents = Buffer.from(rendered.body) - - debug(`done rendering ${filename}, renamed to ${newName}`) }) .catch((err) => { err.message = `${filename}: ${err.message}` @@ -149,18 +151,17 @@ async function render({ filename, files, metalsmith, settings, transform }) { */ function validate({ filename, files, transform }) { - debug(`validating ${filename}`) const { extensions } = parseFilepath(filename) + debug(`validating ${filename} %O %O`, extensions, transform.inputFormats) // IF the transform has inputFormats defined, invalidate the file if it has no matching extname - if (transform.inputFormats && !transform.inputFormats.includes(extensions.slice(-1)[0])) { + if (transform.inputFormats && !extensions.some((fmt) => transform.inputFormats.includes(fmt))) { debug.warn( 'Validation failed for file "%s", transformer %s supports extensions %s.', filename, transform.name, transform.inputFormats.map((i) => `.${i}`).join(', ') ) - return false } // Files that are not utf8 are ignored @@ -174,14 +175,24 @@ function validate({ filename, files, transform }) { /** * @typedef {Object} Options * @property {string|JsTransformer} transform Jstransformer to run: name of a node module or local JS module path (starting with `.`) whose default export is a jstransformer. As a shorthand for existing transformers you can remove the `jstransformer-` prefix: `marked` will be understood as `jstransformer-marked`. Or an actual jstransformer; an object with `name`, `inputFormats`,`outputFormat`, and at least one of the render methods `render`, `renderAsync`, `compile` or `compileAsync` described in the [jstransformer API docs](https://github.com/jstransformers/jstransformer#api) - * @property {string} [pattern='**\/*.'] (*optional*) One or more paths or glob patterns to limit the scope of the transform. Defaults to `'**\/*.'` + * @property {string} [pattern='**\/*.'] (*optional*) One or more paths or glob patterns to limit the scope of the transform. Defaults to `'**\/*.*'` * @property {Object} [engineOptions={}] (*optional*) Pass options to the jstransformer templating engine that's rendering your files. The default is `{}` + * @property {string} [extname] (*optional*) Pass `''` to remove the extension or `'.'` to keep or rename it. Defaults to `transform.outputFormat` **/ -/** @type {Options} */ -const defaultOptions = { - pattern: '**', - engineOptions: {} +/** + * Set default options based on jstransformer `transform` + * @param {JsTransformer} transform + * @returns + */ +function normalizeOptions(transform) { + const extMatch = + transform.inputFormats.length === 1 ? transform.inputFormats[0] : `{${transform.inputFormats.join(',')}}` + return { + pattern: `**/*.${extMatch}*`, + extname: `.${transform.outputFormat}`, + engineOptions: {} + } } /** @@ -189,15 +200,14 @@ const defaultOptions = { * @param {Options} options * @returns {import('metalsmith').Plugin} */ -function initializeInPlace(options = defaultOptions) { - const settings = Object.assign({}, defaultOptions, options) +function initializeInPlace(options = {}) { let transform return async function inPlace(files, metalsmith, done) { debug = metalsmith.debug('@metalsmith/in-place') // Check whether the pattern option is valid - if (!(typeof settings.pattern === 'string' || Array.isArray(settings.pattern))) { + if (options.pattern && !(typeof options.pattern === 'string' || Array.isArray(options.pattern))) { return done( new Error( 'invalid pattern, the pattern option should be a string or array of strings. See https://www.npmjs.com/package/@metalsmith/in-place#pattern' @@ -215,13 +225,11 @@ function initializeInPlace(options = defaultOptions) { } } - if (settings.pattern === defaultOptions.pattern) { - settings.pattern = `${settings.pattern}/*.{${transform.inputFormats.join(',')}}` - } + options = Object.assign(normalizeOptions(transform), options) - debug('Running with options %O', settings) + debug('Running with options %O', options) - const matchedFiles = metalsmith.match(settings.pattern) + const matchedFiles = metalsmith.match(options.pattern) // Filter files by validity, pass basename to avoid dots in folder path const validFiles = matchedFiles.filter((filename) => validate({ filename, files, transform })) @@ -235,7 +243,7 @@ function initializeInPlace(options = defaultOptions) { } // Map all files that should be processed to an array of promises and call done when finished - return Promise.all(validFiles.map((filename) => render({ filename, files, metalsmith, settings, transform }))) + return Promise.all(validFiles.map((filename) => render({ filename, files, metalsmith, options, transform }))) .then(() => done()) .catch((error) => done(error)) } diff --git a/test/fixtures/custom-ext-order/expected/index.html b/test/fixtures/custom-ext-order/expected/index.html new file mode 100644 index 0000000..f069683 --- /dev/null +++ b/test/fixtures/custom-ext-order/expected/index.html @@ -0,0 +1,4 @@ +

Title

+
    +
  • one
  • two
  • three
  • +
\ No newline at end of file diff --git a/test/fixtures/custom-ext-order/src/index.hbs.md b/test/fixtures/custom-ext-order/src/index.hbs.md new file mode 100644 index 0000000..dfe3e36 --- /dev/null +++ b/test/fixtures/custom-ext-order/src/index.hbs.md @@ -0,0 +1,5 @@ +## Title + +
    + {{#each nums }}
  • {{ . }}
  • {{/each}} +
\ No newline at end of file diff --git a/test/fixtures/ext-chaining/expected/index.html b/test/fixtures/ext-chaining/expected/index.html new file mode 100644 index 0000000..62dbf73 --- /dev/null +++ b/test/fixtures/ext-chaining/expected/index.html @@ -0,0 +1,6 @@ +

Title

+
    +
  • one
  • +
  • two
  • +
  • three
  • +
diff --git a/test/fixtures/ext-chaining/src/index.md.hbs b/test/fixtures/ext-chaining/src/index.md.hbs new file mode 100644 index 0000000..8fcc270 --- /dev/null +++ b/test/fixtures/ext-chaining/src/index.md.hbs @@ -0,0 +1,5 @@ +## Title + +{{#each nums }} +- {{ . }} +{{/each}} \ No newline at end of file diff --git a/test/fixtures/ignore-extension-without-jstransformer/expected/index.njk b/test/fixtures/ignore-extension-without-jstransformer/expected/index.njk deleted file mode 100644 index b651552..0000000 --- a/test/fixtures/ignore-extension-without-jstransformer/expected/index.njk +++ /dev/null @@ -1 +0,0 @@ -

{{ title }}

diff --git a/test/fixtures/ignore-extensionless/expected/index b/test/fixtures/ignore-extensionless/expected/index deleted file mode 100644 index 5f15a29..0000000 --- a/test/fixtures/ignore-extensionless/expected/index +++ /dev/null @@ -1,2 +0,0 @@ -

{{title}}

- diff --git a/test/fixtures/ignore-extensionless/expected/index.html b/test/fixtures/ignore-extensionless/expected/index.html deleted file mode 100644 index f4ceb78..0000000 --- a/test/fixtures/ignore-extensionless/expected/index.html +++ /dev/null @@ -1,2 +0,0 @@ -

The title

- diff --git a/test/fixtures/ignore-extensionless/src/index b/test/fixtures/ignore-extensionless/src/index deleted file mode 100644 index 6f80991..0000000 --- a/test/fixtures/ignore-extensionless/src/index +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: The title ---- -

{{title}}

- diff --git a/test/fixtures/ignore-extensionless/src/index.hbs b/test/fixtures/ignore-extensionless/src/index.hbs deleted file mode 100644 index 6f80991..0000000 --- a/test/fixtures/ignore-extensionless/src/index.hbs +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: The title ---- -

{{title}}

- diff --git a/test/fixtures/ignore-extension-without-jstransformer/expected/index.html b/test/fixtures/warn-no-matching-extension/expected/index.html similarity index 100% rename from test/fixtures/ignore-extension-without-jstransformer/expected/index.html rename to test/fixtures/warn-no-matching-extension/expected/index.html diff --git a/test/fixtures/warn-no-matching-extension/expected/index.njk b/test/fixtures/warn-no-matching-extension/expected/index.njk new file mode 100644 index 0000000..78144cf --- /dev/null +++ b/test/fixtures/warn-no-matching-extension/expected/index.njk @@ -0,0 +1,2 @@ +

Title

+

{{ title }}

diff --git a/test/fixtures/ignore-extension-without-jstransformer/src/index.md b/test/fixtures/warn-no-matching-extension/src/index.md similarity index 100% rename from test/fixtures/ignore-extension-without-jstransformer/src/index.md rename to test/fixtures/warn-no-matching-extension/src/index.md diff --git a/test/fixtures/ignore-extension-without-jstransformer/src/index.njk b/test/fixtures/warn-no-matching-extension/src/index.njk similarity index 83% rename from test/fixtures/ignore-extension-without-jstransformer/src/index.njk rename to test/fixtures/warn-no-matching-extension/src/index.njk index 1d8bd1e..0273054 100644 --- a/test/fixtures/ignore-extension-without-jstransformer/src/index.njk +++ b/test/fixtures/warn-no-matching-extension/src/index.njk @@ -1,4 +1,5 @@ --- title: A title --- +## Title

{{ title }}

diff --git a/test/index.js b/test/index.js index afe9eb2..34b4e78 100644 --- a/test/index.js +++ b/test/index.js @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' import Metalsmith from 'metalsmith' import equal from 'assert-dir-equal' -import plugin from '../src/index.js' +import plugin, { handleExtname } from '../src/index.js' import jsTransformerPug from 'jstransformer-pug' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -50,6 +50,7 @@ describe('@metalsmith/in-place', () => { it('should throw on unspecified transform option', (done) => { Metalsmith(fixture('transform-option')) + .env('DEBUG', process.env.DEBUG) .use(plugin()) .process((err) => { try { @@ -66,12 +67,15 @@ describe('@metalsmith/in-place', () => { it('should throw on invalid transformer', (done) => { Promise.allSettled([ Metalsmith(fixture('transform-option')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: false })) .process(), Metalsmith(fixture('transform-option')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: { renderAsync() {} } })) .process(), Metalsmith(fixture('transform-option')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: { name: '' } })) .process() ]).then((promises) => { @@ -98,12 +102,15 @@ describe('@metalsmith/in-place', () => { it('should throw on unresolved transform option', (done) => { Promise.allSettled([ Metalsmith(fixture('transform-option')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'invalid' })) .process(), Metalsmith(fixture('transform-option')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: './invalid-local' })) .process(), Metalsmith(fixture('transform-option')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'jstransformer-invalid' })) .process() ]).then((promises) => { @@ -124,6 +131,7 @@ describe('@metalsmith/in-place', () => { it('should resolve the transform option flexibly', (done) => { // Metalsmith.directory() doesn't really matter here, we just need to validate it doesn't return an error Metalsmith(fixture('transform-option')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'handlebars' })) .use(plugin({ transform: 'jstransformer-marked' })) .use(plugin({ transform: jsTransformerPug })) @@ -139,6 +147,7 @@ describe('@metalsmith/in-place', () => { it('should support filepaths with dots in dirpaths', (done) => { Metalsmith(fixture('dots-in-folderpath')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'handlebars' })) .build((err) => { if (err) done(err) @@ -149,6 +158,7 @@ describe('@metalsmith/in-place', () => { it('should process a single file', (done) => { Metalsmith(fixture('single-file')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'marked' })) .build((err) => { if (err) done(err) @@ -159,7 +169,7 @@ describe('@metalsmith/in-place', () => { it('should stop processing after the last extension has been processed', (done) => { Metalsmith(fixture('stop-processing')) - .env(process.env) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'handlebars' })) .use(plugin({ transform: 'marked' })) .build((err) => { @@ -175,6 +185,7 @@ describe('@metalsmith/in-place', () => { it('should process multiple files', (done) => { Metalsmith(fixture('multiple-files')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'marked' })) .build((err) => { if (err) done(err) @@ -189,6 +200,7 @@ describe('@metalsmith/in-place', () => { it('should only process files that match the string pattern', (done) => { Metalsmith(fixture('string-pattern-process')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ pattern: 'index.md', transform: 'marked' })) .build((err) => { if (err) done(err) @@ -203,6 +215,7 @@ describe('@metalsmith/in-place', () => { it('should only process files that match the array pattern', (done) => { Metalsmith(fixture('array-pattern-process')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ pattern: ['index.md', 'extra.md'], transform: 'marked' })) .build((err) => { if (err) done(err) @@ -233,6 +246,7 @@ describe('@metalsmith/in-place', () => { it('should return an error for an invalid pattern', (done) => { Metalsmith(fixture('invalid-pattern')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ pattern: () => {} })) .build((err) => { strictEqual(err instanceof Error, true) @@ -244,16 +258,6 @@ describe('@metalsmith/in-place', () => { }) }) - it('should ignore files without an extension', (done) => { - Metalsmith(fixture('ignore-extensionless')) - .use(plugin({ transform: 'handlebars', pattern: '**' })) - .build((err) => { - if (err) done(err) - equal(fixture('ignore-extensionless/build'), fixture('ignore-extensionless/expected')) - done() - }) - }) - it('should ignore binary files', (done) => { const ms = Metalsmith(fixture('ignore-binary')) ms.env('DEBUG', '@metalsmith/in-place*') @@ -276,6 +280,7 @@ describe('@metalsmith/in-place', () => { it('should correctly transform files when multiple extensions match', (done) => { Metalsmith(fixture('transform-multiple')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'handlebars' })) .use(plugin({ transform: 'marked' })) .build((err) => { @@ -287,6 +292,7 @@ describe('@metalsmith/in-place', () => { it('should correctly transform files when all extensions match', (done) => { Metalsmith(fixture('transform-multiple-and-first')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'handlebars' })) .use(plugin({ transform: 'marked' })) .build((err) => { @@ -296,19 +302,16 @@ describe('@metalsmith/in-place', () => { }) }) - it("should ignore files whose last extension does not match a jstransformer's inputFormats, and log a warning", (done) => { - const ms = Metalsmith(fixture('ignore-extension-without-jstransformer')) + it("should log a warning for files without extension matching a jstransformer's inputFormats", (done) => { + const ms = Metalsmith(fixture('warn-no-matching-extension')) ms.env('DEBUG', '@metalsmith/in-place*') .use(patchDebug()) .use(plugin({ transform: 'marked', pattern: ['index.njk', 'index.md'] })) .build((err) => { - if (err) done(err) try { - equal( - fixture('ignore-extension-without-jstransformer/build'), - fixture('ignore-extension-without-jstransformer/expected') - ) + strictEqual(err, null) + equal(fixture('warn-no-matching-extension/build'), fixture('warn-no-matching-extension/expected')) strictEqual( ms.metadata().logs.find((log) => log[0] === 'warn')[1], 'Validation failed for file "%s", transformer %s supports extensions %s.' @@ -322,6 +325,7 @@ describe('@metalsmith/in-place', () => { it('should prefix rendering errors with the filename', (done) => { Metalsmith(fixture('rendering-error')) + .env('DEBUG', process.env.DEBUG) .use(plugin({ transform: 'handlebars' })) .build((err) => { strictEqual(err instanceof Error, true) @@ -332,11 +336,142 @@ describe('@metalsmith/in-place', () => { it('should understand the filename option in engine options to allow working with Pug and other transformers with a filename option', (done) => { Metalsmith(fixture('set-filename')) - .use(plugin({ transform: 'pug', engineOptions: { filename: true } })) + .env('DEBUG', process.env.DEBUG) + .use(plugin({ transform: { ...jsTransformerPug, inputFormats: ['pug'] }, engineOptions: { filename: true } })) .build((err) => { - if (err) done(err) - equal(fixture('set-filename/build'), fixture('set-filename/expected')) - done() + try { + strictEqual(err, null) + equal(fixture('set-filename/build'), fixture('set-filename/expected')) + done() + } catch (err) { + done(err) + } + }) + }) + + it('should be capable of rendering in rtl file extension order', (done) => { + Metalsmith(fixture('ext-chaining')) + .env('DEBUG', process.env.DEBUG) + .metadata({ nums: ['one', 'two', 'three'] }) + .use(plugin({ transform: 'handlebars' })) + .use(plugin({ transform: 'marked' })) + .build((err) => { + try { + strictEqual(err, null) + equal(fixture('ext-chaining/build'), fixture('ext-chaining/expected')) + done() + } catch (err) { + done(err) + } + }) + }) + + it('should be capable of rendering in a different order than the rtl file extension order', (done) => { + Metalsmith(fixture('custom-ext-order')) + .env('DEBUG', process.env.DEBUG) + .metadata({ nums: ['one', 'two', 'three'] }) + .use(plugin({ transform: 'marked' })) + .use(plugin({ transform: 'handlebars' })) + .build((err) => { + try { + strictEqual(err, null) + equal(fixture('custom-ext-order/build'), fixture('custom-ext-order/expected')) + done() + } catch (err) { + done(err) + } + }) + }) + + describe('extension handling', () => { + const options = { + defaults: { + extname: '.html', + transform: { + inputFormats: ['njk', 'nunjucks'], + outputFormat: 'html' + } + }, + markdown: { + extname: '.html', + transform: { + inputFormats: ['md', 'marked', 'markdown'], + outputFormat: 'html' + } + }, + ejs: { + extname: '.html', + transform: { + inputFormats: ['ejs'], + outputFormat: 'html' + } + } + } + + describe('when filename has a single extension', () => { + // options.extname defaults to `.${options.tranform.outputFormat}` + it('replaces the matching extension with options.extname', () => { + strictEqual(handleExtname('index.njk', options.defaults), 'index.html') + strictEqual(handleExtname('index.njk', { ...options.defaults, extname: '.htm' }), 'index.htm') + }) + it('keeps the extension if options.extname === `.${extension}`', () => { + strictEqual(handleExtname('index.html', options.defaults), 'index.html') + strictEqual(handleExtname('index.htm', { ...options.defaults, extname: '.html' }), 'index.htm') }) + it("removes the extension if options.extname === null|false|''", () => { + strictEqual(handleExtname('index.njk', { ...options.defaults, extname: false }), 'index') + strictEqual(handleExtname('index.njk', { ...options.defaults, extname: '' }), 'index') + strictEqual(handleExtname('index.njk', { ...options.defaults, extname: null }), 'index') + }) + }) + describe('when filename has multiple extensions', () => { + it('strips the rightmost matching extension', () => { + const renamed = handleExtname('index.md.njk', options.defaults) + strictEqual(renamed, 'index.md') + }) + it('strips the rightmost matching extension, regardless of its position', () => { + const renamed = handleExtname('index.njk.md', options.defaults) + strictEqual(renamed, 'index.md') + }) + it('*only* strips the rightmost matching extension, regardless of multiple matches', () => { + const renamed = handleExtname('index.njk.nunjucks.md', options.defaults) + strictEqual(renamed, 'index.njk.md') + }) + it('does not strip any extension if none matches options.transform.inputFormats', () => { + const renamed = handleExtname('index.html.md', options.defaults) + strictEqual(renamed, 'index.html.md') + }) + }) + + describe('supports complex cases', () => { + it('processing a filename with 4 extensions', () => { + const filename = 'index.njk.md.ejs.html' + const renamed = handleExtname( + handleExtname(handleExtname(filename, options.defaults), options.ejs), + options.markdown + ) + strictEqual(renamed, 'index.html') + }) + it('processing a filename with no extensions', () => { + const filename = 'index.html' + const renamed = handleExtname( + handleExtname(handleExtname(filename, options.defaults), options.ejs), + options.markdown + ) + strictEqual(renamed, 'index.html') + }) + it('filepath with periods in dirname', () => { + strictEqual( + handleExtname('some.release/v2.4.0/index.html.njk', options.defaults), + 'some.release/v2.4.0/index.html' + ) + }) + it('filepath with periods in basename', () => { + strictEqual( + handleExtname('some.release/v2.4.0.html.njk', options.defaults), + 'some.release/v2.4.0.html' + ) + }) + }) }) })