Skip to content

Commit b314915

Browse files
authored
Merge pull request #731 from marp-team/node-sea
Adopt Node SEA for building the standalone binary
2 parents cc1fa23 + 5fb7030 commit b314915

15 files changed

Lines changed: 23 additions & 246 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## [Unreleased]
44

5+
### Change
6+
7+
- Adopt [Node.js SEA](https://nodejs.org/api/single-executable-applications.html) for building the standalone binary ([#568](https://github.com/marp-team/marp-cli/issues/568), [#731](https://github.com/marp-team/marp-cli/pull/731))
8+
- Now the standalone binary allows using ES Modules and `import()` in the engine and configuration file
9+
510
## v4.4.1 - 2026-07-04
611

712
### Changed

README.md

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@ The functional engine should export a function as the default export, which shou
518518
The function must return a class that inherits from Marpit, or an instance of a Marpit-based engine created with the constructor options passed as an argument.
519519

520520
```javascript
521-
// engine.mjs (ES modules)
521+
// engine.mjs
522522
import { MarpitBasedEngine } from 'marpit-based-engine'
523523

524524
export default () => MarpitBasedEngine // Return a class inherited from Marpit
@@ -554,10 +554,6 @@ export default async (constructorOptions) => {
554554
}
555555
```
556556

557-
> [!WARNING]
558-
>
559-
> Currently ES Modules can resolve only when using Marp CLI via Node.js. [The standalone binary](#standalone-binary) cannot resolve ESM. ([vercel/pkg#1291](https://github.com/vercel/pkg/issues/1291))
560-
561557
#### `marp` getter property
562558

563559
Marp CLI also exposes `marp` getter property to the parameter. It returns a prepared instance of the built-in Marp Core engine, so you can apply several customizations to Marp engine with simple declarations.
@@ -651,10 +647,6 @@ By default we use configuration file that is placed on current directory, but yo
651647

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

654-
> [!WARNING]
655-
>
656-
> Currently ES Modules can resolve only when using Marp CLI via Node.js. [The standalone binary](#standalone-binary) cannot resolve ESM. ([vercel/pkg#1291](https://github.com/vercel/pkg/issues/1291))
657-
658650
### Options
659651

660652
| Key | Type | CLI option | Description |

jest.config.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
const esModules = [
2-
'@formatjs/intl-segmenter',
32
'@sindresorhus/merge-streams',
43
'ansi-regex',
54
'array-union',

package-lock.json

Lines changed: 0 additions & 42 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"prepack": "npm-run-all --parallel check:* lint:* test:coverage --parallel build types",
6060
"prepare": "npx --no patch-package || exit 0",
6161
"preversion": "run-p check:* lint:* test:coverage",
62-
"standalone": "node -e \"fs.rmSync('bin',{recursive:true,force:true})\" && pkg -C gzip --out-path ./bin .",
62+
"standalone": "node -e \"fs.rmSync('bin',{recursive:true,force:true})\" && pkg --sea -C Zstd --out-path ./bin .",
6363
"standalone:pack": "node ./scripts/pack.mjs",
6464
"test": "jest",
6565
"test:coverage": "jest --coverage",
@@ -72,7 +72,6 @@
7272
"@babel/preset-env": "^7.29.7",
7373
"@babel/preset-react": "^7.29.7",
7474
"@babel/preset-typescript": "^7.29.7",
75-
"@formatjs/intl-segmenter": "^12.2.10",
7675
"@rollup/plugin-alias": "^6.0.0",
7776
"@rollup/plugin-commonjs": "^29.0.3",
7877
"@rollup/plugin-json": "^6.1.0",
@@ -114,7 +113,6 @@
114113
"fast-plist": "^0.1.3",
115114
"globals": "^17.7.0",
116115
"image-size": "^2.0.2",
117-
"import-from": "^4.0.0",
118116
"import-meta-resolve": "^4.2.0",
119117
"is-inside-container": "^1.0.0",
120118
"is-wsl": "^3.1.1",

rollup.config.mjs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,6 @@ export default [
111111
'src/patch.ts', // CLI patch
112112
'src/prepare.ts', // CLI preparation
113113
],
114-
output: {
115-
compact,
116-
dir: 'lib',
117-
exports: 'named',
118-
format: 'cjs',
119-
dynamicImportInCjs: false, // Required to avoid using `import()` that is incompatible with standalone binary
120-
},
114+
output: { compact, dir: 'lib', exports: 'named', format: 'cjs' },
121115
},
122116
]

scripts/pack.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ const packToTarGz = (binary) => {
2424
pack.entry({ name: binaryName, mode: 0o755 }, binary)
2525
pack.finalize()
2626

27-
return pack.pipe(zlib.createGzip())
27+
return pack.pipe(
28+
zlib.createGzip({ level: zlib.constants.Z_BEST_COMPRESSION })
29+
)
2830
}
2931

3032
// Clean up dist directory
@@ -69,7 +71,9 @@ if (os.includes('windows')) {
6971
fs.readFile(path.resolve(bin, 'marp-cli-win.exe'), (err, buffer) => {
7072
if (err) throw err
7173

72-
const pack = new ZipStream()
74+
const pack = new ZipStream({
75+
zlib: { level: zlib.constants.Z_BEST_COMPRESSION },
76+
})
7377

7478
pack.entry(buffer, { name: `${binaryName}.exe` })
7579
pack.finalize()

src/config.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
33
import chalk from 'chalk'
4-
import { cosmiconfig, cosmiconfigSync } from 'cosmiconfig'
4+
import { cosmiconfig } from 'cosmiconfig'
55
import { osLocale } from 'os-locale'
66
import { availableFinders } from './browser/finder'
77
import type { FinderName } from './browser/finder'
88
import type { BrowserManagerConfig } from './browser/manager'
9-
import { info, warn, error as cliError } from './cli'
9+
import { info, warn } from './cli'
1010
import { ConvertType, type ConverterOption } from './converter'
1111
import { ResolvableEngine, ResolvedEngine } from './engine'
1212
import { keywordsAsArray } from './engine/meta-plugin'
1313
import { error, isError } from './error'
1414
import { TemplateOption } from './templates'
1515
import { Theme, ThemeSet } from './theme'
16-
import { isStandaloneBinary } from './utils/binary'
1716
import { isOfficialContainerImage } from './utils/container'
1817
import { debugConfig } from './utils/debug'
1918

@@ -126,10 +125,6 @@ export class MarpCLIConfig {
126125
return conf
127126
}
128127

129-
static isESMAvailable() {
130-
return ResolvedEngine.isESMAvailable()
131-
}
132-
133128
private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function
134129

135130
browserManagerOption() {
@@ -435,9 +430,7 @@ export class MarpCLIConfig {
435430
}
436431

437432
private async loadConf(confPath?: string) {
438-
const explorer = MarpCLIConfig.isESMAvailable()
439-
? cosmiconfig(MarpCLIConfig.moduleName)
440-
: cosmiconfigSync(MarpCLIConfig.moduleName)
433+
const explorer = cosmiconfig(MarpCLIConfig.moduleName)
441434

442435
try {
443436
const ret = await (async () => {
@@ -473,15 +466,6 @@ export class MarpCLIConfig {
473466

474467
const isErr = isError(e)
475468

476-
if (isErr && e.code === 'ERR_REQUIRE_ESM') {
477-
// Show reason why `require()` failed in the current context
478-
if (isStandaloneBinary()) {
479-
cliError(
480-
'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.'
481-
)
482-
}
483-
}
484-
485469
error(
486470
[
487471
'Could not find or parse configuration file.',

src/engine.ts

Lines changed: 4 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@ import path from 'node:path'
33
import url from 'node:url'
44
import type { Marp } from '@marp-team/marp-core'
55
import { Marpit } from '@marp-team/marpit'
6-
import importFrom from 'import-from'
76
import { resolve as importMetaResolve } from 'import-meta-resolve'
87
import { packageUp } from 'package-up'
9-
import { error, isError } from './error'
10-
import { isStandaloneBinary } from './utils/binary'
8+
import { error } from './error'
119
import { debugEngine } from './utils/debug'
1210

1311
type FunctionalEngine<T extends typeof Marpit = typeof Marpit> = (
@@ -47,8 +45,7 @@ export class ResolvedEngine<T extends Engine = Engine> {
4745

4846
// Bundled Marp Core
4947
Object.assign(
50-
// eslint-disable-next-line @typescript-eslint/no-require-imports -- import statement brings TypeError in the standalone binary
51-
() => Promise.resolve(require('@marp-team/marp-core').Marp),
48+
() => import('@marp-team/marp-core').then(({ Marp }) => Marp),
5249
{ [preResolveAsyncSymbol]: true }
5350
),
5451
])
@@ -74,11 +71,11 @@ export class ResolvedEngine<T extends Engine = Engine> {
7471
if (typeof eng === 'string') {
7572
resolved =
7673
(from &&
77-
(await this._silentImportOrRequire(
74+
(await this._silentImport(
7875
eng,
7976
path.dirname(path.resolve(from))
8077
))) ||
81-
(await this._silentImportOrRequire(eng))
78+
(await this._silentImport(eng))
8279
} else if (typeof eng === 'function' && eng[preResolveAsyncSymbol]) {
8380
resolved = await (eng as any)()
8481
} else {
@@ -121,21 +118,6 @@ export class ResolvedEngine<T extends Engine = Engine> {
121118
return require(pkgPath) as Record<string, any>
122119
}
123120

124-
static isESMAvailable() {
125-
// Standalone binary that is built by pkg cannot import ESM module.
126-
// https://github.com/vercel/pkg/issues/1291
127-
return !isStandaloneBinary()
128-
}
129-
130-
private static async _silentImportOrRequire<T = any>(
131-
moduleId: string,
132-
from?: string
133-
): Promise<T | null> {
134-
if (this.isESMAvailable()) return this._silentImport(moduleId, from)
135-
136-
return this._silentRequire(moduleId, from)
137-
}
138-
139121
private static async _silentImport<T = any>(
140122
moduleId: string,
141123
from?: string
@@ -188,38 +170,6 @@ export class ResolvedEngine<T extends Engine = Engine> {
188170
}
189171
}
190172

191-
private static async _silentRequire<T = any>(
192-
moduleId: string,
193-
from?: string
194-
): Promise<T | null> {
195-
try {
196-
const resolvedFrom = from
197-
? path.dirname(path.resolve(from))
198-
: process.cwd()
199-
200-
return importFrom(resolvedFrom, moduleId) as T | null
201-
202-
/* c8 ignore start */
203-
} catch (e) {
204-
debugEngine(
205-
'Failed to require %s.',
206-
moduleId + (from ? ` from ${from}` : '')
207-
)
208-
debugEngine('%O', e)
209-
210-
if (isError(e) && e.code === 'ERR_REQUIRE_ESM') {
211-
// Show reason why `require()` failed in the current context
212-
if (isStandaloneBinary()) {
213-
error(
214-
'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.'
215-
)
216-
}
217-
}
218-
return null // Jest allows importing ESM via `require()` so this line cannot measure coverage.
219-
}
220-
/* c8 ignore stop */
221-
}
222-
223173
// NOTE: It cannot test because of overriding `require` in Jest context.
224174
/* c8 ignore start */
225175
private findClassPath(klass) {

src/patch.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import module from 'node:module'
2-
import { isStandaloneBinary } from './utils/binary'
3-
import { debug } from './utils/debug'
42

53
export const patcher = {
64
patch: () => {
7-
patcher.patchSegmenter()
85
patcher.enableCompileCache()
96
},
107
enableCompileCache: () => {
@@ -15,14 +12,6 @@ export const patcher = {
1512
// no ops
1613
}
1714
},
18-
patchSegmenter: () => {
19-
// Avoid SEGFAULT in the standalone binary. pkg is using a Node.js build with small ICU.
20-
// https://github.com/nodejs/node/issues/51752
21-
if (isStandaloneBinary() && process.config.variables['icu_small']) {
22-
debug('Using a polyfilled implementation for Intl.Segmenter.')
23-
require('@formatjs/intl-segmenter/polyfill-force.js') // eslint-disable-line @typescript-eslint/no-require-imports
24-
}
25-
},
2615
}
2716

2817
export const patch = () => patcher.patch()

0 commit comments

Comments
 (0)