diff --git a/jest.config.js b/jest.config.js index d95fa26b..253252bd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -32,7 +32,11 @@ module.exports = { '\\.s?css$': '/test/_transformers/css.js', '\\.png$': '/test/_transformers/png.js', '\\.pug$': '/test/_transformers/pug.js', + + // TODO: Remove if Jest did not fail on ESM dynamic imports 'custom-engine\\.mjs$': 'babel-jest', + 'config\\.mjs$': 'babel-jest', + 'esm-project/marp\\.config\\.js$': 'babel-jest', }, transformIgnorePatterns: [`/node_modules/(?!${esModules.join('|')})`], testEnvironment: 'node', diff --git a/src/config.ts b/src/config.ts index 1b836b2c..db9ee2c6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,11 @@ import fs from 'fs' import path from 'path' import chalk from 'chalk' -import { cosmiconfig, cosmiconfigSync } from 'cosmiconfig' +import { + cosmiconfig, + cosmiconfigSync, + Options as CosmiconfigOptions, +} from 'cosmiconfig' import { osLocale } from 'os-locale' import { info, warn, error as cliError } from './cli' import { ConverterOption, ConvertType } from './converter' @@ -110,6 +114,10 @@ export class MarpCLIConfig { return conf } + static isESMAvailable() { + return ResolvedEngine.isESMAvailable() + } + private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function async converterOption(): Promise { @@ -331,11 +339,9 @@ export class MarpCLIConfig { } private async loadConf(confPath?: string) { - const generateCosmiconfigExplorer = ResolvedEngine.isESMAvailable() - ? cosmiconfig - : cosmiconfigSync // sync version is using `require()` instead of `import()` so not expect to meet a trouble with ESM - - const explorer = generateCosmiconfigExplorer(MarpCLIConfig.moduleName) + const explorer = MarpCLIConfig.isESMAvailable() + ? cosmiconfig(MarpCLIConfig.moduleName) + : cosmiconfigSync(MarpCLIConfig.moduleName) try { const ret = await (confPath === undefined diff --git a/test/__mocks__/cosmiconfig.ts b/test/__mocks__/cosmiconfig.ts new file mode 100644 index 00000000..e052e3b7 --- /dev/null +++ b/test/__mocks__/cosmiconfig.ts @@ -0,0 +1,25 @@ +const cosmiconfig: typeof import('cosmiconfig') = + jest.requireActual('cosmiconfig') + +const { + cosmiconfig: originalCosmiconfig, + defaultLoaders, + defaultLoadersSync, +} = cosmiconfig + +// Because of v8's bug, Jest fails with SIGSEGV if used dynamic import. +// When using ESM in tests, you have to set up Jest to transpile config files into CommonJS with Babel. +cosmiconfig.cosmiconfig = jest.fn((moduleName, options) => { + return originalCosmiconfig(moduleName, { + loaders: { + // cosmiconfig sync loader is using `require()` to load JS + ...defaultLoaders, + '.js': defaultLoadersSync['.js'], + '.mjs': defaultLoadersSync['.js'], + '.cjs': defaultLoadersSync['.js'], + }, + ...(options ?? {}), + }) +}) + +module.exports = cosmiconfig diff --git a/test/_configs/esm-project/marp.config.js b/test/_configs/esm-project/marp.config.js new file mode 100644 index 00000000..38540d0a --- /dev/null +++ b/test/_configs/esm-project/marp.config.js @@ -0,0 +1,6 @@ +/** @type {import('../../../src/index').Config} */ +const config = {} + +export default config + +console.debug('A config file with .mjs extension was loaded.') diff --git a/test/_configs/esm-project/package.json b/test/_configs/esm-project/package.json new file mode 100644 index 00000000..e986b24b --- /dev/null +++ b/test/_configs/esm-project/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/test/_configs/mjs/config.mjs b/test/_configs/mjs/config.mjs new file mode 100644 index 00000000..edbd91b8 --- /dev/null +++ b/test/_configs/mjs/config.mjs @@ -0,0 +1,3 @@ +export default {} + +console.debug('A config file with .mjs extension was loaded.') diff --git a/test/marp-cli.ts b/test/marp-cli.ts index 19e1cfb0..61b6563b 100644 --- a/test/marp-cli.ts +++ b/test/marp-cli.ts @@ -37,6 +37,7 @@ const runForObservation = async (argv: string[]) => { return ret } +jest.mock('cosmiconfig') jest.mock('fs') jest.mock('../src/preview') jest.mock('../src/watcher', () => jest.createMockFromModule('../src/watcher')) @@ -1110,6 +1111,48 @@ describe('Marp CLI', () => { } }) }) + + describe('with ES Module', () => { + it('allows loading config with mjs extension', async () => { + const debug = jest.spyOn(console, 'debug').mockImplementation() + const log = jest.spyOn(console, 'log').mockImplementation() + + try { + expect( + await marpCli(['-v', '-c', assetFn('_configs/mjs/config.mjs')]) + ).toBe(0) + + expect(debug).toHaveBeenCalledWith( + expect.stringContaining('loaded') + ) + } finally { + debug.mockRestore() + log.mockRestore() + } + }) + + it('allows loading config from ESM project', async () => { + const debug = jest.spyOn(console, 'debug').mockImplementation() + const log = jest.spyOn(console, 'log').mockImplementation() + + try { + expect( + await marpCli([ + '-v', + '-c', + assetFn('_configs/esm-project/marp.config.js'), + ]) + ).toBe(0) + + expect(debug).toHaveBeenCalledWith( + expect.stringContaining('loaded') + ) + } finally { + debug.mockRestore() + log.mockRestore() + } + }) + }) }) describe('with --preview / -p option', () => {