Skip to content

Commit

Permalink
Merge pull request #534 from marp-team/esm-config
Browse files Browse the repository at this point in the history
ES Modules for the configuration file
  • Loading branch information
yhatt committed Jul 9, 2023
2 parents 99927fe + 2fc72b7 commit c9bc445
Show file tree
Hide file tree
Showing 14 changed files with 246 additions and 79 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [Unreleased]

### Added

- ES Modules support for the configuration file ([#534](https://github.com/marp-team/marp-cli/pull/534))
- `Config` type definition ([#534](https://github.com/marp-team/marp-cli/pull/534))

### Changed

- Upgrade dependent packages to the latest versions ([#533](https://github.com/marp-team/marp-cli/pull/533))
Expand Down
67 changes: 50 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ export default async (constructorOptions) => {
}
```

> :information_source: Currently ESM engine can resolve only when using Marp CLI via Node.js. [The standalone binary](#standalone-binary) cannot resolve ESM due to [vercel/pkg#1291](https://github.com/vercel/pkg/issues/1291).
> :information_source: Currently ES Modules can resolve only when using Marp CLI via Node.js. [The standalone binary](#standalone-binary) cannot resolve ESM due to [vercel/pkg#1291](https://github.com/vercel/pkg/issues/1291).
#### `marp` getter property

Expand Down Expand Up @@ -512,7 +512,9 @@ $ npx marp --version

## Configuration file

Marp CLI can be configured options with file, such as `marp.config.js`, `marp.config.cjs`, `.marprc` (JSON / YAML), and `marp` section of `package.json`. It is useful to configure settings for the whole of project.
Marp CLI can be configured options with file, such as `marp.config.js`, `marp.config.mjs` (ES Modules), `marp.config.cjs` (CommonJS), `.marprc` (JSON / YAML), and `marp` section of `package.json`.

It is useful to configure settings for the whole of project.

```javascript
// package.json
Expand All @@ -536,10 +538,10 @@ pdf: true
```
```javascript
// marp.config.js
const markdownItContainer = require('markdown-it-container')
// marp.config.mjs
import markdownItContainer from 'markdown-it-container'

module.exports = {
export default {
// Customize engine on configuration file directly
engine: ({ marp }) => marp.use(markdownItContainer, 'custom'),
}
Expand All @@ -549,6 +551,8 @@ By default we use configuration file that is placed on current directory, but yo

If you want to prevent looking up a configuration file, you can pass `--no-config-file` (`--no-config`) option.

> :information_source: Currently ES Modules can resolve only when using Marp CLI via Node.js. [The standalone binary](#standalone-binary) cannot resolve ESM due to [vercel/pkg#1291](https://github.com/vercel/pkg/issues/1291).
### Options

| Key | Type | CLI option | Description |
Expand Down Expand Up @@ -578,6 +582,7 @@ If you want to prevent looking up a configuration file, you can pass `--no-confi
| `pdfOutlines` | boolean \| object | `--pdf-outlines` | Add outlines (bookmarks) to PDF |
|`pages` | boolean | `--pdf-outlines.pages` | Make PDF outlines from slide pages (`true` by default when `pdfOutlines` is enabled) |
|`headings` | boolean | `--pdf-outlines.headings` | Make PDF outlines from Markdown headings (`true` by default when `pdfOutlines` is enabled) |
| `pptx` | boolean | `--pptx` | Convert slide deck into PowerPoint document |
| `preview` | boolean | `--preview` `-p` | Open preview window |
| `server` | boolean | `--server` `-s` | Enable server mode |
| `template` | `bare` \| `bespoke` | `--template` | Choose template (`bespoke` by default) |
Expand All @@ -589,20 +594,12 @@ If you want to prevent looking up a configuration file, you can pass `--no-confi

[the whitelist object]: https://github.com/marp-team/marp-core#html-boolean--object

### Advanced

The advanced options that cannot specify through CLI options can be configured by file.

#### Base options for engine constructor
Some of options that cannot specify through CLI options can be configured by file.

`options` can set the base options for the constructor of the used engine. You can fine-tune constructor options for [Marp Core](https://github.com/marp-team/marp-core#constructor-options) / [Marpit](https://marpit-api.marp.app/marpit).
For example, `options` field can set the base options for the constructor of the used engine. You can fine-tune constructor options for the engine, [Marp Core](https://github.com/marp-team/marp-core#constructor-options) / [Marpit](https://marpit-api.marp.app/marpit).

##### Example

The below configuration will set constructor option for Marp Core as specified:

- Disables [Marp Core's line breaks conversion](https://github.com/marp-team/marp-core#marp-markdown) (`\n` to `<br />`) to match for CommonMark, by passing [markdown-it's `breaks` option](https://markdown-it.github.io/markdown-it/#MarkdownIt.new) as `false`.
- Disable minification for rendered theme CSS to make debug your style easily, by passing [`minifyCSS`](https://github.com/marp-team/marp-core#minifycss-boolean) as `false`.
<details>
<summary>Example: Customize engine's constructor option</summary>

```json
{
Expand All @@ -615,8 +612,44 @@ The below configuration will set constructor option for Marp Core as specified:
}
```

This configuration will set the constructor option for Marp Core as specified:

- Disables [Marp Core's line breaks conversion](https://github.com/marp-team/marp-core#marp-markdown) (`\n` to `<br />`) to match for CommonMark, by passing [markdown-it's `breaks` option](https://markdown-it.github.io/markdown-it/#MarkdownIt.new) as `false`.
- Disable minification for rendered theme CSS to make debug your style easily, by passing [`minifyCSS`](https://github.com/marp-team/marp-core#minifycss-boolean) as `false`.

> :warning: Some options may be overridden by used template.
</details>

### Type annotation

For getting better IDE support (such as [IntelliSense](https://code.visualstudio.com/docs/editor/intellisense)) to write a config, you can annotate the config object through JSDoc, with Marp CLI's `Config` type.

```javascript
/** @type {import('@marp-team/marp-cli').Config} */
const config = {
// ...
}

export default config
```

#### `Config` type with custom engine

If you've swapped the engine into another Marpit based engine, you also can provide better suggestion for `options` field by passing the engine type to generics.

```javascript
/** @type {import('@marp-team/marp-cli').Config<typeof import('@marp-team/marpit').Marpit>} */
const config = {
engine: '@marp-team/marpit',
options: {
// Suggest only Marpit constructor options, not Marp Core
},
}

export default config
```

## API _(EXPERIMENTAL)_

You can use Marp CLI through Node.js [if installed Marp CLI into your local project](#local-installation).
Expand Down
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ module.exports = {
'\\.s?css$': '<rootDir>/test/_transformers/css.js',
'\\.png$': '<rootDir>/test/_transformers/png.js',
'\\.pug$': '<rootDir>/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',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@
"@marp-team/marp-core": "^3.7.0",
"@marp-team/marpit": "^2.5.0",
"chokidar": "^3.5.3",
"cosmiconfig": "~8.1.3",
"cosmiconfig": "^8.2.0",
"is-wsl": "^2.2.0",
"puppeteer-core": "20.8.0",
"remove": "^0.1.5",
Expand Down
33 changes: 27 additions & 6 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
import fs from 'fs'
import path from 'path'
import chalk from 'chalk'
import { cosmiconfig } from 'cosmiconfig'
import {
cosmiconfig,
cosmiconfigSync,
Options as CosmiconfigOptions,
} from 'cosmiconfig'
import { osLocale } from 'os-locale'
import { info, warn } from './cli'
import { info, warn, error as cliError } from './cli'
import { ConverterOption, ConvertType } from './converter'
import { ResolvableEngine, ResolvedEngine } from './engine'
import { keywordsAsArray } from './engine/meta-plugin'
Expand Down Expand Up @@ -70,7 +74,7 @@ export type IMarpCLIConfig = Overwrite<
progress?: boolean
transition?: boolean
}
engine?: ResolvableEngine | ResolvableEngine[]
engine?: ResolvableEngine
html?: ConverterOption['html']
keywords?: string | string[]
lang?: string
Expand Down Expand Up @@ -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<ConverterOption> {
Expand Down Expand Up @@ -211,7 +219,7 @@ export class MarpCLIConfig {
this.conf.image

if (image === 'png') return ConvertType.png
if (image === 'jpeg') return ConvertType.jpeg
if (image === 'jpg' || image === 'jpeg') return ConvertType.jpeg

// Detect from filename
const lowerOutput = (output || '').toLowerCase()
Expand Down Expand Up @@ -331,7 +339,9 @@ export class MarpCLIConfig {
}

private async loadConf(confPath?: string) {
const explorer = cosmiconfig(MarpCLIConfig.moduleName)
const explorer = MarpCLIConfig.isESMAvailable()
? cosmiconfig(MarpCLIConfig.moduleName)
: cosmiconfigSync(MarpCLIConfig.moduleName)

try {
const ret = await (confPath === undefined
Expand All @@ -343,10 +353,21 @@ export class MarpCLIConfig {
this.conf = ret.config
}
} catch (e: unknown) {
const isErr = isError(e)

if (isErr && e.code === 'ERR_REQUIRE_ESM') {
// Show reason why `require()` failed in the current context
if ('pkg' in process) {
cliError(
'A standalone binary version of Marp CLI is currently not supported resolving ESM. Please consider using CommonJS, or trying to use Marp CLI via Node.js.'
)
}
}

error(
[
'Could not find or parse configuration file.',
isError(e) && e.name !== 'Error' && `(${e.name})`,
isErr && `(${e.name}: ${e.message.trimEnd()})`,
confPath !== undefined && `[${confPath}]`,
]
.filter((m) => m)
Expand Down
6 changes: 4 additions & 2 deletions src/converter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { URL } from 'url'
import type { MarpOptions } from '@marp-team/marp-core'
import type { Marp, MarpOptions } from '@marp-team/marp-core'
import { Marpit, Options as MarpitOptions } from '@marp-team/marpit'
import chalk from 'chalk'
import type { Browser, Page, HTTPRequest } from 'puppeteer-core'
Expand Down Expand Up @@ -503,7 +503,9 @@ export class Converter {
})

// Resolve functional engine
engine = await Promise.resolve(engine(opts))
engine = await Promise.resolve(
engine(opts as typeof opts & { readonly marp: Marp })
)
}

if (isClass(engine)) engine = new engine(opts)
Expand Down
19 changes: 11 additions & 8 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ import { resolve as importMetaResolve } from 'import-meta-resolve'
import { pkgUp } from 'pkg-up'
import { error, isError } from './error'

type MarpitInstanceOrClass = Marpit | typeof Marpit
type FunctionalEngine<T extends typeof Marpit = typeof Marpit> = (
constructorOptions: ConstructorParameters<T>[0] & { readonly marp: Marp }
) => Marpit | typeof Marpit | Promise<Marpit | typeof Marpit>

type FunctionEngine = (
opts?: Marpit.Options
) => MarpitInstanceOrClass | Promise<MarpitInstanceOrClass>
export type Engine<T extends typeof Marpit = typeof Marpit> =
| Marpit
| typeof Marpit
| FunctionalEngine<T>

export type Engine = MarpitInstanceOrClass | FunctionEngine

export type ResolvableEngine = Engine | string
export type ResolvableEngine<T extends typeof Marpit = typeof Marpit> =
| Engine<T>
| string

const preResolveAsyncSymbol = Symbol('preResolveAsync')

Expand Down Expand Up @@ -188,7 +191,7 @@ export class ResolvedEngine<T extends Engine = Engine> {
// Show reason why `require()` failed in the current context
if ('pkg' in process) {
error(
'A standalone binary version of Marp CLI is currently not supported resolving ESM engine. Please consider using CommonJS engine, or trying to use Marp CLI via Node.js.'
'A standalone binary version of Marp CLI is currently not supported resolving ESM. Please consider using CommonJS, or trying to use Marp CLI via Node.js.'
)
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
import type { Marp } from '@marp-team/marp-core'
import type { Marpit } from '@marp-team/marpit'
import type { IMarpCLIConfig } from './config'
import type { ResolvableEngine } from './engine'

import { apiInterface } from './marp-cli'

type Overwrite<T, U> = Omit<T, Extract<keyof T, keyof U>> & U

export { ObservationHelper, waitForObservation } from './marp-cli'
export { CLIError, CLIErrorCode } from './error'

export const marpCli = apiInterface
export default apiInterface

// ---

// eslint-disable-next-line @typescript-eslint/no-empty-interface -- Use interface instead of type for better IntelliSense
export interface Config<Engine extends typeof Marpit = typeof Marp>
extends Overwrite<
Omit<
IMarpCLIConfig,
/**
* This option is internal setting for collaboration with Marp team tools such as Marp for VS Code.
* It is not designed for users because the result of conversion may break if set wrong base URL.
*/
'baseUrl'
>,
{
engine?: ResolvableEngine<Engine>
image?: 'png' | 'jpeg'
images?: 'png' | 'jpeg'
options?: ConstructorParameters<Engine>[0]
}
> {}
25 changes: 25 additions & 0 deletions test/__mocks__/cosmiconfig.ts
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions test/_configs/esm-project/marp.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('../../../src/index').Config} */
const config = {}

export default config

console.debug('A config file within ESM project was loaded.')
4 changes: 4 additions & 0 deletions test/_configs/esm-project/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"private": true,
"type": "module"
}
3 changes: 3 additions & 0 deletions test/_configs/mjs/config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {}

console.debug('A config file with .mjs extension was loaded.')
Loading

0 comments on commit c9bc445

Please sign in to comment.