cabloy 5.1.153 → 5.1.154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-spec-generation/scripts/generate-implementation-charts.mjs +46 -11
  3. package/.claude/skills/cabloy-spec-generation/scripts/generate-implementation-charts.test.mjs +13 -0
  4. package/CHANGELOG.md +13 -0
  5. package/package.json +1 -1
  6. package/repo-docs/backend/menu-guide.md +3 -2
  7. package/vona/env/.env +8 -0
  8. package/vona/packages-vona/vona/package.json +1 -1
  9. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/package.json +1 -2
  10. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/.metadata/index.ts +40 -65
  11. package/vona/src/suite/a-home/modules/home-base/src/.metadata/index.ts +78 -72
  12. package/vona/src/suite/a-home/modules/home-base/src/.metadata/locales.ts +18 -0
  13. package/vona/src/suite/a-home/modules/home-base/src/config/locale/en-us.ts +3 -0
  14. package/vona/src/suite/a-home/modules/home-base/src/config/locale/zh-cn.ts +3 -0
  15. package/vona/src/suite/a-home/modules/home-base/src/index.ts +1 -0
  16. package/vona/src/suite/a-home/modules/home-base/src/service/siteCatalog.ts +17 -10
  17. package/vona/src/suite/a-home/modules/home-user/src/bean/meta.version.ts +5 -0
  18. package/vona/src/suite/a-home/modules/home-user/src/config/config.ts +36 -5
  19. package/vona/src/suite/a-home/modules/home-user/src/config/roles.ts +7 -2
  20. package/vona/src/suite/a-home/modules/home-user/src/entity/role.ts +3 -0
  21. package/vona/src/suite/a-home/modules/home-user/src/types/env.ts +11 -0
  22. package/vona/src/suite/a-home/modules/home-user/src/types/index.ts +1 -0
  23. package/vona/src/suite/a-home/modules/home-user/test/config.test.ts +65 -0
  24. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +14 -6
  25. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  26. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/package.json +1 -1
  27. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/.metadata/index.ts +17 -2
  28. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/bean/event.resolveMenuVisibility.ts +19 -0
  29. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/beanSsrSiteBase.ts +32 -16
  30. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/index.ts +2 -0
  31. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuEligibility.ts +107 -0
  32. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/lib/ssrMenuVisibility.ts +91 -0
  33. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/service/ssr.ts +30 -0
  34. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/src/types/ssrMenu.ts +67 -0
  35. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/test/ssrMenuEligibility.test.ts +135 -0
  36. package/vona/src/suite-vendor/a-cabloy/modules/a-ssr/test/ssrMenuVisibility.test.ts +64 -0
  37. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  38. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
  39. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/const/index.ts +1 -0
  40. package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/const/site.ts +1 -0
  41. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  42. package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
  43. package/vona/src/suite-vendor/a-vona/modules/a-user/src/types/role.ts +1 -0
  44. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  45. package/zova/pnpm-lock.yaml +1 -1
  46. package/zova/src/suite/a-home/modules/home-api/mock/passport.fake.ts +6 -6
  47. package/zova/src/suite/a-home/modules/home-base/src/lib/index.ts +1 -0
  48. package/zova/src/suite/a-home/modules/home-base/src/lib/roleSiteAdmission.ts +15 -0
  49. package/zova/src/suite/a-home/modules/home-base/src/service/routerGuards.ts +3 -3
  50. package/zova/src/suite/a-home/modules/home-layoutadmin/src/model/menu.ts +4 -3
  51. package/zova/src/suite/a-home/modules/home-layoutweb/src/model/menu.ts +4 -3
  52. package/zova/src/suite/a-home/modules/home-passport/package.json +2 -1
  53. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +2 -2
  54. package/zova/src/suite/a-home/modules/home-passport/test/lib/roleSiteAdmission.test.ts +23 -0
  55. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/src/bean/meta.version.ts +0 -32
  56. package/vona/src/suite/a-commerce/modules/commerce-siteadmin/test/meta.version.test.ts +0 -44
@@ -1,24 +1,29 @@
1
1
  import type { LocalizedTextMap } from 'vona-module-a-user';
2
2
 
3
+ import { roleSiteIdAll } from 'vona-module-a-openapiutils';
4
+
3
5
  export interface IRoleConfig {
4
6
  title: string;
5
7
  titleLocales?: LocalizedTextMap;
6
8
  siteIds: string[];
9
+ builtin: boolean;
7
10
  }
8
11
 
9
12
  export const builtinRoles: Record<string, IRoleConfig> = {
10
13
  registeredUser: {
11
14
  title: 'Registered User',
15
+ builtin: true,
12
16
  titleLocales: {
13
17
  'zh-cn': '注册用户',
14
18
  },
15
- siteIds: ['web'],
19
+ siteIds: [roleSiteIdAll],
16
20
  },
17
21
  systemAdmin: {
18
22
  title: 'System Administrator',
23
+ builtin: true,
19
24
  titleLocales: {
20
25
  'zh-cn': '系统管理员',
21
26
  },
22
- siteIds: ['web', 'admin'],
27
+ siteIds: [roleSiteIdAll],
23
28
  },
24
29
  };
@@ -23,4 +23,7 @@ export class EntityRole extends EntityBase implements IRole {
23
23
 
24
24
  @Api.field(v.array(z.string()))
25
25
  siteIds: string[];
26
+
27
+ @Api.field(v.default(false))
28
+ builtin: boolean;
26
29
  }
@@ -0,0 +1,11 @@
1
+ declare module 'vona' {
2
+ export interface VonaConfigEnv {
3
+ HOME_USER_PASSWORD_DEFAULT_ADMIN: string | undefined;
4
+ HOME_USER_DISABLE_BOOTSTRAP_SYSTEM_ADMIN: string | undefined;
5
+ HOME_USER_DISABLE_USER_ADMIN: string | undefined;
6
+ HOME_USER_BUILTIN_ROLE_REGISTERED_USER_SITE_IDS: string | undefined;
7
+ HOME_USER_BUILTIN_ROLE_SYSTEM_ADMIN_SITE_IDS: string | undefined;
8
+ }
9
+ }
10
+
11
+ export {};
@@ -1,4 +1,5 @@
1
1
  export * from './auth.ts';
2
+ export * from './env.ts';
2
3
  export * from './jwt.ts';
3
4
  export * from './passport.ts';
4
5
  export * from './role.ts';
@@ -0,0 +1,65 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import { config } from '../src/config/config.ts';
5
+ import { builtinRoles } from '../src/config/roles.ts';
6
+
7
+ function createConfig(env: Record<string, string | undefined>) {
8
+ return config({ meta: { env } } as never);
9
+ }
10
+
11
+ describe('config.test.ts', () => {
12
+ it('uses environment overrides and defaults without mutating static role definitions', () => {
13
+ const configured = createConfig({
14
+ HOME_USER_PASSWORD_DEFAULT_ADMIN: 'strong-admin-password',
15
+ HOME_USER_DISABLE_BOOTSTRAP_SYSTEM_ADMIN: 'true',
16
+ HOME_USER_DISABLE_USER_ADMIN: 'true',
17
+ HOME_USER_BUILTIN_ROLE_REGISTERED_USER_SITE_IDS: 'web',
18
+ HOME_USER_BUILTIN_ROLE_SYSTEM_ADMIN_SITE_IDS: 'web,admin',
19
+ });
20
+ assert.equal(configured.passwordDefault.admin, 'strong-admin-password');
21
+ assert.equal(configured.disableBootstrapSystemAdmin, true);
22
+ assert.equal(configured.disableUserAdmin, true);
23
+ assert.deepEqual(configured.builtinRoles.registeredUser.siteIds, ['web']);
24
+ assert.deepEqual(configured.builtinRoles.systemAdmin.siteIds, ['web', 'admin']);
25
+ assert.deepEqual(builtinRoles.registeredUser.siteIds, ['*']);
26
+ assert.deepEqual(builtinRoles.systemAdmin.siteIds, ['*']);
27
+
28
+ const defaults = createConfig({
29
+ HOME_USER_PASSWORD_DEFAULT_ADMIN: '',
30
+ HOME_USER_DISABLE_BOOTSTRAP_SYSTEM_ADMIN: 'false',
31
+ HOME_USER_DISABLE_USER_ADMIN: 'invalid',
32
+ });
33
+ assert.equal(defaults.passwordDefault.admin, '123456');
34
+ assert.equal(defaults.disableBootstrapSystemAdmin, false);
35
+ assert.equal(defaults.disableUserAdmin, false);
36
+ assert.deepEqual(defaults.builtinRoles.registeredUser.siteIds, ['*']);
37
+ assert.deepEqual(defaults.builtinRoles.systemAdmin.siteIds, ['*']);
38
+ });
39
+
40
+ it('parses comma-delimited role site IDs and falls back for blank values', () => {
41
+ const configured = createConfig({
42
+ HOME_USER_BUILTIN_ROLE_REGISTERED_USER_SITE_IDS: ' web, admin, , store ',
43
+ HOME_USER_BUILTIN_ROLE_SYSTEM_ADMIN_SITE_IDS: ' , ',
44
+ });
45
+ assert.deepEqual(configured.builtinRoles.registeredUser.siteIds, ['web', 'admin', 'store']);
46
+ assert.deepEqual(configured.builtinRoles.systemAdmin.siteIds, ['*']);
47
+ });
48
+
49
+ it('rejects non-exclusive wildcard role site IDs', () => {
50
+ assert.throws(
51
+ () =>
52
+ createConfig({
53
+ HOME_USER_BUILTIN_ROLE_REGISTERED_USER_SITE_IDS: '*,web',
54
+ }),
55
+ /HOME_USER_BUILTIN_ROLE_REGISTERED_USER_SITE_IDS/,
56
+ );
57
+ assert.throws(
58
+ () =>
59
+ createConfig({
60
+ HOME_USER_BUILTIN_ROLE_SYSTEM_ADMIN_SITE_IDS: 'web,*',
61
+ }),
62
+ /HOME_USER_BUILTIN_ROLE_SYSTEM_ADMIN_SITE_IDS/,
63
+ );
64
+ });
65
+ });
@@ -9,13 +9,17 @@ describe('role.test.ts', () => {
9
9
  const roleRegisteredUser = await scope.model.role.getByName('registeredUser');
10
10
  assert.equal(roleRegisteredUser?.title, 'Registered User');
11
11
  assert.deepEqual(roleRegisteredUser?.titleLocales, { 'zh-cn': '注册用户' });
12
- assert.ok(roleRegisteredUser?.siteIds.includes('web'));
12
+ assert.deepEqual(
13
+ roleRegisteredUser?.siteIds,
14
+ scope.config.builtinRoles.registeredUser.siteIds,
15
+ );
16
+ assert.equal(roleRegisteredUser?.builtin, true);
13
17
 
14
18
  const roleSystemAdmin = await scope.model.role.getByName('systemAdmin');
15
19
  assert.equal(roleSystemAdmin?.title, 'System Administrator');
16
20
  assert.deepEqual(roleSystemAdmin?.titleLocales, { 'zh-cn': '系统管理员' });
17
- assert.ok(roleSystemAdmin?.siteIds.includes('web'));
18
- assert.ok(roleSystemAdmin?.siteIds.includes('admin'));
21
+ assert.deepEqual(roleSystemAdmin?.siteIds, scope.config.builtinRoles.systemAdmin.siteIds);
22
+ assert.equal(roleSystemAdmin?.builtin, true);
19
23
 
20
24
  await app.bean.passport.signinMock();
21
25
  const passport = await app.bean.executor.performAction('get', '/home/user/passport/current');
@@ -24,13 +28,17 @@ describe('role.test.ts', () => {
24
28
  );
25
29
  assert.equal(passportRegisteredUser?.title, 'Registered User');
26
30
  assert.deepEqual(passportRegisteredUser?.titleLocales, { 'zh-cn': '注册用户' });
27
- assert.ok(passportRegisteredUser?.siteIds.includes('web'));
31
+ assert.deepEqual(
32
+ passportRegisteredUser?.siteIds,
33
+ scope.config.builtinRoles.registeredUser.siteIds,
34
+ );
35
+ assert.equal(passportRegisteredUser?.builtin, true);
28
36
 
29
37
  const passportSystemAdmin = passport.roles.find((role: any) => role.name === 'systemAdmin');
30
38
  assert.equal(passportSystemAdmin?.title, 'System Administrator');
31
39
  assert.deepEqual(passportSystemAdmin?.titleLocales, { 'zh-cn': '系统管理员' });
32
- assert.ok(passportSystemAdmin?.siteIds.includes('web'));
33
- assert.ok(passportSystemAdmin?.siteIds.includes('admin'));
40
+ assert.deepEqual(passportSystemAdmin?.siteIds, scope.config.builtinRoles.systemAdmin.siteIds);
41
+ assert.equal(passportSystemAdmin?.builtin, true);
34
42
  });
35
43
  });
36
44
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-rbac",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-ssr",
3
- "version": "5.1.30",
3
+ "version": "5.1.31",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -162,6 +162,7 @@ declare module 'vona' {
162
162
  }
163
163
  /** service: end */
164
164
  /** event: begin */
165
+ export * from '../bean/event.resolveMenuVisibility.ts';
165
166
  export * from '../bean/event.retrieveMenus.ts';
166
167
  export * from '../bean/event.retrieveMenusSite.ts';
167
168
 
@@ -172,6 +173,16 @@ declare module 'vona' {
172
173
  }
173
174
  declare module 'vona-module-a-ssr' {
174
175
 
176
+ export interface EventResolveMenuVisibility {
177
+ /** @internal */
178
+ get scope(): ScopeModuleASsr;
179
+ }
180
+
181
+ export interface EventResolveMenuVisibility {
182
+ get $beanFullName(): 'a-ssr.event.resolveMenuVisibility';
183
+ get $onionName(): 'a-ssr:resolveMenuVisibility';
184
+ }
185
+
175
186
  export interface EventRetrieveMenus {
176
187
  /** @internal */
177
188
  get scope(): ScopeModuleASsr;
@@ -194,20 +205,24 @@ declare module 'vona-module-a-ssr' {
194
205
  }
195
206
  /** event: end */
196
207
  /** event: begin */
208
+ import type { EventResolveMenuVisibility } from '../bean/event.resolveMenuVisibility.ts';
197
209
  import type { EventRetrieveMenus } from '../bean/event.retrieveMenus.ts';
198
210
  import type { EventRetrieveMenusSite } from '../bean/event.retrieveMenusSite.ts';
199
211
  export interface IModuleEvent {
200
- 'retrieveMenus': EventRetrieveMenus;
212
+ 'resolveMenuVisibility': EventResolveMenuVisibility;
213
+ 'retrieveMenus': EventRetrieveMenus;
201
214
  'retrieveMenusSite': EventRetrieveMenusSite;
202
215
  }
203
216
  /** event: end */
204
217
  /** event: begin */
218
+ import type { TypeEventResolveMenuVisibilityData, TypeEventResolveMenuVisibilityResult } from '../bean/event.resolveMenuVisibility.ts';
205
219
  import type { TypeEventRetrieveMenusData, TypeEventRetrieveMenusResult } from '../bean/event.retrieveMenus.ts';
206
220
  import type { TypeEventRetrieveMenusSiteData, TypeEventRetrieveMenusSiteResult } from '../bean/event.retrieveMenusSite.ts';
207
221
  import type { EventOn } from 'vona-module-a-event';
208
222
  declare module 'vona-module-a-event' {
209
223
  export interface IEventRecord {
210
- 'a-ssr:retrieveMenus': EventOn<TypeEventRetrieveMenusData, TypeEventRetrieveMenusResult>;
224
+ 'a-ssr:resolveMenuVisibility': EventOn<TypeEventResolveMenuVisibilityData, TypeEventResolveMenuVisibilityResult>;
225
+ 'a-ssr:retrieveMenus': EventOn<TypeEventRetrieveMenusData, TypeEventRetrieveMenusResult>;
211
226
  'a-ssr:retrieveMenusSite': EventOn<TypeEventRetrieveMenusSiteData, TypeEventRetrieveMenusSiteResult>;
212
227
  }
213
228
  }
@@ -0,0 +1,19 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+
3
+ import { BeanEventBase, Event } from 'vona-module-a-event';
4
+
5
+ import type { ISsrMenuItemPrepared } from '../types/ssrMenu.ts';
6
+
7
+ export interface TypeEventResolveMenuVisibilityData {
8
+ ssrSiteName: string;
9
+ menus: ISsrMenuItemPrepared[];
10
+ currentRoleIds: TableIdentity[];
11
+ }
12
+
13
+ export type TypeEventResolveMenuVisibilityResult = ISsrMenuItemPrepared[];
14
+
15
+ @Event()
16
+ export class EventResolveMenuVisibility extends BeanEventBase<
17
+ TypeEventResolveMenuVisibilityData,
18
+ TypeEventResolveMenuVisibilityResult
19
+ > {}
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import { BeanBase, cast, deepExtend, SymbolModuleName } from 'vona';
16
16
  import { checkErrorJwtExpiredAndThrow } from 'vona-module-a-jwt';
17
17
 
18
+ import type { TypeEventResolveMenuVisibilityResult } from '../bean/event.resolveMenuVisibility.ts';
18
19
  import type { TypeEventRetrieveMenusResult } from '../bean/event.retrieveMenus.ts';
19
20
  import type { TypeEventRetrieveMenusSiteResult } from '../bean/event.retrieveMenusSite.ts';
20
21
  import type {
@@ -34,6 +35,13 @@ import type {
34
35
  import { ServiceDevProxy } from '../service/devProxy.ts';
35
36
  import { ServiceSsrHandler } from '../service/ssrHandler.ts';
36
37
  import { SymbolCacheMenus } from './const.ts';
38
+ import { checkSsrBinding } from './ssrMenuEligibility.ts';
39
+ import {
40
+ projectPublicSsrMenuGroups,
41
+ projectPublicSsrMenus,
42
+ resolveSsrMenuVisibilityDefault,
43
+ resolveVisibleSsrMenuGroups,
44
+ } from './ssrMenuVisibility.ts';
37
45
 
38
46
  export class BeanSsrSiteBase<
39
47
  SsrSiteOptions extends IDecoratorSsrSiteOptions = IDecoratorSsrSiteOptions,
@@ -154,13 +162,22 @@ export class BeanSsrSiteBase<
154
162
  async retrieveMenus(): Promise<TypeEventRetrieveMenusResult | undefined> {
155
163
  const menusPrepared = await this._getMenusCache(this.ctx.locale);
156
164
  if (!menusPrepared) return;
157
- const menus = menusPrepared.menus
158
- ?.filter(menu => !menu.roles?.length || this.bean.passport.checkRoleName(menu.roles))
159
- .map(menu => {
160
- const { roles: _roles, ...menuPublic } = menu;
161
- return menuPublic;
162
- });
163
- return { menus, groups: menusPrepared.groups };
165
+ const menusPreparedVisible = await this.$scope.ssr.event.resolveMenuVisibility.emit(
166
+ {
167
+ ssrSiteName: this.$onionName,
168
+ menus: menusPrepared.menus ?? [],
169
+ currentRoleIds: (this.bean.passport.currentRoles ?? []).map(role => role.id),
170
+ },
171
+ async data => this._resolveMenuVisibilityDefault(data.menus),
172
+ );
173
+ const groupsPreparedVisible = resolveVisibleSsrMenuGroups(
174
+ menusPreparedVisible,
175
+ menusPrepared.groups ?? [],
176
+ );
177
+ return {
178
+ menus: projectPublicSsrMenus(menusPreparedVisible),
179
+ groups: projectPublicSsrMenuGroups(groupsPreparedVisible),
180
+ };
164
181
  }
165
182
 
166
183
  public get siteOptions() {
@@ -282,6 +299,12 @@ export class BeanSsrSiteBase<
282
299
  };
283
300
  }
284
301
 
302
+ private _resolveMenuVisibilityDefault(
303
+ menus: ISsrMenuItemPrepared[],
304
+ ): TypeEventResolveMenuVisibilityResult {
305
+ return resolveSsrMenuVisibilityDefault(menus, roles => this.bean.passport.checkRoleName(roles));
306
+ }
307
+
285
308
  private async _getMenusCache(
286
309
  locale: keyof ILocaleRecord,
287
310
  ): Promise<TypeEventRetrieveMenusSiteResult | undefined> {
@@ -335,8 +358,8 @@ export class BeanSsrSiteBase<
335
358
  for (const ssrMenu of ssrMenus) {
336
359
  const siteMenuOptions = ssrMenu.beanOptions
337
360
  .options as IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>;
338
- if (!_checkValidSiteOrLocale(siteOnionName, siteMenuOptions.site)) continue;
339
- if (!_checkValidSiteOrLocale(locale, siteMenuOptions.locale)) continue;
361
+ if (!checkSsrBinding(siteOnionName, siteMenuOptions.site)) continue;
362
+ if (!checkSsrBinding(locale, siteMenuOptions.locale)) continue;
340
363
  const menusFrom =
341
364
  siteMenuOptions.items || (siteMenuOptions.item && { '': siteMenuOptions.item });
342
365
  if (!menusFrom) continue;
@@ -356,10 +379,3 @@ export class BeanSsrSiteBase<
356
379
  return menus;
357
380
  }
358
381
  }
359
-
360
- function _checkValidSiteOrLocale(expect: any, target: any) {
361
- if (!target) return true;
362
- if (Array.isArray(target) && target.includes(expect)) return true;
363
- if (expect === target) return true;
364
- return false;
365
- }
@@ -2,5 +2,7 @@ export * from './beanSsrSiteBase.ts';
2
2
  export * from './const.ts';
3
3
  export * from './ssr.ts';
4
4
  export * from './ssrMenu.ts';
5
+ export * from './ssrMenuEligibility.ts';
6
+ export * from './ssrMenuVisibility.ts';
5
7
  export * from './ssrMenuGroup.ts';
6
8
  export * from './ssrSite.ts';
@@ -0,0 +1,107 @@
1
+ import type { IOnionSlice } from 'vona-module-a-onion';
2
+
3
+ import type {
4
+ IDecoratorSsrMenuOptions,
5
+ ISsrMenuCatalog,
6
+ ISsrMenuEligibility,
7
+ ISsrMenuRecord,
8
+ } from '../types/ssrMenu.ts';
9
+ import type { IDecoratorSsrMenuGroupOptions, ISsrMenuGroupRecord } from '../types/ssrMenuGroup.ts';
10
+ import type { IDecoratorSsrSiteOptions } from '../types/ssrSite.ts';
11
+
12
+ export function checkSsrBinding<T>(expect: T, target: T | T[] | null | ''): boolean {
13
+ if (target === undefined || target === null || target === '') return true;
14
+ if (Array.isArray(target)) return target.some(item => item === expect);
15
+ return target === expect;
16
+ }
17
+
18
+ export function resolveSsrMenuCatalog(
19
+ sites: ISsrMenuCatalog['sites'],
20
+ ssrMenus: IOnionSlice<ISsrMenuRecord, keyof ISsrMenuRecord, unknown>[],
21
+ ssrMenuGroups: IOnionSlice<ISsrMenuGroupRecord, keyof ISsrMenuGroupRecord, unknown>[] = [],
22
+ ): ISsrMenuCatalog {
23
+ const menus: ISsrMenuCatalog['menus'] = [];
24
+ const groups: ISsrMenuCatalog['groups'] = [];
25
+ for (const { ssrSiteName } of sites) {
26
+ for (const ssrMenu of ssrMenus) {
27
+ const options = ssrMenu.beanOptions.options as
28
+ | IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>
29
+ | undefined;
30
+ if (!options || !checkSsrBinding(ssrSiteName, options.site)) continue;
31
+ const items = options.items || (options.item && { '': options.item });
32
+ if (!items) continue;
33
+ for (const key of Object.keys(items)) {
34
+ const item = items[key];
35
+ menus.push({
36
+ ssrSiteName,
37
+ ssrMenuName: key ? `${ssrMenu.name}#${key}` : ssrMenu.name,
38
+ onionName: ssrMenu.name,
39
+ roles: item.roles,
40
+ ...withoutUndefined({
41
+ title: item.title,
42
+ description: item.description,
43
+ icon: item.icon,
44
+ order: item.order,
45
+ group: item.group,
46
+ separator: item.separator,
47
+ link: item.link,
48
+ external: item.external,
49
+ target: item.target,
50
+ meta: item.meta,
51
+ }),
52
+ });
53
+ }
54
+ }
55
+ }
56
+ for (const { ssrSiteName } of sites) {
57
+ for (const ssrMenuGroup of ssrMenuGroups) {
58
+ const options = ssrMenuGroup.beanOptions.options as
59
+ | IDecoratorSsrMenuGroupOptions<IDecoratorSsrSiteOptions>
60
+ | undefined;
61
+ if (!options || !checkSsrBinding(ssrSiteName, options.site) || !options.item) {
62
+ continue;
63
+ }
64
+ const item = options.item;
65
+ groups.push({
66
+ ssrSiteName,
67
+ ssrMenuGroupName: ssrMenuGroup.name,
68
+ onionName: ssrMenuGroup.name,
69
+ title: item.title,
70
+ description: item.description,
71
+ icon: item.icon,
72
+ order: item.order,
73
+ group: item.group,
74
+ collapsed: item.collapsed,
75
+ });
76
+ }
77
+ }
78
+ return { sites: [...sites], menus, groups };
79
+ }
80
+
81
+ function withoutUndefined<T extends object>(value: T): T {
82
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
83
+ }
84
+
85
+ export function resolveSsrMenuEligibility(
86
+ ssrSiteName: string,
87
+ ssrMenuName: string,
88
+ ssrMenus: IOnionSlice<ISsrMenuRecord, keyof ISsrMenuRecord, unknown>[],
89
+ ): ISsrMenuEligibility | undefined {
90
+ for (const ssrMenu of ssrMenus) {
91
+ const options = ssrMenu.beanOptions.options as
92
+ | IDecoratorSsrMenuOptions<IDecoratorSsrSiteOptions>
93
+ | undefined;
94
+ if (!options || !checkSsrBinding(ssrSiteName, options.site)) continue;
95
+ const menus = options.items || (options.item && { '': options.item });
96
+ if (!menus) continue;
97
+ for (const key of Object.keys(menus)) {
98
+ const name = key ? `${ssrMenu.name}#${key}` : ssrMenu.name;
99
+ if (name !== ssrMenuName) continue;
100
+ return {
101
+ ssrSiteName,
102
+ ssrMenuName,
103
+ rolesDefined: menus[key].roles !== undefined,
104
+ };
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,91 @@
1
+ import type { IMenuGroup, IMenuItem } from 'vona-module-a-menu';
2
+
3
+ import type { ISsrMenuItemPrepared } from '../types/ssrMenu.ts';
4
+
5
+ export function resolveSsrMenuVisibilityDefault(
6
+ menus: ISsrMenuItemPrepared[],
7
+ checkRoleName: (roles: NonNullable<ISsrMenuItemPrepared['roles']>) => boolean,
8
+ ): ISsrMenuItemPrepared[] {
9
+ return menus.filter(menu => {
10
+ if (menu.roles === undefined) return true;
11
+ if (!menu.roles.length) return false;
12
+ return checkRoleName(menu.roles);
13
+ });
14
+ }
15
+
16
+ export function resolveVisibleSsrMenuGroups(
17
+ menus: ISsrMenuItemPrepared[],
18
+ groups: IMenuGroup[],
19
+ ): IMenuGroup[] {
20
+ const groupsByName = new Map(groups.map(group => [group.name, group]));
21
+ const visibleGroupNames = new Set<string>();
22
+ const retainGroup = (name: string) => {
23
+ const group = groupsByName.get(name);
24
+ if (!group || visibleGroupNames.has(name)) return;
25
+ visibleGroupNames.add(name);
26
+ for (const parentName of getGroupNames(group.group)) {
27
+ retainGroup(parentName);
28
+ }
29
+ };
30
+ for (const menu of menus) {
31
+ for (const groupName of getGroupNames(menu.group)) {
32
+ retainGroup(groupName);
33
+ }
34
+ }
35
+ return groups.filter(group => visibleGroupNames.has(group.name));
36
+ }
37
+
38
+ export function projectPublicSsrMenus(menus: ISsrMenuItemPrepared[]): IMenuItem[] {
39
+ return menus.map(menu => {
40
+ const {
41
+ name,
42
+ title,
43
+ description,
44
+ icon,
45
+ order,
46
+ group,
47
+ separator,
48
+ link,
49
+ external,
50
+ target,
51
+ meta,
52
+ } = menu;
53
+ return _withoutUndefined<IMenuItem>({
54
+ name,
55
+ title,
56
+ description,
57
+ icon,
58
+ order,
59
+ group,
60
+ separator,
61
+ link,
62
+ external,
63
+ target,
64
+ meta,
65
+ });
66
+ });
67
+ }
68
+
69
+ export function projectPublicSsrMenuGroups(groups: IMenuGroup[]): IMenuGroup[] {
70
+ return groups.map(group => {
71
+ const { name, title, description, icon, order, group: parentGroup, collapsed } = group;
72
+ return _withoutUndefined<IMenuGroup>({
73
+ name,
74
+ title,
75
+ description,
76
+ icon,
77
+ order,
78
+ group: parentGroup,
79
+ collapsed,
80
+ });
81
+ });
82
+ }
83
+
84
+ function _withoutUndefined<T extends { name: string }>(value: T): T {
85
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
86
+ }
87
+
88
+ function getGroupNames(group: string | string[] | undefined): string[] {
89
+ if (!group) return [];
90
+ return Array.isArray(group) ? group : [group];
91
+ }
@@ -3,9 +3,11 @@ import type { IOnionSlice } from 'vona-module-a-onion';
3
3
  import { BeanBase } from 'vona';
4
4
  import { Service } from 'vona-module-a-bean';
5
5
 
6
+ import type { ISsrMenuCatalog, ISsrMenuEligibility } from '../types/ssrMenu.ts';
6
7
  import type { IDecoratorSsrSiteOptions, ISsrSiteRecord } from '../types/ssrSite.ts';
7
8
 
8
9
  import { SymbolCacheSites } from '../lib/const.ts';
10
+ import { resolveSsrMenuCatalog, resolveSsrMenuEligibility } from '../lib/ssrMenuEligibility.ts';
9
11
 
10
12
  @Service()
11
13
  export class ServiceSsr extends BeanBase {
@@ -66,6 +68,34 @@ export class ServiceSsr extends BeanBase {
66
68
  return this.app.meta[SymbolCacheSites][cacheKey];
67
69
  }
68
70
 
71
+ public getMenuCatalog(): ISsrMenuCatalog {
72
+ const sites = this.getSitesEnabled().map(site => {
73
+ const options = site.beanOptions.options as IDecoratorSsrSiteOptions;
74
+ return {
75
+ ssrSiteName: site.name,
76
+ title: this.app.meta.text.locale(this.ctx.locale, options.title),
77
+ };
78
+ });
79
+ return resolveSsrMenuCatalog(
80
+ sites,
81
+ this.bean.onion.ssrMenu.getOnionsEnabled(),
82
+ this.bean.onion.ssrMenuGroup.getOnionsEnabled(),
83
+ );
84
+ }
85
+
86
+ public resolveMenuEligibility(
87
+ ssrSiteName: string,
88
+ ssrMenuName: string,
89
+ ): ISsrMenuEligibility | undefined {
90
+ const site = this.getSitesEnabled().find(item => item.name === ssrSiteName);
91
+ if (!site) return;
92
+ return resolveSsrMenuEligibility(
93
+ ssrSiteName,
94
+ ssrMenuName,
95
+ this.bean.onion.ssrMenu.getOnionsEnabled(),
96
+ );
97
+ }
98
+
69
99
  public prepareMenuLink(
70
100
  link?: keyof IDecoratorSsrSiteOptions['pages'],
71
101
  ): keyof IDecoratorSsrSiteOptions['pages'] | undefined {
@@ -18,11 +18,78 @@ export interface ISsrMenuItem<Pages extends {} = {}, Icons extends {} = {}> exte
18
18
 
19
19
  export type ISsrMenuItemPrepared = IMenuItem<any, any> & Pick<ISsrMenuItem<any, any>, 'roles'>;
20
20
 
21
+ export interface ISsrMenuEligibility {
22
+ ssrSiteName: string;
23
+ ssrMenuName: string;
24
+ rolesDefined: boolean;
25
+ }
26
+
27
+ export interface ISsrMenuCatalogSiteInfo {
28
+ ssrSiteName: string;
29
+ title: string;
30
+ }
31
+
32
+ export interface ISsrMenuCatalogMenu {
33
+ ssrSiteName: string;
34
+ ssrMenuName: string;
35
+ onionName: string;
36
+ roles?: (keyof IRoleNameRecord)[];
37
+ title?: IMenuItem['title'];
38
+ description?: IMenuItem['description'];
39
+ icon?: IMenuItem['icon'];
40
+ order?: IMenuItem['order'];
41
+ group?: IMenuItem['group'];
42
+ separator?: IMenuItem['separator'];
43
+ link?: IMenuItem['link'];
44
+ external?: IMenuItem['external'];
45
+ target?: IMenuItem['target'];
46
+ meta?: IMenuItem['meta'];
47
+ }
48
+
49
+ export interface ISsrMenuCatalogGroup {
50
+ ssrSiteName: string;
51
+ ssrMenuGroupName: string;
52
+ onionName: string;
53
+ title?: IMenuGroup['title'];
54
+ description?: IMenuGroup['description'];
55
+ icon?: IMenuGroup['icon'];
56
+ order?: IMenuGroup['order'];
57
+ group?: IMenuGroup['group'];
58
+ collapsed?: IMenuGroup['collapsed'];
59
+ }
60
+
61
+ export interface ISsrMenuCatalog {
62
+ sites: ISsrMenuCatalogSiteInfo[];
63
+ menus: ISsrMenuCatalogMenu[];
64
+ groups: ISsrMenuCatalogGroup[];
65
+ }
66
+
67
+ export interface ISsrMenuCatalogPresentationMenu extends IMenuItem<any, any> {
68
+ ssrSiteName: string;
69
+ ssrMenuName: string;
70
+ configurable: boolean;
71
+ }
72
+
73
+ export interface ISsrMenuCatalogPresentationGroup extends IMenuGroup<any> {
74
+ ssrSiteName: string;
75
+ }
76
+
77
+ export interface ISsrMenuCatalogPresentation {
78
+ menus: ISsrMenuCatalogPresentationMenu[];
79
+ groups: ISsrMenuCatalogPresentationGroup[];
80
+ }
81
+
21
82
  export interface ISsrMenusPrepared {
22
83
  menus?: ISsrMenuItemPrepared[];
23
84
  groups?: IMenuGroup[];
24
85
  }
25
86
 
87
+ export interface ISsrMenuCatalogSite {
88
+ ssrSiteName: string;
89
+ menus: ISsrMenuItemPrepared[];
90
+ groups: IMenuGroup[];
91
+ }
92
+
26
93
  // should not set default generic = IDecoratorSsrSiteOptions
27
94
  export interface IDecoratorSsrMenuOptions<
28
95
  SsrSiteOptions extends IDecoratorSsrSiteOptions,