From 50477bbc77f6f27ce019da5cc5e973725d284c05 Mon Sep 17 00:00:00 2001 From: Mimi <1119186082@qq.com> Date: Thu, 13 Aug 2026 02:42:26 +0800 Subject: [PATCH] feat: support ES module plugins --- lib/hexo/index.ts | 73 ++++++++++++++++++++++++++---- test/scripts/hexo/load_plugins.ts | 75 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 9 deletions(-) diff --git a/lib/hexo/index.ts b/lib/hexo/index.ts index 8cf08e023..1444e0e45 100644 --- a/lib/hexo/index.ts +++ b/lib/hexo/index.ts @@ -1,10 +1,11 @@ import Promise from 'bluebird'; -import { sep, join, dirname } from 'path'; +import { sep, join, dirname, basename, extname } from 'path'; +import { pathToFileURL } from 'url'; import tildify from 'tildify'; import Database from 'warehouse'; import { magenta, underline } from 'picocolors'; import { EventEmitter } from 'events'; -import { readFile } from 'hexo-fs'; +import { exists, readFile } from 'hexo-fs'; import Module, { createRequire } from 'module'; import { runInThisContext } from 'vm'; const { version } = require('../../package.json'); @@ -42,9 +43,49 @@ import type { AddSchemaTypeOptions } from 'warehouse/dist/types'; import type Schema from 'warehouse/dist/schema'; import BinaryRelationIndex from '../models/binary_relation_index'; +type ESModulePlugin = { + default?: unknown; +}; + +// TypeScript transforms import() into require() when emitting CommonJS. +// eslint-disable-next-line no-new-func +const importModule = new Function('specifier', 'return import(specifier)') as (specifier: string) => PromiseLike; + const libDir = dirname(__dirname); const dbVersion = 1; +function findPackageType(dir: string): Promise { + if (basename(dir) === 'node_modules') return Promise.resolve(undefined); + + const packagePath = join(dir, 'package.json'); + + return exists(packagePath).then(exist => { + if (exist) { + return readFile(packagePath).then(content => { + try { + return JSON.parse(content).type; + } catch { + return undefined; + } + }); + } + + const parent = dirname(dir); + if (parent === dir) return undefined; + + return findPackageType(parent); + }); +} + +function isESModule(path: string): Promise { + const extension = extname(path); + + if (extension === '.mjs') return Promise.resolve(true); + if (extension !== '.js') return Promise.resolve(false); + + return findPackageType(dirname(path)).then(type => type === 'module'); +} + const stopWatcher = (box: Box) => { if (box.isWatching()) box.unwatch(); }; const routeCache = new WeakMap(); @@ -500,17 +541,31 @@ class Hexo extends EventEmitter { } loadPlugin(path: string, callback?: NodeJSLikeCallback): Promise { - return readFile(path).then(script => { - const req = createRequire(path); + return isESModule(path).then(esModule => { + if (esModule) { + return Promise.resolve(importModule(pathToFileURL(path).href)).then(plugin => { + const initialize = plugin.default; - const module = new Module(path); - module.filename = path; + if (typeof initialize !== 'function') { + throw new TypeError(`ES module plugin "${path}" must export a default initialization function.`); + } - script = `(async function(exports, require, module, __filename, __dirname, hexo){${script}\n});`; + return Reflect.apply(initialize, undefined, [this]); + }); + } + + return readFile(path).then(script => { + const req = createRequire(path); + + const module = new Module(path); + module.filename = path; - const fn = runInThisContext(script, path); + script = `(async function(exports, require, module, __filename, __dirname, hexo){${script}\n});`; - return fn(module.exports, req, module, path, dirname(path), this); + const fn = runInThisContext(script, path); + + return fn(module.exports, req, module, path, dirname(path), this); + }); }).asCallback(callback); } diff --git a/test/scripts/hexo/load_plugins.ts b/test/scripts/hexo/load_plugins.ts index 94a1c0ca1..9bb6e8acd 100644 --- a/test/scripts/hexo/load_plugins.ts +++ b/test/scripts/hexo/load_plugins.ts @@ -121,6 +121,81 @@ describe('Load plugins', () => { }); }); + it('load ES module plugins with an .mjs entry', async () => { + const name = 'hexo-esm-plugin-test'; + const pluginDir = join(hexo.plugin_dir, name); + const path = join(pluginDir, 'index.mjs'); + + await BluebirdPromise.all([ + createPackageFile(name), + writeFile(join(pluginDir, 'package.json'), JSON.stringify({ + name, + main: 'index.mjs' + })), + writeFile(join(pluginDir, 'value.mjs'), 'export const value = 42;'), + writeFile(path, [ + 'import { value } from "./value.mjs";', + 'await Promise.resolve();', + 'export default async function initialize(hexo) {', + ' await Promise.resolve();', + ' hexo._script_test = { value, url: import.meta.url };', + '}' + ].join('\n')) + ]); + + await loadPlugins(hexo); + + hexo._script_test.value.should.eql(42); + hexo._script_test.url.should.match(/^file:/); + delete hexo._script_test; + return rmdir(pluginDir); + }); + + it('load ES module plugins from a type module package', async () => { + const name = 'hexo-esm-js-plugin-test'; + const pluginDir = join(hexo.plugin_dir, name); + const path = join(pluginDir, 'index.js'); + + await BluebirdPromise.all([ + createPackageFile(name), + writeFile(join(pluginDir, 'package.json'), JSON.stringify({ + name, + type: 'module', + main: 'index.js' + })), + writeFile(path, [ + 'export default function initialize(hexo) {', + ' hexo._script_test = true;', + '}' + ].join('\n')) + ]); + + await loadPlugins(hexo); + + hexo._script_test.should.eql(true); + delete hexo._script_test; + return rmdir(pluginDir); + }); + + it('reject ES module plugins without a default initializer', async () => { + const pluginDir = join(hexo.plugin_dir, 'hexo-invalid-esm-plugin-test'); + const path = join(pluginDir, 'index.mjs'); + let error: Error | undefined; + + await writeFile(path, 'export const value = true;'); + + try { + await hexo.loadPlugin(path); + } catch (err) { + error = err as Error; + } + + should.exist(error); + error.should.be.instanceOf(TypeError); + error.message.should.contain('must export a default initialization function'); + return rmdir(pluginDir); + }); + it('load scoped plugins', () => { const name = '@some-scope/hexo-plugin-test'; const path = join(hexo.plugin_dir, name, 'index.js');