Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 64 additions & 9 deletions lib/hexo/index.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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<ESModulePlugin>;

const libDir = dirname(__dirname);
const dbVersion = 1;

function findPackageType(dir: string): Promise<string | undefined> {
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<boolean> {
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();
Expand Down Expand Up @@ -500,17 +541,31 @@ class Hexo extends EventEmitter {
}

loadPlugin(path: string, callback?: NodeJSLikeCallback<any>): Promise<any> {
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);
}

Expand Down
75 changes: 75 additions & 0 deletions test/scripts/hexo/load_plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading