diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..7951405 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2dc8fdc..96e1e60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "4.6.0", "license": "MIT", "dependencies": { - "inputformat-to-jstransformer": "^1.2.1", "is-utf8": "^0.2.1", "jstransformer": "^1.0.0" }, @@ -6300,17 +6299,6 @@ "node": ">=10" } }, - "node_modules/inputformat-to-jstransformer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/inputformat-to-jstransformer/-/inputformat-to-jstransformer-1.4.0.tgz", - "integrity": "sha512-Ub+Wjb0mjaND4IS/GDvQ+TEyd1i9U4OdrF58mBY7QTYu8CK5K34DPV7mrvo/WQBJLj7UJWQc7QAmFb7CbQ5lLw==", - "dependencies": { - "require-one": "^1.0.3" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/inquirer": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.6.tgz", @@ -10281,11 +10269,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "node_modules/require-one": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/require-one/-/require-one-1.0.3.tgz", - "integrity": "sha512-5nXixwgbAZfQ65g7fXv5fDXYRiKByOT1NT3EffS+0W/1MoF+Lkbsly3MXIlPIAgEtspX5fWAbXnp6d4BNMx+xQ==" - }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -16935,14 +16918,6 @@ "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", "dev": true }, - "inputformat-to-jstransformer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/inputformat-to-jstransformer/-/inputformat-to-jstransformer-1.4.0.tgz", - "integrity": "sha512-Ub+Wjb0mjaND4IS/GDvQ+TEyd1i9U4OdrF58mBY7QTYu8CK5K34DPV7mrvo/WQBJLj7UJWQc7QAmFb7CbQ5lLw==", - "requires": { - "require-one": "^1.0.3" - } - }, "inquirer": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.6.tgz", @@ -19792,11 +19767,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "require-one": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/require-one/-/require-one-1.0.3.tgz", - "integrity": "sha512-5nXixwgbAZfQ65g7fXv5fDXYRiKByOT1NT3EffS+0W/1MoF+Lkbsly3MXIlPIAgEtspX5fWAbXnp6d4BNMx+xQ==" - }, "resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", diff --git a/package.json b/package.json index c51ef35..0be2fda 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "release-it": "^15.10.5" }, "dependencies": { - "inputformat-to-jstransformer": "^1.2.1", "is-utf8": "^0.2.1", "jstransformer": "^1.0.0" }, diff --git a/src/get-transformer.js b/src/get-transformer.js deleted file mode 100644 index 875bfb6..0000000 --- a/src/get-transformer.js +++ /dev/null @@ -1,21 +0,0 @@ -import jstransformer from 'jstransformer' -import toTransformer from 'inputformat-to-jstransformer' - -/** - * Gets jstransformer for an extension, and caches them - */ - -const cache = {} - -function getTransformer(ext) { - if (ext in cache) { - return cache[ext] - } - - const transformer = toTransformer(ext) - cache[ext] = transformer ? jstransformer(transformer) : false - - return cache[ext] -} - -export default getTransformer diff --git a/src/index.js b/src/index.js index 3db9dce..78e5ec8 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,42 @@ import path from 'node:path' import isUtf8 from 'is-utf8' -import getTransformer from './get-transformer.js' +import jstransformer from 'jstransformer' + +async function getTransformer(namePathOrTransformer) { + let transform = null + const t = namePathOrTransformer + const tName = t + const tPath = t + + // let the jstransformer constructor throw errors + if (typeof t !== 'string') { + transform = Promise.resolve(t) + } else { + if (path.isAbsolute(tPath) || tPath.startsWith('.') || tName.startsWith('jstransformer-')) { + debug('Importing transformer: %s', tPath) + transform = import(tPath).then((t) => t.default) + } else { + debug('Importing transformer: jstransformer-%s', tName) + // suppose a shorthand where the jstransformer- prefix is omitted, more likely + transform = import(`jstransformer-${tName}`) + .then((t) => t.default) + .catch(() => { + // else fall back to trying to import the name + debug.warn('"jstransformer-%s" not found, trying "%s" instead', tName, tName) + return import(tName).then((t) => t.default) + }) + } + } + return transform.then((t) => { + return jstransformer(t) + }) +} +/* c8 ignore start */ let debug = () => { throw new Error('uninstantiated debug') } +/* c8 ignore end */ function parseFilepath(filename) { const isNested = filename.includes(path.sep) @@ -17,7 +49,7 @@ function parseFilepath(filename) { * Engine, renders file contents with all available transformers */ -function render({ filename, files, metalsmith, settings }) { +async function render({ filename, files, metalsmith, settings, transform }) { const { dirname, base, extensions } = parseFilepath(filename) const file = files[filename] const engineOptions = Object.assign({}, settings.engineOptions) @@ -27,21 +59,14 @@ function render({ filename, files, metalsmith, settings }) { debug(`rendering ${filename}`) const ext = extensions.pop() - const transform = getTransformer(ext) const locals = Object.assign({}, metadata, file) - // Stop if the current extension can't be transformed - if (!transform) { - debug(`no transformer available for ${ext} extension for ${filename}`) - return Promise.resolve() - } - // 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 last extension with ${transform.outputFormat}`) + debug(`last extension reached, replacing extension with ${transform.outputFormat}`) extensions.push(transform.outputFormat) } @@ -58,25 +83,17 @@ function render({ filename, files, metalsmith, settings }) { .renderAsync(contents, engineOptions, locals) .then((rendered) => { // Delete old file - delete files[filename] // eslint-disable-line no-param-reassign + delete files[filename] // Update files with the newly rendered file const newName = [path.join(dirname, base), ...extensions].join('.') - files[newName] = file // eslint-disable-line no-param-reassign - files[newName].contents = Buffer.from(rendered.body) // eslint-disable-line no-param-reassign + files[newName] = file + files[newName].contents = Buffer.from(rendered.body) debug(`done rendering ${filename}, renamed to ${newName}`) - - // Stop rendering if this was the last extension - if (isLastExtension) { - return Promise.resolve() - } - - // Otherwise, keep rendering until there are no applicable transformers left - return render({ filename: newName, files, metalsmith, settings }) }) .catch((err) => { - err.message = `${filename}: ${err.message}` // eslint-disable-line no-param-reassign + err.message = `${filename}: ${err.message}` throw err }) } @@ -85,31 +102,27 @@ function render({ filename, files, metalsmith, settings }) { * Validate, checks whether a file should be processed */ -function validate({ filename, files }) { +function validate({ filename, files, transform }) { debug(`validating ${filename}`) const { extensions } = parseFilepath(filename) - // Files without an extension cannot be processed - if (!extensions.length) { - debug.warn(`validation failed, ${filename} does not have an extension`) + // 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])) { + 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 if (!isUtf8(files[filename].contents)) { - debug.warn(`validation failed, ${filename} is not utf-8`) + debug.warn(`Validation failed, %s is not utf-8`, filename) return false } - - // Files without an applicable jstransformer are ignored - const extension = extensions[extensions.length - 1] - const transformer = getTransformer(extension) - - if (!transformer) { - debug.warn(`validation failed, no jstransformer found for last extension of ${filename}`) - } - - return transformer + return true } /** @@ -133,10 +146,10 @@ const defaultOptions = { */ function initializeInPlace(options = defaultOptions) { const settings = Object.assign({}, defaultOptions, options) + let transform - return function inPlace(files, metalsmith, done) { + return async function inPlace(files, metalsmith, done) { debug = metalsmith.debug('@metalsmith/in-place') - debug('Running with options %O', settings) // Check whether the pattern option is valid if (!(typeof settings.pattern === 'string' || Array.isArray(settings.pattern))) { @@ -147,21 +160,39 @@ function initializeInPlace(options = defaultOptions) { ) } + // skip resolving the transform option on repeat runs + if (!transform) { + try { + transform = await getTransformer(options.transform) + } catch (err) { + // pass through jstransformer & Node import resolution errors + return done(err) + } + } + + if (settings.pattern === defaultOptions.pattern) { + settings.pattern = `${settings.pattern}/*.{${transform.inputFormats.join(',')}}` + } + + debug('Running with options %O', settings) + const matchedFiles = metalsmith.match(settings.pattern) // Filter files by validity, pass basename to avoid dots in folder path - const validFiles = matchedFiles.filter((filename) => validate({ filename, files })) + const validFiles = matchedFiles.filter((filename) => validate({ filename, files, transform })) - // Let the user know when there are no files to process, usually caused by missing jstransformer + // Let the user know when there are no files to process if (validFiles.length === 0) { debug.warn('No valid files to process.') return done() + } else { + debug('Rendering %s files', validFiles.length) } // 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 }))) + return Promise.all(validFiles.map((filename) => render({ filename, files, metalsmith, settings, transform }))) .then(() => done()) - .catch(/* istanbul ignore next */ (error) => done(error)) + .catch((error) => done(error)) } } diff --git a/test/fixtures/ignore-binary/expected/binary.hbs b/test/fixtures/ignore-binary/expected/binary.hbs new file mode 100644 index 0000000..9058d7d Binary files /dev/null and b/test/fixtures/ignore-binary/expected/binary.hbs differ diff --git a/test/fixtures/ignore-binary/src/binary.hbs b/test/fixtures/ignore-binary/src/binary.hbs new file mode 100644 index 0000000..9058d7d Binary files /dev/null and b/test/fixtures/ignore-binary/src/binary.hbs differ diff --git a/test/fixtures/string-pattern-process/expected/about.md b/test/fixtures/string-pattern-process/expected/about.md new file mode 100644 index 0000000..6448a25 --- /dev/null +++ b/test/fixtures/string-pattern-process/expected/about.md @@ -0,0 +1,3 @@ +# Title + +Some text diff --git a/test/fixtures/string-pattern-process/src/about.md b/test/fixtures/string-pattern-process/src/about.md new file mode 100644 index 0000000..6448a25 --- /dev/null +++ b/test/fixtures/string-pattern-process/src/about.md @@ -0,0 +1,3 @@ +# Title + +Some text diff --git a/test/fixtures/transform-option/expected/release-2.4.0/index.html b/test/fixtures/transform-option/expected/release-2.4.0/index.html new file mode 100644 index 0000000..b8b6d1e --- /dev/null +++ b/test/fixtures/transform-option/expected/release-2.4.0/index.html @@ -0,0 +1 @@ +

The title

\ No newline at end of file diff --git a/test/fixtures/transform-option/src/release-2.4.0/index.hbs b/test/fixtures/transform-option/src/release-2.4.0/index.hbs new file mode 100644 index 0000000..b8b6d1e --- /dev/null +++ b/test/fixtures/transform-option/src/release-2.4.0/index.hbs @@ -0,0 +1 @@ +

The title

\ No newline at end of file diff --git a/test/index.js b/test/index.js index e325f75..2a30143 100644 --- a/test/index.js +++ b/test/index.js @@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs' import Metalsmith from 'metalsmith' import equal from 'assert-dir-equal' import plugin from '../src/index.js' +import jsTransformerPug from 'jstransformer-pug' const __dirname = dirname(fileURLToPath(import.meta.url)) const { name } = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8')) @@ -15,6 +16,28 @@ function fixture(p) { return resolve(__dirname, 'fixtures', p) } +function patchDebug() { + const output = [] + const Debugger = (...args) => { + output.push(['log', ...args]) + } + Object.assign(Debugger, { + info: (...args) => { + output.push(['info', ...args]) + }, + warn: (...args) => { + output.push(['warn', ...args]) + }, + error: (...args) => { + output.push(['error', ...args]) + } + }) + return function patchDebug(files, ms) { + ms.debug = () => Debugger + ms.metadata({ logs: output }) + } +} + describe('@metalsmith/in-place', () => { it('should export a named plugin function matching package.json name', function () { const namechars = name.split('/')[1] @@ -25,9 +48,98 @@ describe('@metalsmith/in-place', () => { strictEqual(plugin().name, camelCased) }) + it('should throw on unspecified transform option', (done) => { + Metalsmith(fixture('transform-option')) + .use(plugin()) + .process((err) => { + try { + strictEqual(err instanceof Error, true) + strictEqual(err.code, 'ERR_ASSERTION') + strictEqual(err.message, 'Transformer must be an object') + done() + } catch (err) { + done(err) + } + }) + }) + + it('should throw on invalid transformer', (done) => { + Promise.allSettled([ + Metalsmith(fixture('transform-option')) + .use(plugin({ transform: false })) + .process(), + Metalsmith(fixture('transform-option')) + .use(plugin({ transform: { renderAsync() {} } })) + .process(), + Metalsmith(fixture('transform-option')) + .use(plugin({ transform: { name: '' } })) + .process() + ]).then((promises) => { + const messages = [ + 'Transformer must be an object', + 'Transformer must have a name', + 'Transformer must have an output format' + ] + for (let i = 0; i < 3; i++) { + const err = promises[i].reason + try { + strictEqual(err instanceof Error, true) + strictEqual(err.code, 'ERR_ASSERTION') + strictEqual(err.message, messages[i]) + } catch (err) { + done(err) + break + } + } + done() + }) + }) + + it('should throw on unresolved transform option', (done) => { + Promise.allSettled([ + Metalsmith(fixture('transform-option')) + .use(plugin({ transform: 'invalid' })) + .process(), + Metalsmith(fixture('transform-option')) + .use(plugin({ transform: './invalid-local' })) + .process(), + Metalsmith(fixture('transform-option')) + .use(plugin({ transform: 'jstransformer-invalid' })) + .process() + ]).then((promises) => { + for (let i = 0; i < 3; i++) { + const reason = promises[i].reason + try { + strictEqual(reason instanceof Error, true) + strictEqual(reason.code, 'ERR_MODULE_NOT_FOUND') + } catch (err) { + done(err) + break + } + } + done() + }) + }) + + it('should resolve the transform option flexibly', (done) => { + Metalsmith(fixture('dots-in-folderpath')) + .use(plugin({ transform: 'handlebars' })) + .use(plugin({ transform: 'jstransformer-marked' })) + .use(plugin({ transform: jsTransformerPug })) + .build((err) => { + if (err) done(err) + try { + equal(fixture('stop-processing/build'), fixture('stop-processing/expected')) + done() + } catch (err) { + done(err) + } + }) + }) + it('should support filepaths with dots in dirpaths', (done) => { Metalsmith(fixture('dots-in-folderpath')) - .use(plugin()) + .use(plugin({ transform: 'handlebars' })) .build((err) => { if (err) done(err) equal(fixture('dots-in-folderpath/build'), fixture('dots-in-folderpath/expected')) @@ -37,7 +149,7 @@ describe('@metalsmith/in-place', () => { it('should process a single file', (done) => { Metalsmith(fixture('single-file')) - .use(plugin()) + .use(plugin({ transform: 'marked' })) .build((err) => { if (err) done(err) equal(fixture('single-file/build'), fixture('single-file/expected')) @@ -47,37 +159,51 @@ describe('@metalsmith/in-place', () => { it('should stop processing after the last extension has been processed', (done) => { Metalsmith(fixture('stop-processing')) - .use(plugin()) + .env(process.env) + .use(plugin({ transform: 'handlebars' })) + .use(plugin({ transform: 'marked' })) .build((err) => { if (err) done(err) - equal(fixture('stop-processing/build'), fixture('stop-processing/expected')) - done() + try { + equal(fixture('stop-processing/build'), fixture('stop-processing/expected')) + done() + } catch (err) { + done(err) + } }) }) it('should process multiple files', (done) => { Metalsmith(fixture('multiple-files')) - .use(plugin()) + .use(plugin({ transform: 'marked' })) .build((err) => { if (err) done(err) - equal(fixture('multiple-files/build'), fixture('multiple-files/expected')) - done() + try { + equal(fixture('multiple-files/build'), fixture('multiple-files/expected')) + done() + } catch (err) { + done(err) + } }) }) it('should only process files that match the string pattern', (done) => { Metalsmith(fixture('string-pattern-process')) - .use(plugin({ pattern: '*.md' })) + .use(plugin({ pattern: 'index.md', transform: 'marked' })) .build((err) => { if (err) done(err) - equal(fixture('string-pattern-process/build'), fixture('string-pattern-process/expected')) - return done() + try { + equal(fixture('string-pattern-process/build'), fixture('string-pattern-process/expected')) + done() + } catch (err) { + done(err) + } }) }) it('should only process files that match the array pattern', (done) => { Metalsmith(fixture('array-pattern-process')) - .use(plugin({ pattern: ['index.md', 'extra.md'] })) + .use(plugin({ pattern: ['index.md', 'extra.md'], transform: 'marked' })) .build((err) => { if (err) done(err) equal(fixture('array-pattern-process/build'), fixture('array-pattern-process/expected')) @@ -85,34 +211,19 @@ describe('@metalsmith/in-place', () => { }) }) - it('should return an error when there are no valid files to process', (done) => { - const ms = Metalsmith(fixture('no-files')), - output = [] - const Debugger = (...args) => { - output.push(['log', ...args]) - } - Object.assign(Debugger, { - info: (...args) => { - output.push(['info', ...args]) - }, - warn: (...args) => { - output.push(['warn', ...args]) - }, - error: (...args) => { - output.push(['error', ...args]) - } - }) - ms.env('DEBUG', '*:warn') - .use(() => { - ms.debug = () => Debugger - }) - .use(plugin()) + it('should log a warning when there are no valid files to process', (done) => { + const ms = Metalsmith(fixture('no-files')) + + ms.env('DEBUG', '@metalsmith/in-place:warn') + .use(patchDebug()) + .use(plugin({ transform: 'marked' })) .build(() => { try { - deepStrictEqual(output.slice(output.length - 2), [ - ['warn', 'validation failed, index does not have an extension'], - ['warn', 'No valid files to process.'] - ]) + const { logs } = ms.metadata() + deepStrictEqual( + logs.filter((l) => l[0] === 'warn'), + [['warn', 'No valid files to process.']] + ) done() } catch (err) { done(err) @@ -135,7 +246,7 @@ describe('@metalsmith/in-place', () => { it('should ignore files without an extension', (done) => { Metalsmith(fixture('ignore-extensionless')) - .use(plugin()) + .use(plugin({ transform: 'handlebars', pattern: '**' })) .build((err) => { if (err) done(err) equal(fixture('ignore-extensionless/build'), fixture('ignore-extensionless/expected')) @@ -144,18 +255,29 @@ describe('@metalsmith/in-place', () => { }) it('should ignore binary files', (done) => { - Metalsmith(fixture('ignore-binary')) - .use(plugin()) + const ms = Metalsmith(fixture('ignore-binary')) + ms.env('DEBUG', '@metalsmith/in-place*') + .use(patchDebug()) + .use(plugin({ transform: 'handlebars' })) .build((err) => { if (err) done(err) - equal(fixture('ignore-binary/build'), fixture('ignore-binary/expected')) - return done() + try { + equal(fixture('ignore-binary/build'), fixture('ignore-binary/expected')) + deepStrictEqual( + ms.metadata().logs.filter((l) => l[0] === 'warn'), + [['warn', 'Validation failed, %s is not utf-8', 'binary.hbs']] + ) + done() + } catch (err) { + done(err) + } }) }) it('should correctly transform files when multiple extensions match', (done) => { Metalsmith(fixture('transform-multiple')) - .use(plugin()) + .use(plugin({ transform: 'handlebars' })) + .use(plugin({ transform: 'marked' })) .build((err) => { if (err) done(err) equal(fixture('transform-multiple/build'), fixture('transform-multiple/expected')) @@ -165,7 +287,8 @@ describe('@metalsmith/in-place', () => { it('should correctly transform files when all extensions match', (done) => { Metalsmith(fixture('transform-multiple-and-first')) - .use(plugin()) + .use(plugin({ transform: 'handlebars' })) + .use(plugin({ transform: 'marked' })) .build((err) => { if (err) done(err) equal(fixture('transform-multiple-and-first/build'), fixture('transform-multiple-and-first/expected')) @@ -173,22 +296,33 @@ describe('@metalsmith/in-place', () => { }) }) - it('should ignore files with extensions that do not match a jstransformer', (done) => { - Metalsmith(fixture('ignore-extension-without-jstransformer')) - .use(plugin()) + 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')) + + ms.env('DEBUG', '@metalsmith/in-place*') + .use(patchDebug()) + .use(plugin({ transform: 'marked', pattern: ['index.njk', 'index.md'] })) .build((err) => { if (err) done(err) - equal( - fixture('ignore-extension-without-jstransformer/build'), - fixture('ignore-extension-without-jstransformer/expected') - ) - done() + try { + equal( + fixture('ignore-extension-without-jstransformer/build'), + fixture('ignore-extension-without-jstransformer/expected') + ) + strictEqual( + ms.metadata().logs.find((log) => log[0] === 'warn')[1], + 'Validation failed for file "%s", transformer %s supports extensions %s.' + ) + done() + } catch (err) { + done(err) + } }) }) it('should prefix rendering errors with the filename', (done) => { Metalsmith(fixture('rendering-error')) - .use(plugin()) + .use(plugin({ transform: 'handlebars' })) .build((err) => { strictEqual(err instanceof Error, true) strictEqual(err.message.split(/\r*\n/)[0], 'index.hbs: Parse error on line 1:') @@ -198,7 +332,7 @@ describe('@metalsmith/in-place', () => { it('should accept an option to set the filename in engine options', (done) => { Metalsmith(fixture('set-filename')) - .use(plugin({ setFilename: true })) + .use(plugin({ setFilename: true, transform: 'pug' })) .build((err) => { if (err) done(err) equal(fixture('set-filename/build'), fixture('set-filename/expected'))