The first release since 2021. remark-hbs moves to ESM and unified 11, and the whole toolchain is modernized.
The plugin's rendered output is unchanged. This release is a platform and packaging update, not a behaviour change — see What did not change for how that was verified.
💥 Breaking changes
ESM-only
The package is now "type": "module" with a default export and an exports map. There is no CommonJS build.
// Before
const hbs = require('remark-hbs');
// After
import hbs from 'remark-hbs';
CommonJS consumers are not shut out — require() of an ES module works on the Node versions this package supports, and returns a namespace with __esModule set, so a compiled-TypeScript require('remark-hbs').default keeps working. If you are on an older Node, use await import('remark-hbs').
Because an exports map is now declared, deep imports into the package (remark-hbs/something) are no longer resolvable. Only the package root is public.
Node 20.19+ required
engines moves from >= 10.* to ^20.19.0 || >=22.12.0. Those specific floors are the Node versions that support require() of an ES module, which is what keeps CommonJS consumers working.
unified 11 is now a peer dependency
"peerDependencies": { "unified": "^11.0.0" }
Install unified yourself. unified 9 and 10 are no longer supported — the plugin now uses unist-util-visit 5 and the unified 11 type surface.
Types rewritten
index.d.ts used export = with a namespace, which cannot describe an ESM default export. It is now a default export with RemarkHbsOptions as a named export:
// Before
import remarkHbs from 'remark-hbs'; // via esModuleInterop
// (RemarkHbsOptions lived on a namespace)
// After
import remarkHbs, { type RemarkHbsOptions } from 'remark-hbs';
The option names are unchanged — escapeCurliesCode and escapeCurliesInlineCode, both defaulting to true.
unist-builder dropped
It was only used to build html and text nodes, which are now plain object literals. The dependency is gone; unist-util-visit (^5.1.0) is the only runtime dependency.
🐛 Fixes
- Incompatible with Unified v10 (#27) — the old types imported
Settings from unified, which unified 10 had removed, breaking type-checking with TS2305. unified 11 exports Settings again and the types now resolve cleanly. - Removed a latent
ReferenceError: parseHBS contained a bare recursion = 0 assignment to an undeclared global, which throws under ESM's implicit strict mode.
What did not change
The point of this release was to modernize the platform without moving a single byte of rendered output, and that was verified rather than assumed:
- A harness was pinned to the old stack (unified 9 / remark-html 13.0.1 /
unist-util-visit 2 / unist-builder 2) and first confirmed to reproduce the previously committed snapshots exactly, establishing it as a trustworthy baseline. - Old and new were then diffed across every test input and both fixtures, under default options and with
escapeCurliesCode: false / escapeCurliesInlineCode: false: byte-identical throughout. - Snapshots were migrated to vitest by decoding and comparing them key-by-key against that baseline, not by re-recording them.
- Verified against a real consumer (Docfy): clean compile, unchanged test results, successful classic-Ember and Vite builds, and all 40 generated
.gjs template files byte-identical by checksum.
⚠️ Note for remark-html users
Unrelated to this release, but easy to trip over while upgrading: remark-html began sanitizing by default in 13.0.2, which strips the raw component invocations this plugin emits. If your components vanish from the output, pass sanitize: false:
unified().use(markdown).use(hbs).use(html, { sanitize: false });
Known limitations
Unchanged from previous releases, and confirmed to behave identically:
- An inline component invocation followed by more text drops the trailing text —
Text with <MyComponent /> and {{foo}} loses and {{foo}} (see josemarluedke/docfy#150). - Closing an element tag on its own line is parsed as a blockquote by CommonMark (#17).
🛠️ Internal
- jest → vitest, which runs ESM natively rather than needing
NODE_OPTIONS='--experimental-vm-modules' - yarn → pnpm, with a
packageManager field - CI rebuilt:
actions/checkout@v5 / actions/setup-node@v5 and a Node 20 / 22 / 24 matrix, replacing a global yarn install on Node 10 - Added eslint (flat config) and a
tsc --noEmit type check, both behind a single pnpm run lint; prettier 2 → 3 - Dev tooling to current majors: eslint 10, TypeScript 7, vitest 4, globals 17
- Added
files and exports; removed the stale .npmignore
🤝 Credits
The ESM conversion began in #28 by @NullVoxPopuli back in December 2021 — that PR's ESM work, its .prettierrc.cjs rename, and its fix for the undeclared-global bug all carried into #35.
💥 Breaking Change
📦 Dependencies
Contributors
@NullVoxPopuli, @dependabot, @dependabot[bot] and @josemarluedke
For full changes, see the comparison between v0.4.1 and v0.5.0