diff --git a/packages/vue-generator/src/generator/codeGenerator.js b/packages/vue-generator/src/generator/codeGenerator.js new file mode 100644 index 000000000..8d385cf74 --- /dev/null +++ b/packages/vue-generator/src/generator/codeGenerator.js @@ -0,0 +1,118 @@ +class CodeGenerator { + config = {} + genResult = [] + plugins = [] + genLogs = [] + schema = {} + parsedSchema = {} + context = {} + constructor(config) { + this.config = config + this.plugins = config.plugins + } + getContext() { + return { + config: this.config, + genResult: this.genResult, + plugins: this.plugins, + genLogs: this.genLogs, + schema: this.schema, + parsedSchema: this.parsedSchema, + ...this.context + } + } + getPluginsByHook(hookName) { + const res = [] + + for (const pluginItem of this.plugins) { + if (typeof pluginItem[hookName] === 'function') { + res.push(pluginItem[hookName]) + } + } + + return res + } + async generate(schema) { + const hooks = ['transformStart', 'parseConfig', 'parseSchema', 'transform', 'transformEnd'] + let err = null + + this.schema = schema + + try { + for (const hookItem of hooks) { + const plugins = this.getPluginsByHook(hookItem) + + await this[hookItem](plugins) + } + } catch (error) { + err = error + } finally { + const plugins = this.getPluginsByHook('transformEnd') + await this.transformEnd(plugins, err) + } + + return { + genResult: this.genResult, + genLogs: this.genLogs + } + } + async transformStart(plugins) { + for (const pluginItem of plugins) { + await pluginItem.apply(this, [this.config]) + } + } + async parseConfig(plugins) { + for (const pluginItem of plugins) { + const newConfig = await pluginItem.apply(this, [this.config]) + + if (newConfig) { + this.config = newConfig + } + } + } + + async parseSchema(plugins) { + for (const pluginItem of plugins) { + const parseResult = await pluginItem.apply(this, [this.schema]) + + if (!parseResult?.id || !parseResult?.result) { + continue + } + + this.parsedSchema[parseResult.id] = parseResult.result + } + } + async transform(plugins) { + for (const pluginItem of plugins) { + const transformRes = await pluginItem.apply(this, [this.parsedSchema]) + + if (!transformRes) { + return + } + + if (Array.isArray(transformRes)) { + this.genResult.push(...transformRes) + } else { + this.genResult.push(transformRes) + } + } + } + async transformEnd(plugins, err) { + for (const pluginItem of plugins) { + await pluginItem.apply(this, [err]) + } + } + replaceGenResult(resultItem) { + const { path, fileName } = resultItem + + const index = this.genResult.findIndex((item) => item.path === path && item.fileName === fileName) + + if (index === -1) { + return + } + + this.genResult.splice(index, 1, resultItem) + } +} + +export default CodeGenerator diff --git a/packages/vue-generator/src/generator/generateApp.js b/packages/vue-generator/src/generator/generateApp.js index f92789830..042770fcc 100644 --- a/packages/vue-generator/src/generator/generateApp.js +++ b/packages/vue-generator/src/generator/generateApp.js @@ -1,40 +1,116 @@ -import { generateTemplate } from '../templates/vue-template' +import { generateTemplate as genDefaultStaticTemplate } from '../templates/vue-template' +import { + genBlockPlugin, + genDataSourcePlugin, + genDependenciesPlugin, + genI18nPlugin, + genPagePlugin, + genRouterPlugin, + genTemplatePlugin, + genUtilsPlugin +} from '../plugins' +import CodeGenerator from './codeGenerator' -const templateMap = { - default: generateTemplate +const inputMock = { + // 应用相关配置信息 + //config: {}, + // 应用相关的 meta 信息 + appMeta: {}, + // 页面区块信息 + componentsTree: [], + blockList: [], + // 数据源信息 + dataSource: [], + // i18n 信息 + i18n: {}, + // utils 信息 + utils: [], + // 全局状态 + globalState: [] } -// function +// TODO 解析整个应用用到的区块 +// 1. 解析页面中用到的区块 +// 2. 解析区块中用到的区块 -function generateI18n() {} - -function generateDataSource() {} +const transformedSchema = { + // 整体应用 meta 信息 + appMeta: { + name: 'test' + }, + // 需要生成的页面 + pageCode: [ + { + // 类型是页面 + // type: 'PAGE', + // 类型是区块 + // type: 'BLOCK', + // 页面 meta 信息 + meta: {}, + // schema 信息,如果是 文件夹,则不需要 + schema: {} + // ... + } + ], + dataSource: {}, + i18n: {}, + routes: {}, + utils: {}, + globalState: [ + { + actions: {}, + getters: {}, + id: '', + state: {} + } + ] +} -function generatePageOrComponent() {} +// 预处理输入的 schema,转换为标准的格式 +function transformSchema(appSchema) { + const { appMeta, pageCode, dataSource, i18n, utils, globalState } = appSchema -function generateRouter() {} + const routes = pageCode.map(({ meta: { isHome, router }, fileName }) => ({ + fileName, + isHome, + path: router.startsWith('/') ? router : `/${router}` + })) -function generateDependencies() {} + const hasRoot = routes.some(({ path }) => path === '/') -/** - * 整体应用出码 - */ -export function generateApp(config, appSchema) { - // 预处理 app schema + if (!hasRoot && routes.length) { + const { path: homePath } = routes.find(({ isHome }) => isHome) || { path: routes[0].path } - // 初始化模板 - const { staticTemplate } = config + routes.unshift({ path: '/', redirect: homePath }) + } - if (typeof staticTemplate === 'function') { - staticTemplate({}) + return { + appMeta, + pageCode, + dataSource, + i18n, + utils, + globalState, + routes } +} - // 国际化出码 +/** + * 整体应用出码 + */ +export async function generateApp(appSchema) { + const codeGenInstance = new CodeGenerator({ + plugins: [ + genBlockPlugin(), + genDataSourcePlugin(), + genDependenciesPlugin(), + genI18nPlugin(), + genPagePlugin(), + genRouterPlugin(), + genTemplatePlugin(), + genUtilsPlugin() + ] + }) - // 数据源出码 - // 页面出码 - // 区块出码 - // utils 工具类出码 - // 路由出码 - // 依赖出码 + return codeGenInstance.generate(appSchema) } diff --git a/packages/vue-generator/src/generator/index.js b/packages/vue-generator/src/generator/index.js index e852c70c0..68a33c499 100644 --- a/packages/vue-generator/src/generator/index.js +++ b/packages/vue-generator/src/generator/index.js @@ -1,14 +1,14 @@ /** -* Copyright (c) 2023 - present TinyEngine Authors. -* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. -* -* Use of this source code is governed by an MIT-style license. -* -* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, -* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR -* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. -* -*/ + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ import { generateCode, generateBlocksCode, generatePageCode } from './page' diff --git a/packages/vue-generator/src/plugins/genBlockPlugin.js b/packages/vue-generator/src/plugins/genBlockPlugin.js new file mode 100644 index 000000000..38a548a99 --- /dev/null +++ b/packages/vue-generator/src/plugins/genBlockPlugin.js @@ -0,0 +1,57 @@ +import { mergeOptions } from '../utils/mergeOptions' +import { generatePageCode } from '../generator/page' + +const defaultOption = { + blockBasePath: './src/components' +} + +function genBlockPlugin(options = {}) { + const realOptions = mergeOptions(defaultOption, options) + + const { blockBasePath } = realOptions + + return { + name: 'tinyengine-plugin-generatecode-block', + description: 'transform block schema to code', + parseSchema(schema) { + const { blockHistories } = schema + const blockSchema = blockHistories.map((block) => block?.content).filter((schema) => typeof schema === 'object') + + return { + id: 'blocks', + result: blockSchema + } + }, + transform(transformedSchema) { + const { blocks } = transformedSchema + + const resBlocks = [] + + for (const block of blocks) { + const res = generatePageCode({ + pageInfo: { schema: block, name: block.componentName }, + componentsMap: this.schema.componentsMap + }) + + const { errors, ...restInfo } = res[0] + + if (errors?.length > 0) { + this.genLogs.push(...errors) + continue + } + + const { panelName, panelValue } = restInfo + + resBlocks.push({ + fileName: panelName, + path: blockBasePath, + fileContent: panelValue + }) + } + + return resBlocks + } + } +} + +export default genBlockPlugin diff --git a/packages/vue-generator/src/plugins/genDataSourcePlugin.js b/packages/vue-generator/src/plugins/genDataSourcePlugin.js new file mode 100644 index 000000000..84d02e443 --- /dev/null +++ b/packages/vue-generator/src/plugins/genDataSourcePlugin.js @@ -0,0 +1,52 @@ +import { mergeOptions } from '../utils/mergeOptions' + +const defaultOption = { + fileName: 'dataSource.json', + path: './src' +} + +function genDataSourcePlugin(options = {}) { + const realOptions = mergeOptions(defaultOption, options) + + const { path, fileName } = realOptions + + return { + name: 'tinyengine-plugin-generatecode-datasource', + description: 'transform schema to dataSource plugin', + parseSchema(schema) { + return { + id: 'dataSource', + result: schema?.dataSource || {} + } + }, + transform(transformedSchema) { + const dataSource = transformedSchema.dataSource + + const { dataHandler, errorHandler, willFetch, list } = dataSource || {} + + const data = { + list: list.map(({ id, name, data }) => ({ id, name, ...data })) + } + + if (dataHandler) { + data.dataHandler = dataHandler + } + + if (errorHandler) { + data.errorHandler = errorHandler + } + + if (willFetch) { + data.willFetch = willFetch + } + + return { + fileName, + path, + fileContent: JSON.stringify(data) + } + } + } +} + +export default genDataSourcePlugin diff --git a/packages/vue-generator/src/plugins/genDependenciesPlugin.js b/packages/vue-generator/src/plugins/genDependenciesPlugin.js new file mode 100644 index 000000000..e9ef5f779 --- /dev/null +++ b/packages/vue-generator/src/plugins/genDependenciesPlugin.js @@ -0,0 +1,61 @@ +import { mergeOptions } from '../utils/mergeOptions' + +const defaultOption = { + fileName: 'package.json', + path: '.' +} + +function genDependenciesPlugin(options = {}) { + const realOptions = mergeOptions(defaultOption, options) + + const { path, fileName } = realOptions + + return { + name: 'tinyengine-plugin-generatecode-dependencies', + description: 'transform dependencies to package.json', + parseSchema(schema) { + const { utils } = schema + + const utilsDependencies = {} + + for (const { + type, + content: { package: packageName, version } + } of utils) { + if (type !== 'npm') { + continue + } + + utilsDependencies[packageName] = version || 'latest' + } + + return { + id: 'dependencies', + result: utilsDependencies + } + }, + transform(transformedSchema) { + const { dependencies } = transformedSchema + const originPackageItem = this.genResult.find((item) => item.fileName === fileName && item.path === path) + + if (!originPackageItem) { + return { + fileName, + path, + fileContent: JSON.stringify({ dependencies }) + } + } + + let originPackageJSON = JSON.parse(originPackageItem.fileContent) + + originPackageJSON.dependencies = { + ...originPackageJSON.dependencies, + ...dependencies + } + + this.replaceGenResult({ fileName, path, fileContent: JSON.stringify(originPackageJSON) }) + } + } +} + +export default genDependenciesPlugin diff --git a/packages/vue-generator/src/plugins/genI18nPlugin.js b/packages/vue-generator/src/plugins/genI18nPlugin.js new file mode 100644 index 000000000..0d6e46cee --- /dev/null +++ b/packages/vue-generator/src/plugins/genI18nPlugin.js @@ -0,0 +1,75 @@ +import { mergeOptions } from '../utils/mergeOptions' +import { generateImportStatement } from '../utils/generateImportStatement' + +const defaultOption = { + localeFileName: 'locale.js', + entryFileName: 'index.js', + path: './src/i18n' +} + +function genI18nPlugin(options = {}) { + const realOptions = mergeOptions(defaultOption, options) + + const { path, localeFileName, entryFileName } = realOptions + + return { + name: 'tinyengine-plugin-generatecode-i18n', + description: 'transform i18n schema to i18n code plugin', + parseSchema(schema) { + return { + id: 'i18n', + result: schema?.i18n || [] + } + }, + transform(transformedSchema) { + const { i18n } = transformedSchema || {} + + const res = [] + + // 生成国际化词条文件 + for (const [key, value] of Object.entries(i18n)) { + res.push({ + fileName: `${key}.json`, + path, + fileContent: JSON.stringify(value, null, 2) + }) + } + + const langs = Object.keys(i18n) + const importStatements = langs.map((lang) => + generateImportStatement({ moduleName: `./${lang}.json`, exportName: lang }) + ) + + // 生成 locale.js + res.push({ + fileName: localeFileName, + path, + fileContent: ` + ${importStatements.join('\n')} + + export default { ${langs.join(',')} } + ` + }) + + // 生成 index.js 入口文件 + res.push({ + fileName: entryFileName, + path, + fileContent: ` + import i18n from '@opentiny/tiny-engine-i18n-host' + import lowcode from '../lowcode' + import locale from './${localeFileName}' + + i18n.lowcode = lowcode + ${langs.map((langItem) => `i18n.global.mergeLocaleMessage('${langItem}', locale.${langItem})`).join('\n')} + + export default i18n + ` + }) + + return res + } + } +} + +export default genI18nPlugin diff --git a/packages/vue-generator/src/plugins/genPagePlugin.js b/packages/vue-generator/src/plugins/genPagePlugin.js new file mode 100644 index 000000000..40193f68c --- /dev/null +++ b/packages/vue-generator/src/plugins/genPagePlugin.js @@ -0,0 +1,87 @@ +import { mergeOptions } from '../utils/mergeOptions' +import { generatePageCode } from '../generator/page' + +const defaultOption = { + pageBasePath: './src/views' +} + +function genPagePlugin(options = {}) { + const realOptions = mergeOptions(defaultOption, options) + + const { pageBasePath } = realOptions + + return { + name: 'tinyengine-plugin-generatecode-page', + description: 'transform page schema to code', + parseSchema(schema) { + const { componentsTree } = schema + const pagesMap = {} + const resPageTree = [] + + for (const componentItem of componentsTree) { + pagesMap[componentItem.id] = componentItem + } + + for (const componentItem of componentsTree) { + if (componentItem.componentName === 'Folder') { + continue + } + + const newComponentItem = { + ...componentItem + } + let path = pageBasePath + let curParentId = componentItem.meta.parentId + let depth = 0 + + while (curParentId !== '0' && depth < 1000) { + const preFolder = pagesMap[curParentId] + + path += `/${preFolder.folderName}` + curParentId = preFolder.parentId + depth++ + } + + newComponentItem.path = path + + resPageTree.push(newComponentItem) + } + + return { + id: 'pages', + result: resPageTree + } + }, + transform(transformedSchema) { + const { pages } = transformedSchema + + const resPage = [] + + for (const page of pages) { + const res = generatePageCode({ + pageInfo: { schema: page, name: page.componentName }, + componentsMap: this.schema.componentsMap + }) + + const { errors, ...restInfo } = res[0] + + if (errors?.length > 0) { + this.genLogs.push(...errors) + continue + } + + const { panelName, panelValue } = restInfo + + resPage.push({ + fileName: panelName, + path: page.path, + fileContent: panelValue + }) + } + + return resPage + } + } +} + +export default genPagePlugin diff --git a/packages/vue-generator/src/plugins/genRouterPlugin.js b/packages/vue-generator/src/plugins/genRouterPlugin.js new file mode 100644 index 000000000..918d0207e --- /dev/null +++ b/packages/vue-generator/src/plugins/genRouterPlugin.js @@ -0,0 +1,78 @@ +import { mergeOptions } from '../utils/mergeOptions' + +const defaultOption = { + fileName: 'index.js', + path: './src/router' +} + +function genRouterPlugin(options = {}) { + const realOptions = mergeOptions(defaultOption, options) + + const { path, fileName } = realOptions + + return { + name: 'tinyengine-plugin-generatecode-router', + description: 'transform router schema to router code plugin', + parseSchema(schema) { + const { pageCode } = schema + + const routes = pageCode.map(({ meta: { isHome, router }, fileName }) => ({ + fileName, + isHome, + path: router.startsWith('/') ? router : `/${router}` + })) + + const hasRoot = routes.some(({ path }) => path === '/') + + if (!hasRoot && routes.length) { + const { path: homePath } = routes.find(({ isHome }) => isHome) || { path: routes[0].path } + + routes.unshift({ path: '/', redirect: homePath }) + } + + return { + id: 'routes', + result: routes + } + }, + transform(transformedSchema) { + const { routes: routesList } = transformedSchema || {} + + // TODO: 支持 hash 模式、history 模式 + const importSnippet = "import { createRouter, createWebHashHistory } from 'vue-router'" + const exportSnippet = ` + export default createRouter({ + history: createWebHashHistory(), + routes + }) + ` + const routes = routesList.map(({ fileName, path, redirect, filePath }) => { + const routeItem = { + path + } + + if (redirect) { + routeItem.redirect = redirect + } + + if (fileName) { + routeItem.component = `() => import('${filePath}')` + } + + return JSON.stringify(routeItem) + }) + + const routeSnippets = `const routes = [${routes.join(',')}]` + + const res = { + fileName, + path, + fileContent: `${importSnippet}\n ${routeSnippets} \n ${exportSnippet}` + } + + return res + } + } +} + +export default genRouterPlugin diff --git a/packages/vue-generator/src/plugins/genTemplatePlugin.js b/packages/vue-generator/src/plugins/genTemplatePlugin.js new file mode 100644 index 000000000..6331ac17e --- /dev/null +++ b/packages/vue-generator/src/plugins/genTemplatePlugin.js @@ -0,0 +1,32 @@ +import { mergeOptions } from '../utils/mergeOptions' +import { templateMap } from '../templates' + +const defaultOption = {} + +function genTemplatePlugin(options = {}) { + // 保留,用作拓展配置用途 + const realOptions = mergeOptions(defaultOption, options) + + return { + name: 'tinyengine-plugin-generatecode-template', + description: 'generate template code', + transform() { + const meta = this.schema.appMeta + const { template } = meta + + if (!template) { + return + } + + if (typeof template === 'function') { + return template(meta) + } + + if (templateMap[template]) { + return templateMap[template](meta) + } + } + } +} + +export default genTemplatePlugin diff --git a/packages/vue-generator/src/plugins/genUtilsPlugin.js b/packages/vue-generator/src/plugins/genUtilsPlugin.js new file mode 100644 index 000000000..ad92fc4d0 --- /dev/null +++ b/packages/vue-generator/src/plugins/genUtilsPlugin.js @@ -0,0 +1,94 @@ +import { mergeOptions } from '../utils/mergeOptions' +import { generateImportStatement } from '../utils/generateImportStatement' + +const defaultOption = { + fileName: 'utils.js', + path: './src' +} + +function genUtilsPlugin(options = {}) { + const realOptions = mergeOptions(defaultOption, options) + + const { path, fileName } = realOptions + + const handleNpmUtils = (utilsConfig) => { + const { content } = utilsConfig + const { package: packageName, exportName, destructuring, subName } = content + + const statement = generateImportStatement({ moduleName: packageName, exportName, alias: subName, destructuring }) + let realExportName = exportName + + if (subName) { + realExportName = subName + } + + return { + res: statement, + exportName: realExportName + } + } + + const handleFunctionUtils = (utilsConfig) => { + const { content, name } = utilsConfig + + return { + res: `const ${name} = ${content.value}`, + exportName: name + } + } + + return { + name: 'tinyengine-plugin-generatecode-utils', + description: 'transform utils schema to utils code', + parseSchema(schema) { + const { utils } = schema + + return { + id: 'utils', + result: utils || [] + } + }, + transform(transformedSchema) { + const { utils } = transformedSchema + + if (!Array.isArray(utils)) { + return + } + + const importStatements = [] + const variableStatements = [] + const exportVariables = [] + + const utilsHandlerMap = { + npm: handleNpmUtils, + function: handleFunctionUtils + } + + for (const utilItem of utils) { + const { res, exportName } = utilsHandlerMap[utilItem.type](utilItem) + + if (utilItem.type === 'function') { + variableStatements.push(res) + } else { + importStatements.push(res) + } + + exportVariables.push(exportName) + } + + const fileContent = ` + ${importStatements.join('\n')}\n + ${variableStatements.join('\n')}\n + export { ${exportVariables.join(',')} } + ` + + return { + fileName, + path, + fileContent + } + } + } +} + +export default genUtilsPlugin diff --git a/packages/vue-generator/src/plugins/index.js b/packages/vue-generator/src/plugins/index.js new file mode 100644 index 000000000..de2a8eefd --- /dev/null +++ b/packages/vue-generator/src/plugins/index.js @@ -0,0 +1,8 @@ +export { default as genDataSourcePlugin } from './genDataSourcePlugin' +export { default as genBlockPlugin } from './genBlockPlugin' +export { default as genDependenciesPlugin } from './genDependenciesPlugin' +export { default as genPagePlugin } from './genPagePlugin' +export { default as genRouterPlugin } from './genRouterPlugin' +export { default as genUtilsPlugin } from './genUtilsPlugin' +export { default as genI18nPlugin } from './genI18nPlugin' +export { default as genTemplatePlugin } from './genTemplatePlugin' diff --git a/packages/vue-generator/src/templates/index.js b/packages/vue-generator/src/templates/index.js new file mode 100644 index 000000000..7450fb42c --- /dev/null +++ b/packages/vue-generator/src/templates/index.js @@ -0,0 +1,5 @@ +import { generateTemplate as genDefaultStaticTemplate } from './templates/vue-template' + +export const templateMap = { + default: genDefaultStaticTemplate +} diff --git a/packages/vue-generator/src/templates/vue-template/index.js b/packages/vue-generator/src/templates/vue-template/index.js index 7d20896b0..ca8f56197 100644 --- a/packages/vue-generator/src/templates/vue-template/index.js +++ b/packages/vue-generator/src/templates/vue-template/index.js @@ -12,6 +12,12 @@ import axiosFile from './templateFiles/src/http/axios.js?raw' import axiosConfigFile from './templateFiles/src/http/config.js?raw' import httpEntryFile from './templateFiles/src/http/index.js?raw' +/** + * 模板写入动态内容 + * @param {*} context + * @param {*} str + * @returns + */ const getTemplate = (context, str) => { return str.replace(/(\$\$TinyEngine{(.*)}END\$)/g, function (match, p1, p2) { if (!p2) { diff --git a/packages/vue-generator/src/utils/generateImportStatement.js b/packages/vue-generator/src/utils/generateImportStatement.js new file mode 100644 index 000000000..5a9588b01 --- /dev/null +++ b/packages/vue-generator/src/utils/generateImportStatement.js @@ -0,0 +1,16 @@ +// TODO: 支持4种 import 的形式 +export function generateImportStatement(config) { + const { moduleName, exportName, alias, destructuring } = config + + let statementName = `${exportName}` + + if (alias && alias !== exportName) { + statementName = `${exportName} as ${alias}` + } + + if (destructuring) { + statementName = `{ ${statementName} }` + } + + return `import ${statementName} from ${moduleName}` +} diff --git a/packages/vue-generator/src/utils/mergeOptions.js b/packages/vue-generator/src/utils/mergeOptions.js new file mode 100644 index 000000000..2f83e42da --- /dev/null +++ b/packages/vue-generator/src/utils/mergeOptions.js @@ -0,0 +1,29 @@ +function isObject(target) { + return Object.prototype.toString.call(target) === '[object Object]' +} + +export const mergeOptions = (originOptions, newOptions) => { + if (!isObject(originOptions) || !isObject(newOptions)) { + return originOptions + } + + const res = {} + + for (const [key, value] of Object.entries(originOptions)) { + if (!Object.prototype.hasOwnProperty.call(newOptions, key)) { + res[key] = value + } + + if (isObject(value) && isObject(newOptions[key])) { + res[key] = mergeOptions(value, newOptions[key]) + } + } + + for (const [key, value] of Object.entries(newOptions)) { + if (!Object.prototype.hasOwnProperty.call(res, key)) { + res[key] = value + } + } + + return res +}