diff --git a/bricks/nav/src/bootstrap.ts b/bricks/nav/src/bootstrap.ts index ea2764f52..c5fb3de30 100644 --- a/bricks/nav/src/bootstrap.ts +++ b/bricks/nav/src/bootstrap.ts @@ -21,3 +21,4 @@ import "./directory-tree/directory-tree-internal-node/index.js"; import "./nav-logo/index.js"; import "./poll-announce/index.js"; import "./data-providers/get-menu-config-tree.js"; +import "./data-providers/get-menu-config-options.js"; diff --git a/bricks/nav/src/data-providers/get-menu-config-options.spec.ts b/bricks/nav/src/data-providers/get-menu-config-options.spec.ts new file mode 100644 index 000000000..f846e2c45 --- /dev/null +++ b/bricks/nav/src/data-providers/get-menu-config-options.spec.ts @@ -0,0 +1,167 @@ +import { describe, test, expect } from "@jest/globals"; +import { initializeI18n } from "@next-core/i18n"; +import type { MicroApp } from "@next-core/types"; +import { getMenuConfigOptions } from "./get-menu-config-options.js"; +import type { MenuRawData } from "./get-menu-config-tree.js"; + +initializeI18n(); + +const mockError = jest.spyOn(console, "error"); + +describe("getMenuConfigOptions", () => { + test("should work", async () => { + const menuList: MenuRawData[] = [ + { + menuId: "menu-a", + title: "<% I18N('MENU_A') %>", + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + i18n: { + en: { + MENU_A: "Menu A", + }, + }, + }, + { + menuId: "menu-b", + title: "<% I18N('MENU_B', 'Default Menu B') %>", + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + }, + { + menuId: "menu-c", + title: "<% I18N('MENU_C') %>", + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + }, + { + menuId: "menu-d", + title: undefined!, + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + }, + { + menuId: "menu-e", + title: "<% APP.localeName %>", + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + overrideApp: { + name: "App A", + } as MicroApp, + }, + { + menuId: "menu-f", + title: "<% `${CTX.name} - ${I18n('')}` %>", + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + }, + ]; + expect(await getMenuConfigOptions(menuList)).toEqual([ + { + label: "Menu A (menu-a)", + value: "menu-a", + }, + { + label: "Default Menu B (menu-b)", + value: "menu-b", + }, + { + label: "MENU_C (menu-c)", + value: "menu-c", + }, + { + label: " (menu-d)", + value: "menu-d", + }, + { + label: "App A (menu-e)", + value: "menu-e", + }, + { + label: "<% `${CTX.name} - ${I18n('')}` %> (menu-f)", + value: "menu-f", + }, + ]); + }); + + test("syntax error in expression", async () => { + mockError.mockReturnValueOnce(); + expect( + await getMenuConfigOptions([ + { + menuId: "menu-b", + title: "<% I18N('MENU_B' %>", + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + }, + ]) + ).toEqual([ + { + label: "<% I18N('MENU_B' %> (menu-b)", + value: "menu-b", + }, + ]); + expect(mockError).toBeCalledTimes(1); + expect(mockError).toBeCalledWith( + "Parse menu title expression \"<% I18N('MENU_B' %>\" failed:", + expect.any(SyntaxError) + ); + }); + + test("expression evaluates error", async () => { + mockError.mockReturnValueOnce(); + expect( + await getMenuConfigOptions([ + { + menuId: "menu-b", + title: "<% I18N[0][0] %>", + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + }, + ]) + ).toEqual([ + { + label: "<% I18N[0][0] %> (menu-b)", + value: "menu-b", + }, + ]); + expect(mockError).toBeCalledTimes(1); + expect(mockError).toBeCalledWith( + 'Evaluate menu title expression "<% I18N[0][0] %>" failed:', + expect.any(TypeError) + ); + }); + + test("expression to non-string", async () => { + mockError.mockReturnValueOnce(); + expect( + await getMenuConfigOptions([ + { + menuId: "menu-b", + title: "<% {} %>", + type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", + }, + ]) + ).toEqual([ + { + label: "<% {} %> (menu-b)", + value: "menu-b", + }, + ]); + expect(mockError).toBeCalledTimes(1); + expect(mockError).toBeCalledWith( + 'The result of menu title expression "<% {} %>" is not a string:', + {} + ); + }); +}); diff --git a/bricks/nav/src/data-providers/get-menu-config-options.ts b/bricks/nav/src/data-providers/get-menu-config-options.ts new file mode 100644 index 000000000..1d695d8fd --- /dev/null +++ b/bricks/nav/src/data-providers/get-menu-config-options.ts @@ -0,0 +1,43 @@ +import { createProviderClass } from "@next-core/utils/general"; +import { i18n } from "@next-core/i18n"; +import type { MenuRawData } from "./get-menu-config-tree"; +import { smartDisplayForMenuTitle } from "./shared/smartDisplayForMenuTitle"; + +export interface MenuOption { + label: string; + value: string; +} + +/** + * 构造用于菜单自定义的下拉选项数据。 + * + * 将对菜单标题进行表达式解析,支持 I8N 和 APP。 + */ +export async function getMenuConfigOptions( + menuList: MenuRawData[] +): Promise { + const options: MenuOption[] = []; + + for (const menu of menuList) { + if (menu.type === "main") { + const menuI18nNamespace = `customize-menu/${menu.menuId}~${menu.app[0].appId}+${ + menu.instanceId + }`; + // Support any language in `menu.i18n`. + Object.entries(menu.i18n ?? {}).forEach(([lang, resources]) => { + i18n.addResourceBundle(lang, menuI18nNamespace, resources); + }); + options.push({ + label: `${smartDisplayForMenuTitle(menu.title, menuI18nNamespace, menu.overrideApp) ?? ""} (${menu.menuId})`, + value: menu.menuId, + }); + } + } + + return options; +} + +customElements.define( + "nav.get-menu-config-options", + createProviderClass(getMenuConfigOptions) +); diff --git a/bricks/nav/src/data-providers/get-menu-config-tree.spec.ts b/bricks/nav/src/data-providers/get-menu-config-tree.spec.ts index c54030495..36410a045 100644 --- a/bricks/nav/src/data-providers/get-menu-config-tree.spec.ts +++ b/bricks/nav/src/data-providers/get-menu-config-tree.spec.ts @@ -1,6 +1,9 @@ import { describe, test, expect } from "@jest/globals"; +import { initializeI18n } from "@next-core/i18n"; import { getMenuConfigTree, type MenuRawData } from "./get-menu-config-tree.js"; +initializeI18n(); + describe("getMenuConfigTree", () => { test("should work", async () => { const menuList: MenuRawData[] = [ @@ -8,10 +11,15 @@ describe("getMenuConfigTree", () => { menuId: "menu-a", title: "<% 'Menu A' %>", type: "main", + app: [{ appId: "app-a" }], + instanceId: "i-a", items: [ { text: "Menu A - Item 1", sort: 10, + icon: { + imgSrc: '<% IMG.get("...") %>', + }, }, { text: "Menu A - Item 2", @@ -42,6 +50,8 @@ describe("getMenuConfigTree", () => { menuId: "menu-a", title: "Inject Menu A", type: "inject", + app: [{ appId: "app-a" }], + instanceId: "i-a", items: [ { text: "<% `Menu A - ${I18N('ITEM_0')}` %>", @@ -55,12 +65,19 @@ describe("getMenuConfigTree", () => { sort: 25, }, ], + i18n: { + en: { + ITEM_0: "Item 0", + }, + }, }, { menuId: "menu-a", title: "Inject Menu A", injectMenuGroupId: "group-x", type: "inject", + app: [{ appId: "app-a" }], + instanceId: "i-a", items: [ { text: "Group X - ii", @@ -74,6 +91,8 @@ describe("getMenuConfigTree", () => { title: "Inject Menu A", injectMenuGroupId: "group-x", type: "inject", + app: [{ appId: "app-a" }], + instanceId: "i-a", items: [ { text: "Group X - iii", @@ -85,6 +104,8 @@ describe("getMenuConfigTree", () => { menuId: "menu-a", title: "Inject Menu A", type: "inject", + app: [{ appId: "app-a" }], + instanceId: "i-a", dynamicItems: true, itemsResolve: {}, }, @@ -92,9 +113,14 @@ describe("getMenuConfigTree", () => { menuId: "menu-a", title: "Inject Menu A", type: "inject", + app: [{ appId: "app-a" }], + instanceId: "i-a", }, ]; - expect(await getMenuConfigTree(menuList)).toEqual([ + // Use JSON to remove the symbol properties. + expect( + JSON.parse(JSON.stringify(await getMenuConfigTree(menuList))) + ).toEqual([ { __keys: [ "0", @@ -123,7 +149,7 @@ describe("getMenuConfigTree", () => { lib: "fa", }, key: "0-0", - title: "ITEM_0", + title: "Menu A - Item 0", }, { children: undefined, @@ -131,6 +157,9 @@ describe("getMenuConfigTree", () => { children: [], sort: 10, text: "Menu A - Item 1", + icon: { + imgSrc: '<% IMG.get("...") %>', + }, }, faded: undefined, icon: { @@ -305,7 +334,7 @@ describe("getMenuConfigTree", () => { lib: "fa", }, key: "0", - title: "<% 'Menu A' %>", + title: "Menu A", }, ]); }); diff --git a/bricks/nav/src/data-providers/get-menu-config-tree.ts b/bricks/nav/src/data-providers/get-menu-config-tree.ts index 4bed21a7b..676a992fe 100644 --- a/bricks/nav/src/data-providers/get-menu-config-tree.ts +++ b/bricks/nav/src/data-providers/get-menu-config-tree.ts @@ -1,24 +1,41 @@ +import type { MetaI18n, MicroApp } from "@next-core/types"; +import { i18n } from "@next-core/i18n"; import { createProviderClass } from "@next-core/utils/general"; -import { smartDisplayForEvaluableString } from "@next-shared/general/smartDisplayForEvaluableString"; import { sortBy } from "lodash"; +import { smartDisplayForMenuTitle } from "./shared/smartDisplayForMenuTitle"; + +const symbolMenuI18nNamespace = Symbol("menuI18nNamespace"); +const symbolOverrideApp = Symbol("overrideApp"); /** 原始菜单数据。 */ export interface MenuRawData { menuId: string; title: string; - icon?: unknown; + icon?: { + imgSrc?: string; + }; type?: "main" | "inject"; injectMenuGroupId?: string; dynamicItems?: boolean; itemsResolve?: unknown; items?: MenuItemRawData[]; + instanceId: string; + app: [ + { + appId: string; + }, + ]; + i18n?: MetaI18n; + overrideApp?: MicroApp; } /** 原始菜单项数据。 */ export type MenuItemRawData = { /** 菜单项文本。 */ text: string; - icon?: unknown; + icon?: { + imgSrc?: string; + }; sort?: number; groupId?: string; hidden?: boolean; @@ -31,7 +48,11 @@ export interface TitleDataSource { attributeId?: string; } -interface RuntimeMenuItemRawData extends MenuItemRawData {} +interface RuntimeMenuItemRawData extends MenuItemRawData { + children?: RuntimeMenuItemRawData[]; + [symbolMenuI18nNamespace]?: string; + [symbolOverrideApp]?: MicroApp; +} export interface TreeNode { key: string; @@ -51,6 +72,8 @@ const DEFAULT_ICON = { /** * 构造用于菜单自定义的树形结构数据。 + * + * 将对菜单标题进行表达式解析,支持 I8N 和 APP。 */ export async function getMenuConfigTree( menuList: MenuRawData[] @@ -58,7 +81,7 @@ export async function getMenuConfigTree( const keys: string[] = []; function getChildren( - items: MenuItemRawData[] | undefined, + items: RuntimeMenuItemRawData[] | undefined, prefixKey: string ): TreeNode[] | undefined { const children = items?.map((item, j) => { @@ -66,9 +89,14 @@ export async function getMenuConfigTree( keys.push(key); return { key, - title: smartDisplayForEvaluableString(item.text), + title: + smartDisplayForMenuTitle( + item.text, + item[symbolMenuI18nNamespace], + item[symbolOverrideApp] + ) ?? "", data: item, - icon: item.icon ?? DEFAULT_ICON, + icon: getIcon(item.icon), faded: item.hidden, children: getChildren(item.children, key), }; @@ -83,6 +111,18 @@ export async function getMenuConfigTree( const validMenuList: MenuRawData[] = []; const injectWithMenus = new Map(); + const menuWithI18n = new WeakMap(); + + for (const menu of menuList) { + const menuI18nNamespace = `customize-menu/${menu.menuId}~${menu.app[0].appId}+${ + menu.instanceId + }`; + // Support any language in `menu.i18n`. + Object.entries(menu.i18n ?? {}).forEach(([lang, resources]) => { + i18n.addResourceBundle(lang, menuI18nNamespace, resources); + }); + menuWithI18n.set(menu, menuI18nNamespace); + } for (const menu of menuList) { if (!(menu.dynamicItems && menu.itemsResolve) && menu.items?.length) { @@ -105,7 +145,7 @@ export async function getMenuConfigTree( validMenuList.flatMap( (menu) => // Here always have non-empty items - processGroupInject(menu.items, menu, injectWithMenus)! + processGroupInject(menu.items, menu, injectWithMenus, menuWithI18n)! ) ); @@ -113,9 +153,14 @@ export async function getMenuConfigTree( { key: "0", __keys: keys, - title: smartDisplayForEvaluableString(mainMenu.title) ?? mainMenu.menuId, + title: + smartDisplayForMenuTitle( + mainMenu.title, + menuWithI18n.get(mainMenu), + mainMenu.overrideApp + ) ?? mainMenu.menuId, data: mainMenu, - icon: mainMenu.icon ?? DEFAULT_ICON, + icon: getIcon(mainMenu.icon), children: getChildren(firstLevelItems, "0"), }, ]; @@ -123,10 +168,19 @@ export async function getMenuConfigTree( return tree; } +function getIcon(icon: { imgSrc?: string } | undefined): unknown { + // 使用图片图标时,该图标一般是表达式,是应用的运行时数据,菜单管理无法获取。 + if (icon?.imgSrc) { + return DEFAULT_ICON; + } + return icon ?? DEFAULT_ICON; +} + function processGroupInject( items: MenuItemRawData[] | undefined, menu: MenuRawData, - injectWithMenus: Map + injectWithMenus: Map, + menuWithI18n: WeakMap ): RuntimeMenuItemRawData[] | undefined { return items?.map((item) => { const foundInjectingMenus = @@ -139,8 +193,12 @@ function processGroupInject( return { ...item, children: ( - processGroupInject(item.children, menu, injectWithMenus) ?? - ([] as RuntimeMenuItemRawData[]) + processGroupInject( + item.children, + menu, + injectWithMenus, + menuWithI18n + ) ?? ([] as RuntimeMenuItemRawData[]) ).concat( foundInjectingMenus ? foundInjectingMenus.flatMap( @@ -149,11 +207,14 @@ function processGroupInject( processGroupInject( injectingMenu.items, injectingMenu, - injectWithMenus + injectWithMenus, + menuWithI18n )! ) : ([] as RuntimeMenuItemRawData[]) ), + [symbolOverrideApp]: menu.overrideApp, + [symbolMenuI18nNamespace]: menuWithI18n.get(menu), }; }); } diff --git a/bricks/nav/src/data-providers/shared/smartDisplayForMenuTitle.ts b/bricks/nav/src/data-providers/shared/smartDisplayForMenuTitle.ts new file mode 100644 index 000000000..0e8660095 --- /dev/null +++ b/bricks/nav/src/data-providers/shared/smartDisplayForMenuTitle.ts @@ -0,0 +1,108 @@ +import type { MicroApp } from "@next-core/types"; +import { i18n } from "@next-core/i18n"; +import { hasOwnProperty } from "@next-core/utils/general"; +import { + cook, + isEvaluable, + preevaluate, + type PreevaluateResult, +} from "@next-core/cook"; +import { supply } from "@next-core/supply"; +import { + beforeVisitGlobalMember, + type MemberUsageInExpressions, +} from "@next-core/utils/storyboard"; + +const allowedAppProps = new Set(["name", "id", "homepage", "localeName"]); + +/** + * 对菜单标题进行表达式解析,支持 I8N 和 APP。 + */ +export function smartDisplayForMenuTitle( + title: unknown, + i18nNamespace: string | undefined, + overrideApp: MicroApp | undefined +): string | undefined { + if (typeof title !== "string") { + return; + } + if (isEvaluable(title)) { + // A `SyntaxError` maybe thrown. + let precooked: PreevaluateResult | undefined; + const appUsage: MemberUsageInExpressions = { + usedProperties: new Set(), + hasNonStaticUsage: false, + }; + try { + precooked = preevaluate(title, { + withParent: true, + hooks: { + beforeVisitGlobal: beforeVisitGlobalMember(appUsage, ["APP"]), + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Parse menu title expression "${title}" failed:`, error); + } + + if ( + precooked && + (!precooked.attemptToVisitGlobals.has("APP") || + (overrideApp && + !appUsage.hasNonStaticUsage && + [...appUsage.usedProperties].every((prop) => + allowedAppProps.has(prop) + ))) + ) { + const globals: Record = {}; + for (const key of precooked.attemptToVisitGlobals) { + switch (key) { + case "I18N": + globals[key] = i18n.getFixedT( + null, + [i18nNamespace].filter(Boolean) + ); + break; + case "APP": + globals[key] = { + ...overrideApp, + localeName: overrideApp?.name, + }; + break; + } + } + const suppliedGlobals = supply(precooked.attemptToVisitGlobals, globals); + + let computable = true; + for (const key of precooked.attemptToVisitGlobals) { + if (!hasOwnProperty(suppliedGlobals, key)) { + computable = false; + break; + } + } + + if (computable) { + try { + const result = cook(precooked.expression, precooked.source, { + globalVariables: suppliedGlobals, + }); + if (typeof result === "string") { + return result; + } + // eslint-disable-next-line no-console + console.error( + `The result of menu title expression "${title}" is not a string:`, + result + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `Evaluate menu title expression "${title}" failed:`, + error + ); + } + } + } + } + return title; +}